diff --git a/README.md b/README.md index 112bc1e0..29e74eee 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,6 @@ export interface IGWProps { i18nLang?: string; i18nResources?: { [lang: string]: Record }; keepAlive?: boolean | string; - fieldKeyGuard?: boolean; vizThemeConfig?: IThemeKey; apperence?: IDarkMode; storeRef?: React.MutableRefObject; diff --git a/packages/duckdb-wasm-computation/src/index.ts b/packages/duckdb-wasm-computation/src/index.ts index 8e62f428..cbb2db5a 100644 --- a/packages/duckdb-wasm-computation/src/index.ts +++ b/packages/duckdb-wasm-computation/src/index.ts @@ -5,7 +5,7 @@ import duckdb_wasm from '@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm?url'; import mvp_worker from '@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js?url'; import duckdb_wasm_eh from '@duckdb/duckdb-wasm/dist/duckdb-eh.wasm?url'; import eh_worker from '@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js?url'; -import initWasm, { parser_dsl_with_table } from '@kanaries/gw-dsl-parser'; +import initWasm, { parser_dsl_with_meta, parser_dsl_with_table } from '@kanaries/gw-dsl-parser'; import dslWasm from '@kanaries/gw-dsl-parser/gw_dsl_parser_bg.wasm?url'; import { nanoid } from 'nanoid'; import type { IDataSourceProvider, IMutField, IDataSourceListener } from '@kanaries/graphic-walker'; @@ -51,7 +51,7 @@ const ArrowToJSON = (v: any): any => { if (typeof v === 'object') { if (v instanceof Vector) { return Array.from(v).map(ArrowToJSON); - } else { + } else if (v !== null) { return parseInt(bigNumToString(v as any)); } } @@ -68,6 +68,7 @@ const transformData = (table: Table) => { export async function getMemoryProvider(): Promise { await init(); const conn = await db.connect(); + const files: { id: string; content: any }[] = []; const datasets: { name: string; id: string }[] = []; const metaDict = new Map(); const specDict = new Map(); @@ -82,6 +83,7 @@ export async function getMemoryProvider(): Promise { const filename = `${id}.json`; await db.registerFileText(filename, JSON.stringify(data)); await conn.insertJSONFromPath(filename, { name: id }); + files.push({ id, content: data }); datasets.push({ id, name }); metaDict.set(id, meta); specDict.set(id, JSON.stringify([exportFullRaw(fromFields(meta, 'Chart 1'))])); @@ -101,7 +103,11 @@ export async function getMemoryProvider(): Promise { async getSpecs(datasetId) { const specs = specDict.get(datasetId); if (!specs) { - throw new Error('cannot find specs'); + const selectedDatasets: string[] = JSON.parse(datasetId); + const fields = selectedDatasets.flatMap((dataset) => metaDict.get(dataset)?.map((x) => ({ ...x, dataset })) ?? []); + const specs = JSON.stringify([exportFullRaw(fromFields(fields, 'Chart 1'))]); + specDict.set(datasetId, specs); + return specs; } return specs; }, @@ -110,7 +116,13 @@ export async function getMemoryProvider(): Promise { listeners.forEach((cb) => cb(4, datasetId)); }, async queryData(query, datasetIds) { - const sql = parser_dsl_with_table(datasetIds[0], JSON.stringify(query)); + let sql: string; + if (datasetIds.length === 1) { + sql = parser_dsl_with_table(datasetIds[0], JSON.stringify(query)); + } else { + const metas = Object.fromEntries(datasetIds.map((id) => [id, metaDict.get(id)!.map((x) => ({ key: x.fid, type: 'string' }))])); + sql = parser_dsl_with_meta(query.datasets[0], JSON.stringify(query), JSON.stringify(metas)); + } if (process.env.NODE_ENV !== 'production') { console.log(query, sql); } @@ -123,6 +135,40 @@ export async function getMemoryProvider(): Promise { listeners.filter((x) => x !== cb); }; }, + async onExportFile() { + const data = { + files, + datasets, + metaDict: Array.from(metaDict.entries()), + specDict: Array.from(specDict.entries()), + }; + const result = new Blob([JSON.stringify(data)], { type: 'text/plain' }); + return result; + }, + async onImportFile(file) { + const data = JSON.parse(await file.text()) as { + files: { + id: string; + content: any; + }[]; + datasets: { + name: string; + id: string; + }[]; + metaDict: [string, IMutField[]][]; + specDict: [string, string][]; + }; + files.push(...data.files); + for (const { id, content } of data.files) { + const filename = `${id}.json`; + await db.registerFileText(filename, JSON.stringify(content)); + await conn.insertJSONFromPath(filename, { name: id }); + } + data.datasets.forEach((x) => datasets.push(x)); + data.metaDict.forEach(([k, v]) => metaDict.set(k, v)); + data.specDict.forEach(([k, v]) => specDict.set(k, v)); + listeners.forEach((cb) => cb(1, '')); + }, }; } diff --git a/packages/graphic-walker/package.json b/packages/graphic-walker/package.json index cb0be39c..e9302945 100644 --- a/packages/graphic-walker/package.json +++ b/packages/graphic-walker/package.json @@ -48,7 +48,7 @@ }, "types": "./dist/index.d.ts", "dependencies": { - "@headlessui-float/react": "^0.11.4", + "@headlessui-float/react": "^0.13.2", "@headlessui/react": "1.7.12", "@heroicons/react": "^2.0.8", "@kanaries/react-beautiful-dnd": "^0.1.1", @@ -58,6 +58,7 @@ "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", diff --git a/packages/graphic-walker/src/App.tsx b/packages/graphic-walker/src/App.tsx index cf8f4531..5ecfc520 100644 --- a/packages/graphic-walker/src/App.tsx +++ b/packages/graphic-walker/src/App.tsx @@ -20,7 +20,6 @@ import GeoConfigPanel from './components/leafletRenderer/geoConfigPanel'; import AskViz from './components/askViz'; import { renderSpec } from './store/visualSpecStore'; import FieldsContextWrapper from './fields/fieldsContext'; -import { guardDataKeys } from './utils/dataPrep'; import { getComputation } from './computation/clientComputation'; import LogPanel from './fields/datasetFields/logPanel'; import BinPanel from './fields/datasetFields/binPanel'; @@ -41,7 +40,9 @@ import { VizAppContext } from './store/context'; import { Tabs, TabsList, TabsTrigger } from './components/ui/tabs'; import { ChartPieIcon, CircleStackIcon, ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline'; import { TabsContent } from '@radix-ui/react-tabs'; +import MultiDatasetFields from './fields/datasetFields/multi'; import { VegaliteChat } from './components/chat'; +import { LinkDataset } from './components/linkDataset'; export type BaseVizProps = IAppI18nProps & IVizProps & @@ -68,6 +69,7 @@ export const VizApp = observer(function VizApp(props: BaseVizProps) { chart, vlSpec, onError, + datasetNames, } = props; const { t, i18n } = useTranslation(); @@ -145,12 +147,13 @@ export const VizApp = observer(function VizApp(props: BaseVizProps) { return ( - Something went wrong} onError={props.onError}> + Something went wrong} onError={console.log}>
@@ -214,6 +217,7 @@ export const VizApp = observer(function VizApp(props: BaseVizProps) { + {vizStore.showGeoJSONConfigPanel && }
- + {!vizStore.isMultiDataset && } + {vizStore.isMultiDataset && } { if (data) { - if (props.fieldKeyGuard) { - const { safeData, safeMetas } = guardDataKeys(data, fields); - return { - safeMetas, - computation: getComputation(safeData), - onMetaChange: (safeFID, meta) => { - const index = safeMetas.findIndex((x) => x.fid === safeFID); - if (index >= 0) { - props.onMetaChange?.(fields[index].fid, meta); - } - }, - }; - } return { safeMetas: fields, computation: getComputation(data), @@ -319,7 +311,7 @@ export function VizAppWithContext(props: IVizAppProps & IComputationProps) { computation: props.computation, onMetaChange: props.onMetaChange, }; - }, [fields, data ? data : props.computation, props.fieldKeyGuard, props.onMetaChange]); + }, [fields, data ? data : props.computation, props.onMetaChange]); const darkMode = useCurrentMediaTheme(appearance); diff --git a/packages/graphic-walker/src/Renderer.tsx b/packages/graphic-walker/src/Renderer.tsx index 91daf397..a8a0f265 100644 --- a/packages/graphic-walker/src/Renderer.tsx +++ b/packages/graphic-walker/src/Renderer.tsx @@ -18,13 +18,12 @@ import ReactiveRenderer from './renderer/index'; import { ComputationContext, VizStoreWrapper, useCompututaion, useVizStore, withErrorReport, withTimeout } from './store'; import { mergeLocaleRes, setLocaleLanguage } from './locales/i18n'; import { renderSpec } from './store/visualSpecStore'; -import { guardDataKeys } from './utils/dataPrep'; import { getComputation } from './computation/clientComputation'; import { ErrorContext } from './utils/reportError'; import { ErrorBoundary } from 'react-error-boundary'; import Errorpanel from './components/errorpanel'; import { useCurrentMediaTheme } from './utils/media'; -import { classNames, getFilterMeaAggKey, parseErrorMessage } from './utils'; +import { classNames, getFieldIdentifier, getFilterMeaAggKey, isSameField, parseErrorMessage } from './utils'; import { VegaliteMapper } from './lib/vl2gw'; import { newChart } from './models/visSpecHistory'; import { SimpleOneOfSelector, SimpleRange, SimpleSearcher, SimpleTemporalRange } from './fields/filterField/simple'; @@ -138,6 +137,7 @@ export const RendererApp = observer(function VizApp(props: BaseVizProps) { themeContext={darkMode} vegaThemeContext={{ vizThemeConfig: vizThemeConfig ?? themeConfig ?? themeKey }} portalContainerContext={portal} + DatasetNamesContext={props.datasetNames} >
@@ -170,12 +170,12 @@ const FilterItem = observer(function FilterItem({ filter, onChange }: { filter: const computation = useCompututaion(); - const originalField = filter.enableAgg ? allFields.find((x) => x.fid === filter.fid) : undefined; + const originalField = filter.enableAgg ? allFields.find(isSameField(filter)) : undefined; const filterAggName = filter?.enableAgg ? filter.aggName : undefined; const transformedComputation = useMemo((): IComputationFunction => { if (originalField && viewDimensions.length > 0) { - const preWorkflow = toWorkflow( + const { workflow, datasets } = toWorkflow( [], allFields, viewDimensions, @@ -185,24 +185,26 @@ const FilterItem = observer(function FilterItem({ filter, onChange }: { filter: [], undefined, timezoneDisplayOffset - ).map((x) => { - if (x.type === 'view') { - return { - ...x, - query: x.query.map((q) => { - if (q.op === 'aggregate') { - return { ...q, measures: q.measures.map((m) => ({ ...m, asFieldKey: m.field })) }; - } - return q; - }), - }; - } - return x; - }); + ); return (query) => computation({ ...query, - workflow: preWorkflow.concat(query.workflow.filter((x) => x.type !== 'transform')), + workflow: workflow + .map((x) => { + if (x.type === 'view') { + return { + ...x, + query: x.query.map((q) => { + if (q.op === 'aggregate') { + return { ...q, measures: q.measures.map((m) => ({ ...m, asFieldKey: m.field })) }; + } + return q; + }), + }; + } + return x; + }) + .concat(query.workflow.filter((x) => x.type !== 'transform')), }); } else { return computation; @@ -264,7 +266,7 @@ const FilterSection = observer(function FilterSection() { return (
{vizStore.viewFilters.map((filter, idx) => ( - handleWriteFilter(idx, rule)} /> + handleWriteFilter(idx, rule)} /> ))}
); @@ -273,7 +275,7 @@ const FilterSection = observer(function FilterSection() { export function RendererAppWithContext( props: IVizAppProps & IComputationProps & { overrideSize?: IVisualLayout['size']; containerClassName?: string; containerStyle?: React.CSSProperties } ) { - const { dark, dataSource, computation, onMetaChange, fieldKeyGuard, keepAlive, storeRef, defaultConfig, ...rest } = props; + const { dark, dataSource, computation, onMetaChange, keepAlive, storeRef, defaultConfig, ...rest } = props; // @TODO remove deprecated props const appearance = props.appearance ?? props.dark; const data = props.data ?? props.dataSource; @@ -285,19 +287,6 @@ export function RendererAppWithContext( onMetaChange: safeOnMetaChange, } = useMemo(() => { if (data) { - if (props.fieldKeyGuard) { - const { safeData, safeMetas } = guardDataKeys(data, fields); - return { - safeMetas, - computation: getComputation(safeData), - onMetaChange: (safeFID, meta) => { - const index = safeMetas.findIndex((x) => x.fid === safeFID); - if (index >= 0) { - props.onMetaChange?.(fields[index].fid, meta); - } - }, - }; - } return { safeMetas: fields, computation: getComputation(data), @@ -309,7 +298,7 @@ export function RendererAppWithContext( computation: props.computation, onMetaChange: props.onMetaChange, }; - }, [fields, data ? data : props.computation, props.fieldKeyGuard, props.onMetaChange]); + }, [fields, data ? data : props.computation, props.onMetaChange]); const darkMode = useCurrentMediaTheme(appearance); diff --git a/packages/graphic-walker/src/Table.tsx b/packages/graphic-walker/src/Table.tsx index 9ccf452c..b10df564 100644 --- a/packages/graphic-walker/src/Table.tsx +++ b/packages/graphic-walker/src/Table.tsx @@ -4,16 +4,17 @@ import { ErrorBoundary } from 'react-error-boundary'; import { useTranslation } from 'react-i18next'; import { IAppI18nProps, IErrorHandlerProps, IComputationContextProps, ITableProps, ITableSpecProps, IComputationProps, IMutField } from './interfaces'; import { mergeLocaleRes, setLocaleLanguage } from './locales/i18n'; -import { useVizStore, withErrorReport, withTimeout, ComputationContext, VizStoreWrapper } from './store'; -import { parseErrorMessage } from './utils'; +import { useVizStore, withErrorReport, withTimeout, VizStoreWrapper } from './store'; +import { getFieldIdentifier, parseErrorMessage } from './utils'; import { ErrorContext } from './utils/reportError'; -import { guardDataKeys } from './utils/dataPrep'; import { getComputation } from './computation/clientComputation'; -import DatasetTable from './components/dataTable'; import { useCurrentMediaTheme } from './utils/media'; import { toJS } from 'mobx'; import Errorpanel from './components/errorpanel'; import { VizAppContext } from './store/context'; +import { DEFAULT_DATASET } from './constants'; +import DataTable from './components/dataTable'; +import { Tabs, TabsList, TabsTrigger } from './components/ui/tabs'; export type BaseTableProps = IAppI18nProps & IErrorHandlerProps & @@ -71,8 +72,13 @@ export const TableApp = observer(function VizApp(props: BaseTableProps) { ); const metas = toJS(vizStore.meta); + const [portal, setPortal] = useState(null); + const datasets = Array.from(new Set(metas.map((x) => x.dataset ?? DEFAULT_DATASET))); + const [dataset, setDataset] = useState(datasets[0] ?? DEFAULT_DATASET); + const tableMeta = metas.filter((x) => dataset === (x.dataset ?? DEFAULT_DATASET)); + return ( Something went wrong
} onError={props.onError}> @@ -81,17 +87,33 @@ export const TableApp = observer(function VizApp(props: BaseTableProps) { themeContext={darkMode} vegaThemeContext={{ vizThemeConfig: vizThemeConfig ?? themeConfig ?? themeKey }} portalContainerContext={portal} + DatasetNamesContext={props.datasetNames} > -
-
- { - vizStore.updateCurrentDatasetMetas(fid, diffMeta); - } : undefined} +
+
+ {datasets.length > 1 && ( + + + {datasets.map((ds) => ( + {props.datasetNames?.[ds] ?? ds} + ))} + + + )} + { + vizStore.updateCurrentDatasetMetas(getFieldIdentifier(metas[fIndex]), diffMeta); + } + : undefined + } size={pageSize} - metas={metas} + metas={tableMeta} computation={wrappedComputation} displayOffset={props.displayOffset} + hidePaginationAtOnepage={props.hidePaginationAtOnepage} + hideProfiling={props.hideProfiling} />
@@ -104,7 +126,7 @@ export const TableApp = observer(function VizApp(props: BaseTableProps) { }); export function TableAppWithContext(props: ITableProps & IComputationProps) { - const { dark, dataSource, computation, onMetaChange, fieldKeyGuard, keepAlive, storeRef, defaultConfig, ...rest } = props; + const { dark, dataSource, computation, onMetaChange, keepAlive, storeRef, defaultConfig, ...rest } = props; // @TODO remove deprecated props const appearance = props.appearance ?? props.dark; const data = props.data ?? props.dataSource; @@ -116,21 +138,6 @@ export function TableAppWithContext(props: ITableProps & IComputationProps) { onMetaChange: safeOnMetaChange, } = useMemo(() => { if (data) { - if (props.fieldKeyGuard) { - const { safeData, safeMetas } = guardDataKeys(data, fields); - return { - safeMetas, - computation: getComputation(safeData), - onMetaChange: onMetaChange - ? (safeFID, meta) => { - const index = safeMetas.findIndex((x) => x.fid === safeFID); - if (index >= 0) { - onMetaChange(fields[index].fid, meta); - } - } - : undefined, - }; - } return { safeMetas: fields, computation: getComputation(data), @@ -142,7 +149,7 @@ export function TableAppWithContext(props: ITableProps & IComputationProps) { computation: props.computation, onMetaChange, }; - }, [fields, data ? data : props.computation, props.fieldKeyGuard, onMetaChange]); + }, [fields, data ? data : props.computation, onMetaChange]); const darkMode = useCurrentMediaTheme(appearance); diff --git a/packages/graphic-walker/src/components/chat/index.tsx b/packages/graphic-walker/src/components/chat/index.tsx index da5f8b8b..d9857f72 100644 --- a/packages/graphic-walker/src/components/chat/index.tsx +++ b/packages/graphic-walker/src/components/chat/index.tsx @@ -14,6 +14,9 @@ import { useReporter } from '@/utils/reportError'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'; import { Textarea } from '../ui/textarea'; import LoadingLayer from '../loadingLayer'; +import { transformMultiDatasetFields } from '@/utils/route'; +import { viewEncodingKeys } from '@/models/visSpec'; +import { emptyEncodings } from '@/utils/save'; async function fetchQueryChat(api: string, metas: IViewField[], messages: IChatMessage[], headers: Record) { const res = await fetch(api, { @@ -116,6 +119,23 @@ const AssistantMessage = observer(function AssistantMessage(props: { message: IA return { allFields, viewDimensions, viewMeasures, filters }; }, [encodings]); + const multiViewInfo = useMemo(() => { + const viewsEncodings: Partial> = {}; + viewEncodingKeys(config.geoms[0]).forEach((k) => { + viewsEncodings[k] = encodings[k]; + }); + + const { filters, views } = transformMultiDatasetFields({ + filters: encodings.filters, + views: viewsEncodings, + }); + return { + ...emptyEncodings, + ...views, + filters, + }; + }, [encodings, config.geoms]); + const { viewData: data, loading: waiting } = useRenderer({ allFields, viewDimensions, @@ -149,7 +169,7 @@ const AssistantMessage = observer(function AssistantMessage(props: { message: IA vizThemeConfig={vizThemeConfig} name={name} data={data} - draggableFieldState={encodings} + draggableFieldState={multiViewInfo} visualConfig={config} layout={{ ...layout, diff --git a/packages/graphic-walker/src/components/computedField/index.tsx b/packages/graphic-walker/src/components/computedField/index.tsx index d3ebcb58..583bafca 100644 --- a/packages/graphic-walker/src/components/computedField/index.tsx +++ b/packages/graphic-walker/src/components/computedField/index.tsx @@ -1,14 +1,15 @@ import { observer } from 'mobx-react-lite'; -import React, { useState, useRef, useMemo, useEffect } from 'react'; -import { useVizStore } from '../../store'; -import { isNotEmpty, parseErrorMessage } from '../../utils'; +import React, { useState, useRef, useMemo, useContext, useEffect } from 'react'; +import { DatasetNamesContext, useVizStore } from '../../store'; +import { getFieldIdentifier, isNotEmpty, parseErrorMessage } from '../../utils'; import { highlightField } from '../highlightField'; import { aggFuncs, reservedKeywords, sqlFunctions } from '../../lib/sql'; -import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID, PAINT_FIELD_ID } from '../../constants'; +import { COUNT_FIELD_ID, DEFAULT_DATASET, EMPTY_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID, PAINT_FIELD_ID } from '../../constants'; import { unstable_batchedUpdates } from 'react-dom'; import { Dialog, DialogContent } from '../ui/dialog'; import { Input } from '../ui/input'; import { Button } from '../ui/button'; +import Combobox from '../dropdownSelect/combobox'; const keywordRegex = new RegExp(`\\b(${Array.from(reservedKeywords).join('|')})\\b`, 'gi'); const bulitInRegex = new RegExp(`\\b(${Array.from(sqlFunctions).join('|')})(\\s*)\\(`, 'gi'); @@ -18,15 +19,18 @@ const stringRegex = /('[^']*'?)/g; const ComputedFieldDialog: React.FC = observer(() => { const vizStore = useVizStore(); - const { editingComputedFieldFid } = vizStore; + const { editingComputedFieldFid, datasets, isMultiDataset } = vizStore; const [name, setName] = useState(''); const [sql, setSql] = useState(''); const [error, setError] = useState(''); const ref = useRef(null); + const [dataset, setDataset] = useState(DEFAULT_DATASET); + const datasetNames = useContext(DatasetNamesContext); const SQLField = useMemo(() => { const fields = vizStore.allFields .filter((x) => ![COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID, PAINT_FIELD_ID].includes(x.fid)) + .filter((x) => !isMultiDataset || x.dataset === dataset) .map((x) => x.name.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')) .join('|'); const fieldRegex = fields.length > 0 ? new RegExp(`\\b(${fields})\\b`, 'gi') : null; @@ -50,7 +54,7 @@ const ComputedFieldDialog: React.FC = observer(() => { return sql; }); - }, [vizStore.allFields]); + }, [vizStore.allFields, dataset]); useEffect(() => { if (isNotEmpty(editingComputedFieldFid)) { @@ -60,23 +64,25 @@ const ComputedFieldDialog: React.FC = observer(() => { idx++; } unstable_batchedUpdates(() => { + setDataset(datasets[0]); setName(`Computed ${idx}`); setSql(''); setError(''); }); ref.current && (ref.current.innerHTML = ''); } else { - const f = vizStore.allFields.find((x) => x.fid === editingComputedFieldFid); + const f = vizStore.allFields.find((x) => getFieldIdentifier(x) === editingComputedFieldFid); if (!f || !f.computed || f.expression?.op !== 'expr') { - vizStore.setComputedFieldFid(''); + vizStore.setComputedFieldFid(EMPTY_FIELD_ID); return; } const sql = f.expression.params.find((x) => x.type === 'sql'); if (!sql) { - vizStore.setComputedFieldFid(''); + vizStore.setComputedFieldFid(EMPTY_FIELD_ID); return; } unstable_batchedUpdates(() => { + setDataset(datasets[0]); setName(f.name); setSql(sql.value); setError(''); @@ -110,6 +116,16 @@ const ComputedFieldDialog: React.FC = observer(() => {
+ {isMultiDataset && ( +
+ + ({ label: datasetNames?.[dataset] ?? dataset, value: dataset }))} + selectedKey={dataset} + onSelect={(v) => v && setDataset(v)} + /> +
+ )}
{ children={editingComputedFieldFid === '' ? 'Add' : 'Edit'} onClick={() => { try { - vizStore.upsertComputedField(editingComputedFieldFid!, name, sql); + vizStore.upsertComputedField(editingComputedFieldFid!, name, sql, dataset); vizStore.setComputedFieldFid(); } catch (e) { setError(parseErrorMessage(e)); diff --git a/packages/graphic-walker/src/components/dataBoard.tsx b/packages/graphic-walker/src/components/dataBoard.tsx index dafa630f..c39aa0ad 100644 --- a/packages/graphic-walker/src/components/dataBoard.tsx +++ b/packages/graphic-walker/src/components/dataBoard.tsx @@ -1,52 +1,131 @@ import { observer } from 'mobx-react-lite'; -import { useCompututaion, useVizStore } from '../store'; +import { DatasetNamesContext, useCompututaion, useVizStore } from '../store'; import DataTable from './dataTable'; -import React, { useMemo } from 'react'; -import { IComputationFunction, IVisFilter } from '../interfaces'; -import { addFilterForQuery, addTransformForQuery, processExpression } from '../utils/workflow'; +import React, { useContext, useMemo } from 'react'; +import { IComputationFunction, IJoinWorkflowStep, IViewField, IVisFilter } from '../interfaces'; +import { addFilterForQuery, addJoinForQuery, addTransformForQuery, changeDatasetForQuery, processExpression } from '../utils/workflow'; import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from '../constants'; -import { isNotEmpty } from '../utils'; +import { deduper, getFieldIdentifier, isNotEmpty } from '../utils'; import { Dialog, DialogContent } from './ui/dialog'; -import { toJS } from "mobx"; +import { computed, toJS } from 'mobx'; const DataBoard = observer(function DataBoardModal() { const vizStore = useVizStore(); const computation = useCompututaion(); - const { showDataBoard, selectedMarkObject, allFields, config, viewFilters } = vizStore; - const filters = useMemo(() => { - const mark = toJS(selectedMarkObject); - const entries: [string, any][] = Object.entries(mark).filter( - (x): x is [string, any] => ![MEA_KEY_ID, MEA_VAL_ID, COUNT_FIELD_ID].includes(x[0]) && isNotEmpty(x[1]) - ); - if (isNotEmpty(mark[MEA_KEY_ID]) && isNotEmpty(mark[MEA_VAL_ID])) { - entries.push([mark[MEA_KEY_ID] as string, mark[MEA_VAL_ID]]); - } - return entries.map(([k, v]): IVisFilter => ({ fid: k, rule: { type: 'one of', value: [v] } })); - }, [selectedMarkObject]); - const computedFileds = useMemo(() => allFields.filter((x) => x.fid !== COUNT_FIELD_ID && x.computed && x.expression && x.aggName !== 'expr'), [allFields]); + const { + showDataBoard, + selectedMarkObject, + allFields, + config, + multiViewInfo, + viewFilters, + workflow: { workflow }, + } = vizStore; + + const datasetNames = useContext(DatasetNamesContext); + + const joinWorkflow = useMemo(() => workflow.filter((x): x is IJoinWorkflowStep => x.type === 'join'), [workflow]); + const viewDatasets = useMemo( + () => + deduper( + joinWorkflow.flatMap((s) => + s.foreigns.flatMap((f) => { + return f.keys.map((x) => ({ + dataset: x.dataset, + as: x.as, + })); + }) + ), + (x) => x.as + ), + [joinWorkflow] + ); + const meta: IViewField[] = useMemo( + () => + viewDatasets.length > 0 + ? viewDatasets + .flatMap(({ dataset, as }) => + allFields + .filter((x) => x.dataset === dataset) + .filter((x) => !x.computed) + .map( + (f): IViewField => ({ + ...f, + basename: f.name, + path: [datasetNames?.[dataset] ?? dataset, ...(f.path ?? [f.fid])], + fid: `${as}.${f.fid}`, + }) + ) + .concat( + Object.values(multiViewInfo.views) + .flat() + .filter((x) => x.dataset === dataset && x.fid !== COUNT_FIELD_ID && x.computed && x.expression && x.aggName !== 'expr') + ) + ) + .concat(allFields.filter((x) => !x.dataset)) + : allFields, + [viewDatasets, multiViewInfo.views, allFields] + ); + + const filters = useMemo( + () => + computed(() => { + const mark = toJS(selectedMarkObject); + const entries: [string, any][] = Object.entries(mark).filter( + (x): x is [string, any] => ![MEA_KEY_ID, MEA_VAL_ID, COUNT_FIELD_ID].includes(x[0]) && isNotEmpty(x[1]) + ); + if (isNotEmpty(mark[MEA_KEY_ID]) && isNotEmpty(mark[MEA_VAL_ID])) { + entries.push([mark[MEA_KEY_ID] as string, mark[MEA_VAL_ID]]); + } + return entries.map(([k, v]): IVisFilter => ({ fid: k, rule: { type: 'one of', value: [v] } })); + }), + [selectedMarkObject] + ).get(); + + const computedFileds = useMemo( + () => + viewDatasets.length > 0 + ? Object.values(multiViewInfo.views) + .flat() + .filter((x) => x.fid !== COUNT_FIELD_ID && x.computed && x.expression && x.aggName !== 'expr') + : allFields.filter((x) => x.computed && x.expression && x.aggName !== 'expr'), + [viewDatasets, allFields, multiViewInfo.views] + ); const filteredComputation = useMemo((): IComputationFunction => { return (query) => computation( - addTransformForQuery( - addFilterForQuery( - query, - viewFilters - .map((f) => ({ fid: f.fid, rule: f.rule })) - .filter((x): x is IVisFilter => !!x.rule) - .concat(filters) + changeDatasetForQuery( + addJoinForQuery( + addTransformForQuery( + addFilterForQuery( + query, + viewFilters + .map((f) => ({ fid: f.fid, rule: f.rule })) + .filter((x): x is IVisFilter => !!x.rule) + .concat(filters) + ), + computedFileds.map((x) => ({ + expression: processExpression(x.expression!, allFields, { + timezoneDisplayOffset: config.timezoneDisplayOffset, + transformFid: multiViewInfo.processFid(x.joinPath), + }), + key: getFieldIdentifier(x), + })) + ), + joinWorkflow ), - computedFileds.map((x) => ({ - expression: processExpression(x.expression!, allFields, config), - key: x.fid!, - })) + deduper( + viewDatasets.map((x) => x.dataset), + (x) => x + ) ) ); }, [computation, filters, computedFileds, allFields, config]); const metas = useMemo(() => { - return allFields.filter((x) => x.aggName !== 'expr').filter((x) => ![MEA_KEY_ID, MEA_VAL_ID, COUNT_FIELD_ID].includes(x.fid)); - }, [allFields]); + return meta.filter((x) => x.aggName !== 'expr').filter((x) => ![MEA_KEY_ID, MEA_VAL_ID, COUNT_FIELD_ID].includes(x.fid)); + }, [meta]); return ( ) => void; disableFilter?: boolean; + hideProfiling?: boolean; + hidePaginationAtOnepage?: boolean; displayOffset?: number; } const Container = styled.div` overflow-x: auto; + height: 100%; + display: flex; + flex-direction: column; table { box-sizing: content-box; border-collapse: collapse; @@ -86,9 +93,9 @@ type wrapMutField = { const getHeaders = (metas: IMutField[]): wrapMutField[][] => { const height = metas.map((x) => x.path?.length ?? 1).reduce((a, b) => Math.max(a, b), 0); const result: wrapMutField[][] = [...Array(height)].map(() => []); - let now = 1; metas.forEach((x, fIndex) => { const path = x.path ?? [x.name ?? x.fid]; + const now = path.findIndex((p, i) => !(result[i] && result[i].at(-1)?.value === p)) + 1; if (path.length > now) { for (let i = now - 1; i < path.length - 1; i++) { result[i].push({ @@ -99,7 +106,6 @@ const getHeaders = (metas: IMutField[]): wrapMutField[][] => { }); } } - now = path.length; for (let i = 0; i < path.length - 1; i++) { result[i][result[i].length - 1].colSpan++; } @@ -125,22 +131,23 @@ function useFilters(metas: IMutField[]) { const [filters, setFilters] = useState([]); const [editingFilterIdx, setEditingFilterIdx] = useState(null); const options = useMemo(() => { - return metas.map((x) => ({ label: x.name ?? x.fid, value: x.fid })); + return metas.map((x) => ({ label: x.name ?? x.fid, value: getFieldIdentifier(x) })); }, [metas]); const onSelectFilter = useCallback( - (fid: string) => { - const i = filters.findIndex((x) => x.fid === fid); + (fid: FieldIdentifier) => { + const i = filters.findIndex((x) => getFieldIdentifier(x) === fid); if (i > -1) { setEditingFilterIdx(i); } else { - const meta = metas.find((x) => x.fid === fid); + const meta = metas.find((x) => getFieldIdentifier(x) === fid); if (!meta) return; const newFilter: IFilterField = { - fid, + fid: meta.fid, rule: null, analyticType: meta.analyticType, name: meta.name ?? meta.fid, semanticType: meta.semanticType, + dataset: meta.dataset, }; if (editingFilterIdx === null || !filters[editingFilterIdx]) { setFilters(filters.concat(newFilter)); @@ -164,16 +171,70 @@ function useFilters(metas: IMutField[]) { return { filters, options, editingFilterIdx, onSelectFilter, onDeleteFilter, onWriteFilter, onClose }; } -function FieldValue(props: { field: IMutField; item: IRow; displayOffset?: number }) { +function fieldValue(props: { field: IMutField; item: IRow; displayOffset?: number }) { const { field, item } = props; if (field.semanticType === 'temporal') { - return <>{formatDate(parsedOffsetDate(props.displayOffset, field.offset)(item[field.fid]))}; + return formatDate(parsedOffsetDate(props.displayOffset, field.offset)(item[field.fid])); } - return <>{`${item[field.fid]}`}; + return `${item[field.fid]}`; +} + +function CopyButton(props: { value: string }) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (copied) { + const timer = setTimeout(() => { + setCopied(false); + }, 2000); + return () => { + clearTimeout(timer); + }; + } + }, [copied]); + return ( + + ); +} + +function TruncateDector(props: { value: string }) { + const ref = useRef(null); + const [isTruncate, setIsTruncate] = useState(false); + const [open, setOpen] = useState(false); + useEffect(() => { + if (ref.current) { + setIsTruncate(ref.current.offsetWidth < ref.current.scrollWidth); + } + }, [ref.current]); + return ( + + + {props.value} + + +

{props.value}

+ +
+
+ ); } const DataTable: React.FC = (props) => { - const { size = 10, onMetaChange, metas, computation, disableFilter, displayOffset } = props; + const { size = 10, onMetaChange, metas, computation, disableFilter, displayOffset, hidePaginationAtOnepage, hideProfiling } = props; const [pageIndex, setPageIndex] = useState(0); const { t } = useTranslation(); const computationFunction = computation; @@ -189,13 +250,15 @@ const DataTable: React.FC = (props) => { const [dataLoading, setDataLoading] = useState(false); const taskIdRef = useRef(0); - const [sorting, setSorting] = useState<{ fid: string; sort: 'ascending' | 'descending' } | undefined>(); + const [sorting, setSorting] = useState<{ fid: FieldIdentifier; sort: 'ascending' | 'descending' } | undefined>(); const { filters, editingFilterIdx, onClose, onDeleteFilter, onSelectFilter, onWriteFilter, options } = useFilters(metas); const [total, setTotal] = useState(0); const [statLoading, setStatLoading] = useState(false); + const datasets = useMemo(() => Array.from(new Set(metas.map((x) => x.dataset ?? DEFAULT_DATASET))), [metas]); + // Get count when filter changed useEffect(() => { const f = filters.filter((x) => x.rule).map((x) => ({ ...x, rule: x.rule })); @@ -227,11 +290,12 @@ const DataTable: React.FC = (props) => { ], }, ], + datasets, }).then((v) => { setTotal(v[0]?.count ?? 0); setStatLoading(false); }); - }, [disableFilter, filters, computation]); + }, [disableFilter, filters, computation, datasets]); const from = pageIndex * size; const to = Math.min((pageIndex + 1) * size - 1, total - 1); @@ -245,10 +309,23 @@ const DataTable: React.FC = (props) => { useEffect(() => { setDataLoading(true); const taskId = ++taskIdRef.current; - dataReadRaw(computationFunction, size, pageIndex, { - sorting, - filters: filters.filter((x) => x.rule).map((x) => ({ ...x, rule: x.rule! })), - }) + const sortingItem = sorting && metas.find((x) => getFieldIdentifier(x) === sorting.fid); + dataReadRaw( + computationFunction, + metas.map((m) => m.fid), + size, + datasets, + pageIndex, + { + sorting: sortingItem + ? { + fid: sortingItem.fid, + sort: sorting.sort, + } + : undefined, + filters: filters.filter((x) => x.rule).map((x) => ({ ...x, rule: x.rule! })), + } + ) .then((data) => { if (taskId === taskIdRef.current) { setDataLoading(false); @@ -265,10 +342,10 @@ const DataTable: React.FC = (props) => { return () => { taskIdRef.current++; }; - }, [computationFunction, pageIndex, size, sorting, filters]); + }, [computationFunction, pageIndex, size, sorting, filters, datasets]); const filteredComputation = useMemo((): IComputationFunction => { - const filterRules = filters.filter((f) => f.rule).map(createFilter); + const filterRules = filters.filter((f) => f.rule).map((f) => createFilter(f)); return (query) => computation(addFilterForQuery(query, filterRules)); }, [computation, filters]); @@ -297,36 +374,42 @@ const DataTable: React.FC = (props) => {
Filters: {filters.map((x, i) => ( - onSelectFilter(x.fid)} onRemove={() => onDeleteFilter(i)} /> + onSelectFilter(getFieldIdentifier(x))} + onRemove={() => onDeleteFilter(i)} + /> ))}
)} - - -
+ {!(hidePaginationAtOnepage && total <= size) && ( + + )} +
@@ -336,7 +419,7 @@ const DataTable: React.FC = (props) => { ))} - - {metas.map((field) => ( - - ))} - + {!props.hideProfiling && ( + + {metas.map((field) => ( + + ))} + + )} {rows.map((row, index) => ( - {metas.map((field) => ( - - ))} + {metas.map((field) => { + const value = fieldValue({ field, item: row, displayOffset }); + return ( + + ); + })} ))} diff --git a/packages/graphic-walker/src/components/dataTable/pagination.tsx b/packages/graphic-walker/src/components/dataTable/pagination.tsx index b944d6a5..d8440fcf 100644 --- a/packages/graphic-walker/src/components/dataTable/pagination.tsx +++ b/packages/graphic-walker/src/components/dataTable/pagination.tsx @@ -91,7 +91,7 @@ export default function Pagination(props: PaginationProps) { )} - {showIndices.slice(1, -1).map((page) => pageButton(page.index))} + {showIndices.slice(1, showIndices.length > 3 ? -1 : undefined).map((page) => pageButton(page.index))} {showIndices.length > 2 && showIndices[showIndices.length - 1].index > showIndices[showIndices.length - 2].index + 1 && ( diff --git a/packages/graphic-walker/src/components/dataTable/profiling.tsx b/packages/graphic-walker/src/components/dataTable/profiling.tsx index 547d9e1f..b220603f 100644 --- a/packages/graphic-walker/src/components/dataTable/profiling.tsx +++ b/packages/graphic-walker/src/components/dataTable/profiling.tsx @@ -13,13 +13,19 @@ import { parseColorToHSL } from '@/utils/colors'; export interface FieldProfilingProps { field: string; + dataset: string; computation: IComputationFunction; } -function NominalProfiling({ computation, field, valueRenderer = (s) => `${s}` }: FieldProfilingProps & { valueRenderer?: (v: string | number) => string }) { +function NominalProfiling({ + computation, + field, + dataset, + valueRenderer = (s) => `${s}`, +}: FieldProfilingProps & { valueRenderer?: (v: string | number) => string }) { const [stat, setStat] = useState>>(); useEffect(() => { - profileNonmialField(computation, field).then(setStat); + profileNonmialField(computation, field, dataset).then(setStat); }, [computation, field]); if (!isNotEmpty(stat)) { @@ -52,7 +58,7 @@ function NominalProfiling({ computation, field, valueRenderer = (s) => `${s}` }: return (
-
{displayValue}
+
{displayValue}
{Math.floor((100 * count) / meta.total)}%
@@ -82,10 +88,10 @@ function NominalProfiling({ computation, field, valueRenderer = (s) => `${s}` }: const formatter = format('~s'); -function QuantitativeProfiling({ computation, field }: FieldProfilingProps) { +function QuantitativeProfiling({ computation, field, dataset }: FieldProfilingProps) { const [stat, setStat] = useState>>(); useEffect(() => { - profileQuantitativeField(computation, field).then(setStat); + profileQuantitativeField(computation, field, dataset).then(setStat); }, [computation, field]); if (!isNotEmpty(stat)) { return
Loading...
; diff --git a/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx b/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx index 125e6680..1bc8fc2f 100644 --- a/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx +++ b/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx @@ -6,16 +6,16 @@ import { Command, CommandInput, CommandEmpty, CommandGroup, CommandItem } from ' import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'; import { ScrollArea } from '../ui/scroll-area'; import React from 'react'; -import { IDropdownSelectProps } from '.'; +import { IDropdownSelectOption, IDropdownSelectProps } from '.'; -function Combobox({ +function Combobox({ options = [], selectedKey: value, onSelect, className, popClassName, placeholder = 'Select A Value', -}: IDropdownSelectProps & { popClassName?: string }) { +}: IDropdownSelectProps & { popClassName?: string }) { const [open, setOpen] = useState(false); const selectedKey = value || '_none'; @@ -40,11 +40,11 @@ function Combobox({ { - if (currentValue === '_none') { - onSelect?.(''); + onSelect={() => { + if (opt.value === '_none') { + onSelect?.('' as T); } else { - onSelect?.(currentValue === selectedKey ? '' : currentValue); + onSelect?.(opt.value === selectedKey ? '' as T : opt.value); } setOpen(false); }} @@ -61,4 +61,66 @@ function Combobox({ ); } +export function MultiCombobox({ + options = [], + selectedKeys, + onSelect, + className, + popClassName, + placeholder = 'Select A Value', +}: { + options?: IDropdownSelectOption[]; + disable?: boolean; + selectedKeys: string[]; + onSelect?: (value: string[]) => void; + placeholder?: string; + className?: string; + buttonClassName?: string; + popClassName?: string; + children?: React.ReactNode | Iterable; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + No options found. + + + {options.map((opt) => ( + { + onSelect?.( + selectedKeys.includes(opt.value) ? selectedKeys.filter((k) => k !== opt.value) : selectedKeys.concat(opt.value) + ); + setOpen(false); + }} + > + {opt.label} + + + ))} + + + + + + ); +} + export default Combobox; diff --git a/packages/graphic-walker/src/components/dropdownSelect/index.tsx b/packages/graphic-walker/src/components/dropdownSelect/index.tsx index 6d825660..cc7a7109 100644 --- a/packages/graphic-walker/src/components/dropdownSelect/index.tsx +++ b/packages/graphic-walker/src/components/dropdownSelect/index.tsx @@ -1,21 +1,21 @@ import React from 'react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; -export interface IDropdownSelectOption { +export interface IDropdownSelectOption { label: React.ReactNode; - value: string; + value: T; } -export interface IDropdownSelectProps { - options?: IDropdownSelectOption[]; +export interface IDropdownSelectProps { + options?: IDropdownSelectOption[]; disable?: boolean; - selectedKey: string; - onSelect?: (value: string) => void; + selectedKey: T; + onSelect?: (value: T) => void; placeholder?: string; className?: string; buttonClassName?: string; children?: React.ReactNode | Iterable; } -const DropdownSelect: React.FC = (props) => { +const DropdownSelect = function (props: IDropdownSelectProps) { const { options = [], disable, selectedKey, onSelect, placeholder = 'Select an option', className } = props; return (
@@ -386,14 +469,14 @@ const DataTable: React.FC = (props) => { className="inline-block" onClick={() => setSorting((s) => { - if (s?.fid === f.value.fid && s.sort === 'descending') { + if (s?.fid === getFieldIdentifier(f.value) && s.sort === 'descending') { return { - fid: f.value.fid, + fid: getFieldIdentifier(f.value), sort: 'ascending', }; } return { - fid: f.value.fid, + fid: getFieldIdentifier(f.value), sort: 'descending', }; }) @@ -401,7 +484,7 @@ const DataTable: React.FC = (props) => { > {f.value.basename || f.value.name || f.value.fid} - {sorting?.fid === f.value.fid && ( + {sorting?.fid === getFieldIdentifier(f.value) && (
{sorting.sort === 'ascending' && } {sorting.sort === 'descending' && } @@ -414,7 +497,7 @@ const DataTable: React.FC = (props) => { className: 'cursor-pointer invisible group-hover:visible', size: 'icon-sm', })} - onClick={() => onSelectFilter(f.value.fid)} + onClick={() => onSelectFilter(getFieldIdentifier(f.value))} >
@@ -425,28 +508,37 @@ const DataTable: React.FC = (props) => { ))}
- -
+ +
- - + +
@@ -237,10 +273,10 @@ const PivotTable: React.FC = observer(function PivotTableCompon data={topTree} dimsInCol={dimsInColumn} measInCol={measInColumn} - onHeaderCollapse={(n) => vizStore?.updateTableCollapsedHeader(n)} + onHeaderCollapse={(n) => updateTableCollapsedHeader(n)} onTopTreeHeaderRowNumChange={(num) => setTopTreeHeaderRowNum(num)} enableCollapse={enableCollapse} - displayOffset={vizStore.config.timezoneDisplayOffset} + displayOffset={visualConfig.timezoneDisplayOffset} /> )} {metricTable && ( @@ -250,6 +286,6 @@ const PivotTable: React.FC = observer(function PivotTableCompon
); -}); +}; export default PivotTable; diff --git a/packages/graphic-walker/src/components/pivotTable/utils.ts b/packages/graphic-walker/src/components/pivotTable/utils.ts index 0a335ae1..b1327aeb 100644 --- a/packages/graphic-walker/src/components/pivotTable/utils.ts +++ b/packages/graphic-walker/src/components/pivotTable/utils.ts @@ -68,18 +68,18 @@ const TOTAL_KEY = '__total'; function insertSummaryNode(node: INestNode): void { if (node.children.length > 0) { - node.children.push({ + node.children.unshift({ key: TOTAL_KEY, - value: 'total', + value: `${node.value}(total)`, sort: '', - fieldKey: node.children[0].fieldKey, + fieldKey: TOTAL_KEY, uniqueKey: `${node.uniqueKey}${TOTAL_KEY}`, children: [], path: [], height: node.children[0].height, isCollapsed: true, }); - for (let i = 0; i < node.children.length - 1; i++) { + for (let i = 1; i < node.children.length; i++) { insertSummaryNode(node.children[i]); } } @@ -197,7 +197,7 @@ export function buildMetricTableFromNestTree(leftTree: INestNode, topTree: INest const predicates = iteLeft .predicates() .concat(iteTop.predicates()) - .filter((ele) => ele.value !== 'total'); + .filter((ele) => ele.key !== TOTAL_KEY); const matchedRows = data.filter((r) => predicates.every((pre) => r[pre.key] === pre.value)); if (matchedRows.length > 0) { // If multiple rows are matched, then find the most matched one (the row with smallest number of keys) diff --git a/packages/graphic-walker/src/components/selectContext/index.tsx b/packages/graphic-walker/src/components/selectContext/index.tsx index 68d85fd4..1d91cd4b 100644 --- a/packages/graphic-walker/src/components/selectContext/index.tsx +++ b/packages/graphic-walker/src/components/selectContext/index.tsx @@ -5,24 +5,24 @@ import { Float } from '@headlessui-float/react'; import { blockContext } from '../../fields/fieldsContext'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; -export interface ISelectContextOption { - key: string; +export interface ISelectContextOption { + key: T; label: string; disabled?: boolean; } -interface ISelectContextProps { - options?: ISelectContextOption[]; +interface ISelectContextProps { + options?: ISelectContextOption[]; disable?: boolean; - selectedKeys?: string[]; - onSelect?: (selectedKeys: string[]) => void; + selectedKeys?: T[]; + onSelect?: (selectedKeys: T[]) => void; className?: string; required?: boolean; children?: React.ReactNode | Iterable; } -const SelectContext: React.FC = (props) => { +function SelectContext (props: ISelectContextProps) { const { options = [], disable = false, selectedKeys = [], onSelect, className = '', required } = props; - const [selected, setSelected] = useState(options.filter((opt) => selectedKeys.includes(opt.key))); + const [selected, setSelected] = useState[]>(options.filter((opt) => selectedKeys.includes(opt.key))); useEffect(() => { setSelected(options.filter((opt) => selectedKeys.includes(opt.key))); diff --git a/packages/graphic-walker/src/components/tabs/editableTab.tsx b/packages/graphic-walker/src/components/tabs/editableTab.tsx index 8d890cf9..961c97c7 100644 --- a/packages/graphic-walker/src/components/tabs/editableTab.tsx +++ b/packages/graphic-walker/src/components/tabs/editableTab.tsx @@ -196,7 +196,7 @@ export default function EditableTabs(props: EditableTabsProps) { }); }} > - Edit + Rename , + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/packages/graphic-walker/src/components/visualConfig/index.tsx b/packages/graphic-walker/src/components/visualConfig/index.tsx index 51ad1224..c2fdd5d8 100644 --- a/packages/graphic-walker/src/components/visualConfig/index.tsx +++ b/packages/graphic-walker/src/components/visualConfig/index.tsx @@ -72,6 +72,17 @@ function useScale(minRange: number, maxRange: number, defaultMinRange?: number, }; } +function fallbackRender({ error, resetErrorBoundary }) { + // Call resetErrorBoundary() to reset the error boundary and retry the render. + + return ( +
+

Something went wrong:

+
{error.message}
+
+ ); +} + const VisualConfigPanel: React.FC = () => { const vizStore = useVizStore(); const { layout, showVisualConfigPanel, config } = vizStore; @@ -148,40 +159,7 @@ const VisualConfigPanel: React.FC = () => {
- -
- { - setColorEdited(true); - setDefaultColor((x) => ({ ...x, r: Number(e.target.value) })); - }} - /> - { - setColorEdited(true); - setDefaultColor((x) => ({ ...x, g: Number(e.target.value) })); - }} - /> - { - setColorEdited(true); - setDefaultColor((x) => ({ ...x, b: Number(e.target.value) })); - }} - /> -
- } - > +
{ e.stopPropagation(); diff --git a/packages/graphic-walker/src/computation/clientComputation.ts b/packages/graphic-walker/src/computation/clientComputation.ts index 716fae3d..5e4c67a0 100644 --- a/packages/graphic-walker/src/computation/clientComputation.ts +++ b/packages/graphic-walker/src/computation/clientComputation.ts @@ -1,26 +1,41 @@ -import type { IDataQueryPayload, IDataQueryWorkflowStep, IFilterFiledSimple, IRow } from "../interfaces"; -import { applyFilter, applySort, applyViewQuery, transformDataService } from "../services"; +import { DEFAULT_DATASET } from '@/constants'; +import type { IDataQueryPayload, IDataQueryWorkflowStep, IFilterFiledSimple, IRow, IBasicDataQueryWorkflowStep, IJoinWorkflowStep } from '../interfaces'; +import { applyFilter, applySort, applyViewQuery, joinDataService, transformDataService } from '../services'; export const dataQueryClient = async ( - rawData: IRow[], + rawDatas: Record, workflow: IDataQueryWorkflowStep[], + datasets: string[], offset?: number, - limit?: number, + limit?: number ): Promise => { - if (process.env.NODE_ENV !== "production") { - console.log('local query triggered', workflow); + if (process.env.NODE_ENV !== 'production') { + console.log('local query triggered', workflow, datasets); } - let res = rawData; - for await (const step of workflow) { + + const steps = workflow.filter((step): step is IBasicDataQueryWorkflowStep => step.type !== 'join'); + const joins = workflow.find((step): step is IJoinWorkflowStep => step.type === 'join'); + let res: IRow[]; + if (joins) { + res = await joinDataService(rawDatas, joins.foreigns); + } else { + res = datasets.flatMap((dataset) => rawDatas[dataset]); + } + for await (const step of steps) { switch (step.type) { case 'filter': { - res = await applyFilter(res, step.filters.map(filter => { - const res: IFilterFiledSimple = { - fid: filter.fid, - rule: filter.rule, - }; - return res; - }).filter(Boolean)); + res = await applyFilter( + res, + step.filters + .map((filter) => { + const res: IFilterFiledSimple = { + fid: filter.fid, + rule: filter.rule, + }; + return res; + }) + .filter(Boolean) + ); break; } case 'transform': { @@ -44,7 +59,14 @@ export const dataQueryClient = async ( } } } - return res.slice(offset ?? 0, limit ? ((offset ?? 0) + limit) : undefined); + + return res.slice(offset ?? 0, limit ? (offset ?? 0) + limit : undefined); }; -export const getComputation = (rawData: IRow[]) => (payload: IDataQueryPayload) => dataQueryClient(rawData, payload.workflow, payload.offset, payload.limit) +export const getComputation = (rawDatas: Record | IRow[]) => { + if (rawDatas instanceof Array) { + return (payload: IDataQueryPayload) => + dataQueryClient({ [DEFAULT_DATASET]: rawDatas }, payload.workflow, [DEFAULT_DATASET], payload.offset, payload.limit); + } + return (payload: IDataQueryPayload) => dataQueryClient(rawDatas, payload.workflow, payload.datasets, payload.offset, payload.limit); +}; diff --git a/packages/graphic-walker/src/computation/index.ts b/packages/graphic-walker/src/computation/index.ts index 73a18e13..748280af 100644 --- a/packages/graphic-walker/src/computation/index.ts +++ b/packages/graphic-walker/src/computation/index.ts @@ -18,38 +18,14 @@ import { getTimeFormat } from '../lib/inferMeta'; import { newOffsetDate } from '../lib/op/offset'; import { processExpression } from '../utils/workflow'; import { binarySearchClosest, isNotEmpty, parseKeyword } from '../utils'; -import { COUNT_FIELD_ID } from '../constants'; +import { COUNT_FIELD_ID, DEFAULT_DATASET } from '../constants'; import { range } from 'lodash-es'; -export const datasetStats = async (service: IComputationFunction): Promise => { - const res = (await service({ - workflow: [ - { - type: 'view', - query: [ - { - op: 'aggregate', - groupBy: [], - measures: [ - { - field: '*', - agg: 'count', - asFieldKey: 'count', - }, - ], - }, - ], - }, - ], - })) as [{ count: number }]; - return { - rowCount: res[0]?.count ?? 0, - }; -}; - export const dataReadRaw = async ( service: IComputationFunction, + fields: string[], pageSize: number, + datasets: string[], pageOffset = 0, option?: { sorting?: { fid: string; sort: 'ascending' | 'descending' }; @@ -71,7 +47,7 @@ export const dataReadRaw = async ( query: [ { op: 'raw', - fields: ['*'], + fields, }, ], }, @@ -87,18 +63,23 @@ export const dataReadRaw = async ( ], limit: pageSize, offset: pageOffset * pageSize, + datasets, }); return res; }; -export const dataQuery = async (service: IComputationFunction, workflow: IDataQueryWorkflowStep[], limit?: number): Promise => { +export const dataQuery = async (service: IComputationFunction, workflow: IDataQueryWorkflowStep[], datasets: string[], limit?: number): Promise => { const viewWorkflow = workflow.find((x) => x.type === 'view') as IViewWorkflowStep | undefined; - if (viewWorkflow && viewWorkflow.query.length === 1 && viewWorkflow.query[0].op === 'raw' && viewWorkflow.query[0].fields.length === 0) { + if ( + (viewWorkflow && viewWorkflow.query.length === 1 && viewWorkflow.query[0].op === 'raw' && viewWorkflow.query[0].fields.length === 0) || + datasets.length === 0 + ) { return []; } const res = await service({ workflow, limit, + datasets, }); return res; }; @@ -121,10 +102,11 @@ export const fieldStat = async ( allFields: IMutField[] ): Promise => { const { values = true, range = true, valuesMeta = true, sortBy = 'none', timezoneDisplayOffset, keyword } = options; - const COUNT_ID = `count_${field.fid}`; - const TOTAL_DISTINCT_ID = `total_distinct_${field.fid}`; - const MIN_ID = `min_${field.fid}`; - const MAX_ID = `max_${field.fid}`; + const COUNT_ID = `${field.fid}_count`; + const TOTAL_DISTINCT_ID = `${field.fid}_total_distinct`; + const MIN_ID = `${field.fid}_min`; + const MAX_ID = `${field.fid}_max`; + const datasets = [field.dataset ?? DEFAULT_DATASET]; const k = isNotEmpty(keyword) ? parseKeyword(keyword) : undefined; const filterWork: IFilterWorkflowStep[] = k ? [ @@ -189,6 +171,7 @@ export const fieldStat = async ( ], }, ], + datasets, }; const valuesQueryPayload: IDataQueryPayload = { workflow: [ @@ -222,6 +205,7 @@ export const fieldStat = async ( ], limit: options.valuesLimit, offset: options.valuesOffset, + datasets, }; const [valuesMetaRes = { [TOTAL_DISTINCT_ID]: 0, count: 0 }] = valuesMeta ? await service(valuesMetaQueryPayload) : [{ [TOTAL_DISTINCT_ID]: 0, count: 0 }]; const valuesRes = values ? await service(valuesQueryPayload) : []; @@ -251,6 +235,7 @@ export const fieldStat = async ( ], }, ], + datasets, }; const [ rangeRes = { @@ -300,6 +285,7 @@ export const fieldStat = async ( ], }, ], + datasets, } : null; const [selectedCountRes = { count: 0 }] = selectedCountWork ? await service(selectedCountWork) : []; @@ -318,9 +304,9 @@ export const fieldStat = async ( }; }; -export async function getRange(service: IComputationFunction, field: string) { - const MIN_ID = `min_${field}`; - const MAX_ID = `max_${field}`; +export async function getRange(service: IComputationFunction, field: string, dataset?: string) { + const MIN_ID = `${field}_min`; + const MAX_ID = `${field}_max`; const rangeQueryPayload: IDataQueryPayload = { workflow: [ { @@ -345,6 +331,7 @@ export async function getRange(service: IComputationFunction, field: string) { ], }, ], + datasets: [dataset ?? DEFAULT_DATASET], }; const [ rangeRes = { @@ -380,7 +367,7 @@ export function withComputedField(field: IField, allFields: IMutField[], service }; } -export async function getSample(service: IComputationFunction, field: string) { +export async function getSample(service: IComputationFunction, field: string, dataset?: string) { const res = await service({ workflow: [ { @@ -395,17 +382,18 @@ export async function getSample(service: IComputationFunction, field: string) { ], limit: 1, offset: 0, + datasets: [dataset ?? DEFAULT_DATASET], }); return res?.[0]?.[field]; } -export async function getTemporalRange(service: IComputationFunction, field: string, offset?: number) { +export async function getTemporalRange(service: IComputationFunction, field: string, dataset?: string, offset?: number) { const sample = await getSample(service, field); const format = getTimeFormat(sample); const usedOffset = offset ?? new Date().getTimezoneOffset(); const newDate = newOffsetDate(usedOffset); - const MIN_ID = `min_${field}`; - const MAX_ID = `max_${field}`; + const MIN_ID = `${field}_min`; + const MAX_ID = `${field}_max`; const rangeQueryPayload: IDataQueryPayload = { workflow: [ { @@ -434,6 +422,7 @@ export async function getTemporalRange(service: IComputationFunction, field: str ], }, ], + datasets: [dataset ?? DEFAULT_DATASET], }; const [ rangeRes = { @@ -444,9 +433,9 @@ export async function getTemporalRange(service: IComputationFunction, field: str return [newDate(rangeRes[MIN_ID]).getTime(), newDate(rangeRes[MAX_ID]).getTime(), format] as [number, number, string]; } -export async function getFieldDistinctMeta(service: IComputationFunction, field: string) { - const COUNT_ID = `count_${field}`; - const TOTAL_DISTINCT_ID = `total_distinct_${field}`; +export async function getFieldDistinctMeta(service: IComputationFunction, field: string, dataset?: string) { + const COUNT_ID = `${field}_count`; + const TOTAL_DISTINCT_ID = `${field}_distinct_total`; const workflow: IDataQueryWorkflowStep[] = [ { type: 'view', @@ -486,7 +475,7 @@ export async function getFieldDistinctMeta(service: IComputationFunction, field: ], }, ]; - const [valuesMetaRes = { [TOTAL_DISTINCT_ID]: 0, count: 0 }] = await service({ workflow }); + const [valuesMetaRes = { [TOTAL_DISTINCT_ID]: 0, count: 0 }] = await service({ workflow, datasets: [dataset ?? DEFAULT_DATASET] }); return { total: valuesMetaRes.count as number, distinctTotal: valuesMetaRes[TOTAL_DISTINCT_ID] as number, @@ -496,6 +485,7 @@ export async function getFieldDistinctMeta(service: IComputationFunction, field: export async function getFieldDistinctCounts( service: IComputationFunction, field: string, + dataset?: string, options: { sortBy?: 'value' | 'count' | 'value_dsc' | 'count_dsc' | 'none'; valuesLimit?: number; @@ -503,7 +493,7 @@ export async function getFieldDistinctCounts( } = {} ) { const { sortBy = 'none', valuesLimit, valuesOffset } = options; - const COUNT_ID = `count_${field}`; + const COUNT_ID = `${field}_count`; const valuesQueryPayload: IDataQueryPayload = { workflow: [ { @@ -534,6 +524,7 @@ export async function getFieldDistinctCounts( ], limit: valuesLimit, offset: valuesOffset, + datasets: [dataset ?? DEFAULT_DATASET], }; const valuesRes = await service(valuesQueryPayload); return valuesRes.map((row) => ({ @@ -542,15 +533,15 @@ export async function getFieldDistinctCounts( })); } -export async function profileNonmialField(service: IComputationFunction, field: string) { +export async function profileNonmialField(service: IComputationFunction, field: string, dataset?: string) { const TOPS_NUM = 2; - const meta = getFieldDistinctMeta(service, field); - const tops = getFieldDistinctCounts(service, field, { sortBy: 'count_dsc', valuesLimit: TOPS_NUM }); + const meta = getFieldDistinctMeta(service, field, dataset); + const tops = getFieldDistinctCounts(service, field, dataset, { sortBy: 'count_dsc', valuesLimit: TOPS_NUM }); return Promise.all([meta, tops] as const); } -export async function profileQuantitativeField(service: IComputationFunction, field: string) { - const BIN_FIELD = `bin_${field}`; +export async function profileQuantitativeField(service: IComputationFunction, field: string, dataset?: string) { + const BIN_FIELD = `${field}_bin`; const ROW_NUM_FIELD = `${COUNT_FIELD_ID}_sum`; const BIN_SIZE = 10; @@ -600,7 +591,7 @@ export async function profileQuantitativeField(service: IComputationFunction, fi }, ]; - const valuesRes = service({ workflow }); + const valuesRes = service({ workflow, datasets: [dataset ?? DEFAULT_DATASET] }); const values = (await valuesRes).sort((x, y) => x[BIN_FIELD][0] - y[BIN_FIELD][0]); if (values.length === 0) { return { diff --git a/packages/graphic-walker/src/constants.ts b/packages/graphic-walker/src/constants.ts index cf83757d..94e243d2 100644 --- a/packages/graphic-walker/src/constants.ts +++ b/packages/graphic-walker/src/constants.ts @@ -1,11 +1,24 @@ +import type { FieldIdentifier } from "./interfaces"; + export const COUNT_FIELD_ID = 'gw_count_fid'; -export const DATE_TIME_DRILL_LEVELS = [ - "year", "quarter", "month", "week", "day", "hour", "minute", "second" -] as const; +export const DATE_TIME_DRILL_LEVELS = ['year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second', 'iso_year', 'iso_week'] as const; export const DATE_TIME_FEATURE_LEVELS = [ - "year", "quarter", "month", "week", "weekday", "day", "hour", "minute", "second" + 'year', + 'quarter', + 'month', + 'week', + 'weekday', + 'day', + 'hour', + 'minute', + 'second', + 'iso_year', + 'iso_week', + 'iso_weekday', ] as const; export const MEA_KEY_ID = 'gw_mea_key_fid'; export const MEA_VAL_ID = 'gw_mea_val_fid'; -export const PAINT_FIELD_ID = 'gw_paint_fid'; \ No newline at end of file +export const PAINT_FIELD_ID = 'gw_paint_fid'; +export const DEFAULT_DATASET = 'dataset'; +export const EMPTY_FIELD_ID = '' as FieldIdentifier; diff --git a/packages/graphic-walker/src/dataSource/dataSelection/csvData.tsx b/packages/graphic-walker/src/dataSource/dataSelection/csvData.tsx index 67d994eb..c2bc25ff 100644 --- a/packages/graphic-walker/src/dataSource/dataSelection/csvData.tsx +++ b/packages/graphic-walker/src/dataSource/dataSelection/csvData.tsx @@ -41,11 +41,11 @@ const CSVData: React.FC = ({ commonStore }) => { onLoading: () => {}, encoding, }).then((data) => { - commonStore.updateTempDS(data as IRow[]); + commonStore.updateTempDS(data as IRow[], file.name.split('.')[0]); }); } else { jsonReader(file).then((data) => { - commonStore.updateTempDS(data as IRow[]); + commonStore.updateTempDS(data as IRow[], file.name.split('.')[0]); }); } } diff --git a/packages/graphic-walker/src/dataSource/datasetConfig/index.tsx b/packages/graphic-walker/src/dataSource/datasetConfig/index.tsx index fcd19924..dd5df1e1 100644 --- a/packages/graphic-walker/src/dataSource/datasetConfig/index.tsx +++ b/packages/graphic-walker/src/dataSource/datasetConfig/index.tsx @@ -1,23 +1,39 @@ -import React from 'react'; +import React, { useContext, useState } from 'react'; import DatasetTable from '../../components/dataTable'; import { observer } from 'mobx-react-lite'; -import { useCompututaion, useVizStore } from '../../store'; -import { toJS } from 'mobx'; +import { DatasetNamesContext, useCompututaion, useVizStore } from '../../store'; +import { DEFAULT_DATASET } from '@/constants'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { getFieldIdentifier } from '@/utils'; const DatasetConfig: React.FC = () => { const vizStore = useVizStore(); const computation = useCompututaion(); - const metas = toJS(vizStore.meta); + const metas = vizStore.meta; + const datasets = Array.from(new Set(metas.map((x) => x.dataset ?? DEFAULT_DATASET))); + const [dataset, setDataset] = useState(datasets[0] ?? DEFAULT_DATASET); + const datasetNames = useContext(DatasetNamesContext); + const tableMeta = metas.filter((x) => dataset === (x.dataset ?? DEFAULT_DATASET)); return (
+ {datasets.length > 1 && ( + + + {datasets.map((ds) => ( + {datasetNames?.[ds] ?? ds} + ))} + + + )} + { - vizStore.updateCurrentDatasetMetas(fid, diffMeta); + vizStore.updateCurrentDatasetMetas(getFieldIdentifier(tableMeta[fIndex]), diffMeta); }} />
diff --git a/packages/graphic-walker/src/dataSource/index.tsx b/packages/graphic-walker/src/dataSource/index.tsx index 918578bd..17493582 100644 --- a/packages/graphic-walker/src/dataSource/index.tsx +++ b/packages/graphic-walker/src/dataSource/index.tsx @@ -5,7 +5,7 @@ import { downloadBlob } from '../utils/save'; import GwFile from './dataSelection/gwFile'; import DataSelection from './dataSelection'; import DropdownSelect from '../components/dropdownSelect'; -import { IUIThemeConfig, IComputationFunction, IDarkMode, IDataSourceEventType, IDataSourceProvider, IMutField, IThemeKey } from '../interfaces'; +import { FieldIdentifier, IUIThemeConfig, IComputationFunction, IDarkMode, IDataSourceEventType, IDataSourceProvider, IMutField, IThemeKey } from '../interfaces'; import { ShadowDom } from '../shadow-dom'; import { CommonStore } from '../store/commonStore'; import { VizSpecStore } from '../store/visualSpecStore'; @@ -15,18 +15,20 @@ import { composeContext } from '../utils/context'; import { portalContainerContext, themeContext, vegaThemeContext } from '../store/theme'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { MultiCombobox } from '@/components/dropdownSelect/combobox'; +import { getFieldIdentifier } from '@/utils'; interface DSSegmentProps { commonStore: CommonStore; dataSources: { name: string; id: string }[]; - selectedId: string; - onSelectId: (value: string) => void; + selectedIds: string[]; + onSelectIds: (value: string[]) => void; onSave?: () => Promise; onLoad?: (file: File) => void; } const DataSourceSegment: React.FC = observer((props) => { - const { commonStore, dataSources, onSelectId, selectedId, onLoad, onSave } = props; + const { commonStore, dataSources, onSelectIds, selectedIds, onLoad, onSave } = props; const gwFileRef = useRef(null); const { t } = useTranslation(); @@ -37,12 +39,12 @@ const DataSourceSegment: React.FC = observer((props) => { {/* */} -
- + ({ label: d.name, value: d.id }))} - selectedKey={selectedId} - onSelect={onSelectId} + selectedKeys={selectedIds} + onSelect={onSelectIds} placeholder={t('DataSource.labels.cur_dataset')} />
@@ -124,15 +126,15 @@ export function DataSourceSegmentComponent(props: { colorConfig?: IUIThemeConfig; uiTheme?: IUIThemeConfig; children: (props: { - meta: IMutField[]; - onMetaChange: (fid: string, meta: Partial) => void; + fields: IMutField[]; + onMetaChange: (fid: FieldIdentifier, meta: Partial) => void; computation: IComputationFunction; storeRef: React.RefObject; - datasetName: string; + datasetNames: Record; syncSpecs: () => void; }) => JSX.Element; }) { - const [selectedId, setSelectedId] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); const [datasetList, setDatasetList] = useState<{ name: string; id: string }[]>([]); useEffect(() => { props.provider.getDataSourceList().then(setDatasetList); @@ -143,26 +145,33 @@ export function DataSourceSegmentComponent(props: { }); }, [props.provider]); - const dataset = useMemo(() => datasetList.find((x) => x.id === selectedId), [datasetList, selectedId]); + const datasets = useMemo(() => datasetList.filter((x) => selectedIds.includes(x.id)), [datasetList, selectedIds]); const [computationID, refreshComputation] = useReducer((x: number) => x + 1, 0); const [meta, setMeta] = useState([]); const vizSpecStoreRef = useRef(null); + const [computation, setComputation] = useState(() => async () => []); useEffect(() => { - if (dataset) { + if (datasets.length) { const { provider } = props; - provider.getMeta(dataset.id).then(setMeta); - provider.getSpecs(dataset.id).then((x) => { + Promise.all(datasets.map(({ id }) => provider.getMeta(id).then((meta) => meta.map((x) => ({ ...x, dataset: id }))))).then((metas) => { + setMeta(metas.flat()); + }); + const specKey = datasets.length > 1 ? JSON.stringify(datasets.map((x) => x.id).sort()) : datasets[0].id; + + provider.getSpecs(specKey).then((x) => { vizSpecStoreRef.current?.importRaw(JSON.parse(x)); }); const disposer = provider.registerCallback((e, datasetId) => { - if (dataset.id === datasetId) { + if (datasets.find((x) => x.id === datasetId)) { if (e & IDataSourceEventType.updateData) { refreshComputation(); } if (e & IDataSourceEventType.updateMeta) { - provider.getMeta(datasetId).then(setMeta); + Promise.all(datasets.map(({ id }) => provider.getMeta(id).then((meta) => meta.map((x) => ({ ...x, dataset: id }))))).then((metas) => { + setMeta(metas.flat()); + }); } if (e & IDataSourceEventType.updateSpec) { provider.getSpecs(datasetId).then((x) => (x) => { @@ -174,30 +183,37 @@ export function DataSourceSegmentComponent(props: { return () => { disposer(); const data = vizSpecStoreRef.current?.exportAllCharts(); - data && provider.saveSpecs(dataset.id, JSON.stringify(data)); + data && provider.saveSpecs(specKey, JSON.stringify(data)); }; } - }, [dataset, props.provider]); + }, [datasets, props.provider]); - const computation = useMemo( - () => async (payload) => { - return selectedId ? props.provider.queryData(payload, [selectedId]) : []; - }, - [computationID, props.provider, selectedId] - ); + useEffect(() => { + setComputation(() => async (payload) => { + return selectedIds ? props.provider.queryData(payload, selectedIds) : []; + }); + }, [computationID, props.provider, selectedIds]); const onMetaChange = useCallback( - (fid: string, meta: Partial) => { + (fid: FieldIdentifier, meta: Partial) => { setMeta((x) => { - const result = x.map((f) => (f.fid === fid ? { ...f, ...meta } : f)); - props.provider.setMeta(selectedId, result); + const oriMeta = x.find((f) => getFieldIdentifier(f) === fid); + if (!oriMeta) { + return x; + } + const result = x.map((f) => (getFieldIdentifier(f) === fid ? { ...f, ...meta } : f)); + const dataset = oriMeta.dataset ?? selectedIds[0]; + props.provider.setMeta( + dataset, + result.filter((x) => x.dataset === dataset) + ); return result; }); }, - [props.provider, selectedId] + [props.provider, selectedIds] ); - const commonStore = useMemo(() => new CommonStore(props.provider, setSelectedId, { displayOffset: props.displayOffset }), [props.provider]); + const commonStore = useMemo(() => new CommonStore(props.provider, (id) => setSelectedIds([id]), { displayOffset: props.displayOffset }), [props.provider]); useEffect(() => { commonStore.setDisplayOffset(props.displayOffset); @@ -210,7 +226,7 @@ export function DataSourceSegmentComponent(props: { importFile(file); once(props.provider.registerCallback, (e) => { if (e & IDataSourceEventType.updateList) { - props.provider.getDataSourceList().then(([first]) => setSelectedId(first.id)); + props.provider.getDataSourceList().then(([first]) => setSelectedIds([first.id])); } }); }; @@ -223,20 +239,22 @@ export function DataSourceSegmentComponent(props: { if (exportFile) { return async () => { const data = vizSpecStoreRef.current?.exportAllCharts(); + const specKey = selectedIds.length > 1 ? JSON.stringify(selectedIds.map((x) => x).sort()) : selectedIds[0]; if (data) { - await saveSpecs(selectedId, JSON.stringify(data)); + await saveSpecs(specKey, JSON.stringify(data)); } return exportFile(); }; } - }, [selectedId, props.provider]); + }, [selectedIds, props.provider]); const syncSpecs = useCallback(() => { const data = vizSpecStoreRef.current?.exportAllCharts(); if (data) { - props.provider.saveSpecs(selectedId, JSON.stringify(data)); + const specKey = selectedIds.length > 1 ? JSON.stringify(selectedIds.map((x) => x).sort()) : selectedIds[0]; + props.provider.saveSpecs(specKey, JSON.stringify(data)); } - }, [selectedId, props.provider]); + }, [selectedIds, props.provider]); const darkMode = useCurrentMediaTheme(props.appearance ?? props.dark); const [portal, setPortal] = useState(null); @@ -253,8 +271,8 @@ export function DataSourceSegmentComponent(props: { @@ -264,8 +282,8 @@ export function DataSourceSegmentComponent(props: { [x.id, x.name]))} + fields={meta} onMetaChange={onMetaChange} storeRef={vizSpecStoreRef} syncSpecs={syncSpecs} diff --git a/packages/graphic-walker/src/dataSourceProvider/memory.ts b/packages/graphic-walker/src/dataSourceProvider/memory.ts index f185ca73..8dddf39e 100644 --- a/packages/graphic-walker/src/dataSourceProvider/memory.ts +++ b/packages/graphic-walker/src/dataSourceProvider/memory.ts @@ -34,7 +34,7 @@ export function createMemoryProvider(initData?: string | null): IDataSourceProvi if (!meta) { throw new Error('cannot find meta'); } - return meta; + return meta.map((x) => ({ ...x, dataset: datasetId })); }, async setMeta(datasetId, meta) { const dataSet = store.dataSources.find((x) => x.id === datasetId); @@ -48,31 +48,44 @@ export function createMemoryProvider(initData?: string | null): IDataSourceProvi async getSpecs(datasetId) { const dataSet = store.dataSources.find((x) => x.id === datasetId); if (!dataSet) { - throw new Error('cannot find dataset'); - } - const metaId = dataSet.metaId; - const specs = store.visDict[metaId]; - if (!specs) { - throw new Error('cannot find specs'); + if (store.visDict[datasetId]) { + return JSON.stringify(store.visDict[datasetId]); + } + const datasets: string[] = JSON.parse(datasetId); + const fields = store.dataSources + .filter((x) => datasets.includes(x.id)) + .flatMap((ds) => store.metaDict[ds.metaId].map((x) => ({ ...x, dataset: ds.id }))); + store.createVis(datasetId, fields); + return JSON.stringify(store.visDict[datasetId]); + } else { + const metaId = dataSet.metaId; + const specs = store.visDict[metaId]; + if (!specs) { + throw new Error('cannot find specs'); + } + return JSON.stringify(specs); } - return JSON.stringify(specs); }, async saveSpecs(datasetId, value) { const dataSet = store.dataSources.find((x) => x.id === datasetId); if (!dataSet) { - throw new Error('cannot find dataset'); + store.visDict[datasetId] = JSON.parse(value); + listeners.forEach((cb) => cb(IDataSourceEventType.updateSpec, datasetId)); + } else { + const metaId = dataSet.metaId; + store.visDict[metaId] = JSON.parse(value); + listeners.forEach((cb) => cb(IDataSourceEventType.updateSpec, dataSet.id)); } - const metaId = dataSet.metaId; - store.visDict[metaId] = JSON.parse(value); - listeners.forEach((cb) => cb(IDataSourceEventType.updateSpec, dataSet.id)); }, async queryData(query, datasetIds) { - // TODO: add support for querying multi datasource - const dataSet = store.dataSources.find((x) => x.id === datasetIds[0]); - if (!dataSet) { + const dataSets = store.dataSources.filter((x) => datasetIds.includes(x.id)); + if (!dataSets.length) { throw new Error('cannot find dataset'); } - return getComputation(dataSet.data)(query); + if (dataSets.length === 1) { + return getComputation(dataSets[0].data)(query); + } + return getComputation(Object.fromEntries(dataSets.map((x) => [x.id, x.data])))(query); }, async onExportFile() { const data = store.exportData(); @@ -82,7 +95,7 @@ export function createMemoryProvider(initData?: string | null): IDataSourceProvi async onImportFile(file) { const data = await file.text(); store.importData(JSON.parse(data)); - listeners.forEach((cb) => cb(IDataSourceEventType.updateList, '')); + listeners.forEach((cb) => cb(1, '')); }, registerCallback(cb) { listeners.push(cb); diff --git a/packages/graphic-walker/src/fields/datasetFields/index.tsx b/packages/graphic-walker/src/fields/datasetFields/index.tsx index ff9ce03b..2700d66b 100644 --- a/packages/graphic-walker/src/fields/datasetFields/index.tsx +++ b/packages/graphic-walker/src/fields/datasetFields/index.tsx @@ -21,7 +21,7 @@ const DatasetFields: React.FC = (props) => { {(provided, snapshot) => (
-
+
@@ -30,7 +30,7 @@ const DatasetFields: React.FC = (props) => { {(provided, snapshot) => (
-
+
diff --git a/packages/graphic-walker/src/fields/datasetFields/multi.tsx b/packages/graphic-walker/src/fields/datasetFields/multi.tsx new file mode 100644 index 00000000..5e61bdc9 --- /dev/null +++ b/packages/graphic-walker/src/fields/datasetFields/multi.tsx @@ -0,0 +1,370 @@ +import React, { useContext } from 'react'; +import { observer } from 'mobx-react-lite'; +import { DatasetNamesContext, useVizStore } from '../../store'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { IJoinPath } from '@/interfaces'; +import { mergePaths, reversePath } from '@/utils/route'; +import { Draggable, Droppable } from '@kanaries/react-beautiful-dnd'; +import ActionMenu from '@/components/actionMenu'; +import { FieldPill } from '../datasetFields/fieldPill'; +import { useMenuActions } from '../datasetFields/utils'; +import { refMapper } from '../fieldsContext'; +import DataTypeIcon from '@/components/dataTypeIcon'; +import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; +import { MEA_KEY_ID, MEA_VAL_ID } from '@/constants'; +import { deduper, getFieldIdentifier } from '@/utils'; +import Combobox from '@/components/dropdownSelect/combobox'; + +const getDiffDesc = (path: IJoinPath, paths: IJoinPath[], fields: { fid: string; dataset?: string; name?: string }[]) => { + const result: string[] = []; + if (new Set(paths.map((x) => x.fid)).size > 1) { + result.push(fields.find((x) => x.fid === path.fid && x.dataset === path.from)?.name ?? path.fid); + } + if (new Set(paths.map((x) => x.tid)).size > 1) { + result.push(fields.find((x) => x.fid === path.tid && x.dataset === path.to)?.name ?? path.tid); + } + return result.length > 0 ? `(${result.join(',')})` : ''; +}; + +/** + * path: the path of this dataset to the base dataset in the view. + * basePath: the path of base dataset to a logic base dataset. + * bannedPath: the path that passed. + * tempBannedPath: the path that just comes from. + */ +const DatasetFields = observer((props: { dataset: string; path: IJoinPath[]; basePath: IJoinPath[]; bannedPath: IJoinPath[]; tempBannedPath: IJoinPath[] }) => { + const vizStore = useVizStore(); + const { dimensions, measures, routeMap, meta, datasets } = vizStore; + const bannedPaths = new Set( + props.bannedPath + .concat(props.tempBannedPath) + .filter((x) => x.from === props.dataset) + .map((x) => `${x.fid}_${x.to}_${x.tid}`) + ); + const nextPaths = deduper( + (routeMap[props.dataset] ?? []).filter((x) => !bannedPaths.has(`${x.fid}_${x.to}_${x.tid}`)), + (p) => p.fid + ).filter((x) => datasets.includes(x.to)); + const dimMenuActions = useMenuActions('dimensions'); + const meaMenuActions = useMenuActions('measures'); + const joinedPath = mergePaths(props.path.concat(props.basePath)); + const joinedPathId = vizStore.encodeJoinPath(joinedPath); + const datasetNames = useContext(DatasetNamesContext); + const tempBannedPaths = nextPaths.map(reversePath); + + return ( +
+ + {(provided, snapshot) => ( +
+ {dimensions.map((f, index) => { + if (f.dataset === props.dataset) { + // draggableId starts with _ passes join path info + return ( + + {(provided, snapshot) => { + return ( + + + + + {f.name} + + + + + + { + + + + {f.name} + + + + + + } + + ); + }} + + ); + } + return null; + })} +
+ )} +
+ {/* {dimensions.find((x) => x.dataset === props.dataset) && measures.find((x) => x.dataset === props.dataset) && ( +
+ )} */} + + {(provided, snapshot) => ( +
+ {measures.map((f, index) => { + if (f.dataset === props.dataset) { + return ( + // draggableId starts with _ passes join path info + + {(provided, snapshot) => { + return ( +
+ + + + + {f.name} + + + + + + { + + + + {f.name} + + + + + + } + +
+ ); + }} +
+ ); + } + return null; + })} +
+ )} +
+ +
+ {nextPaths.map((p) => ( +
+
+ {datasetNames?.[p.to] ?? p.to} + {getDiffDesc( + p, + routeMap[props.dataset].filter((x) => x.to === p.to), + meta + )} +
+ +
+ ))} +
+
+ ); +}); + +const MultiDatasetFields = observer(() => { + const vizStore = useVizStore(); + const { baseDataset, datasets, unReachedDatasets, basePath, dimensions, measures } = vizStore; + const dimMenuActions = useMenuActions('dimensions'); + const meaMenuActions = useMenuActions('measures'); + const datasetNames = useContext(DatasetNamesContext); + + return ( +
+ vizStore.setViewBaseDataset(d)} + options={datasets.map((ds) => ({ + label: datasetNames?.[ds] ?? ds, + value: ds, + }))} + /> +
+
+ +
+ + {(provided, snapshot) => ( +
+ {dimensions.map((f, index) => { + // TODO add support for fold + if (!f.dataset && ![MEA_KEY_ID, MEA_VAL_ID].includes(f.fid)) { + return ( + + {(provided, snapshot) => { + return ( + + + + + {f.name} + + + + + + { + + + + {f.name} + + + + + + } + + ); + }} + + ); + } + return null; + })} +
+ )} +
+ + {(provided, snapshot) => ( +
+ {measures.map((f, index) => { + if (!f.dataset && ![MEA_KEY_ID, MEA_VAL_ID].includes(f.fid)) { + return ( + + {(provided, snapshot) => { + return ( +
+ + + + + {f.name} + + + + + + { + + + + {f.name} + + + + + + } + +
+ ); + }} +
+ ); + } + return null; + })} +
+ )} +
+
+
+
+ {unReachedDatasets.length > 0 && ( +
+
Unlink datasets
+
    + {unReachedDatasets.map((ds) => ( +
  • vizStore.setLinkingDataset(ds)}> + {datasetNames?.[ds] ?? ds} +
  • + ))} +
+
+ )} +
+ ); +}); + +export default MultiDatasetFields; diff --git a/packages/graphic-walker/src/fields/datasetFields/utils.ts b/packages/graphic-walker/src/fields/datasetFields/utils.ts index 13f53ab2..d7943042 100644 --- a/packages/graphic-walker/src/fields/datasetFields/utils.ts +++ b/packages/graphic-walker/src/fields/datasetFields/utils.ts @@ -5,6 +5,7 @@ import type { IActionMenuItem } from '../../components/actionMenu/list'; import { COUNT_FIELD_ID, DATE_TIME_DRILL_LEVELS, DATE_TIME_FEATURE_LEVELS, MEA_KEY_ID, MEA_VAL_ID, PAINT_FIELD_ID } from '../../constants'; import { getSample } from '../../computation'; import { getTimeFormat } from '../../lib/inferMeta'; +import { getFieldIdentifier } from '@/utils'; const keepTrue = (array: (T | 0 | null | false | undefined | void)[]): T[] => { return array.filter(Boolean) as T[]; @@ -25,14 +26,14 @@ export const useMenuActions = (channel: 'dimensions' | 'measures'): IActionMenuI label: t('to_mea'), disabled: isInnerField, onPress() { - vizStore.moveField('dimensions', index, 'measures', vizStore.viewMeasures.length); + vizStore.moveField('dimensions', index, 'measures', vizStore.viewMeasures.length, null); }, }, channel === 'measures' && { label: t('to_dim'), disabled: isInnerField, onPress() { - vizStore.moveField('measures', index, 'dimensions', vizStore.viewDimensions.length); + vizStore.moveField('measures', index, 'dimensions', vizStore.viewDimensions.length, null); }, }, { @@ -164,7 +165,7 @@ export const useMenuActions = (channel: 'dimensions' | 'measures'): IActionMenuI f.expression?.op === 'expr' && { label: 'Edit Computed Field', onPress() { - vizStore.setComputedFieldFid(f.fid); + vizStore.setComputedFieldFid(getFieldIdentifier(f)); }, }, f.computed && diff --git a/packages/graphic-walker/src/fields/encodeFields/multiEncodeEditor.tsx b/packages/graphic-walker/src/fields/encodeFields/multiEncodeEditor.tsx index ac0f47ce..d3c88436 100644 --- a/packages/graphic-walker/src/fields/encodeFields/multiEncodeEditor.tsx +++ b/packages/graphic-walker/src/fields/encodeFields/multiEncodeEditor.tsx @@ -1,17 +1,19 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useContext } from 'react'; import { DraggableFieldState, IAggregator, IDraggableStateKey } from '../../interfaces'; import { observer } from 'mobx-react-lite'; -import { useVizStore } from '../../store'; +import { DatasetNamesContext, useVizStore } from '../../store'; import { DroppableProvided } from 'react-beautiful-dnd'; -import { ChevronUpDownIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { ChevronUpDownIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'; import { useTranslation } from 'react-i18next'; -import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from '../../constants'; +import { COUNT_FIELD_ID, DEFAULT_DATASET, MEA_KEY_ID, MEA_VAL_ID } from '../../constants'; import DropdownContext from '../../components/dropdownContext'; import { GLOBAL_CONFIG } from '../../config'; import { Draggable, DroppableStateSnapshot } from '@kanaries/react-beautiful-dnd'; import styled from 'styled-components'; import SelectContext, { type ISelectContextOption } from '../../components/selectContext'; import { refMapper } from '../fieldsContext'; +import Tooltip from '@/components/tooltip'; +import { EditNamePopover } from '../renamePanel'; import { getFieldIdentifier } from '@/utils'; const PillActions = styled.div` @@ -29,7 +31,7 @@ interface MultiEncodeEditorProps { const SingleEncodeEditor: React.FC = (props) => { const { dkey, provided, snapshot } = props; const vizStore = useVizStore(); - const { currentEncodings, config, allFields } = vizStore; + const { currentEncodings, config, foldOptions, datasetJoinPaths, isMultiDataset } = vizStore; const folds = config.folds ?? []; const channelItems = currentEncodings[dkey.id]; const { t } = useTranslation(); @@ -41,20 +43,20 @@ const SingleEncodeEditor: React.FC = (props) => { })); }, []); - const foldOptions = useMemo(() => { - const validFoldBy = allFields.filter((f) => f.analyticType === 'measure' && f.fid !== MEA_VAL_ID); - return validFoldBy.map((f) => ({ - key: f.fid, - label: f.name, - })); - }, [allFields]); + const datasetNames = useContext(DatasetNamesContext); return (
{channelItems.map((channelItem, index) => { return ( - + {(provided, snapshot) => { + const hasMultiJoins = datasetJoinPaths[channelItem.dataset ?? DEFAULT_DATASET]?.length > 1; + return (
= (props) => { className="flex-1" > + {isMultiDataset && channelItem.dataset + ? `${datasetNames?.[channelItem.dataset] ?? channelItem.dataset}.` + : ''} {channelItem.name} )} - {channelItem.fid !== MEA_KEY_ID && {channelItem.name}}{' '} + {channelItem.fid !== MEA_KEY_ID && ( + + {isMultiDataset && channelItem.dataset ? `${datasetNames?.[channelItem.dataset] ?? channelItem.dataset}.` : ''} + {channelItem.name} + + )} + {hasMultiJoins && channelItem.joinPath && ( + vizStore.editFieldName(props.dkey.id, index, name)} + desc={ +
+ This Field is Joined with below: +
{vizStore.renderJoinPath(channelItem.joinPath ?? [], datasetNames)}
+
+ } + > + +
+ )} {channelItem.analyticType === 'measure' && channelItem.fid !== COUNT_FIELD_ID && config.defaultAggregated && diff --git a/packages/graphic-walker/src/fields/encodeFields/singleEncodeEditor.tsx b/packages/graphic-walker/src/fields/encodeFields/singleEncodeEditor.tsx index f2531a3b..22f2630c 100644 --- a/packages/graphic-walker/src/fields/encodeFields/singleEncodeEditor.tsx +++ b/packages/graphic-walker/src/fields/encodeFields/singleEncodeEditor.tsx @@ -1,17 +1,18 @@ -import React, { useMemo } from 'react'; -import { DraggableFieldState, IAggregator, IDraggableStateKey } from '../../interfaces'; +import React, { useMemo, useContext } from 'react'; +import { DraggableFieldState, IAggregator } from '../../interfaces'; import { observer } from 'mobx-react-lite'; -import { useVizStore } from '../../store'; +import { DatasetNamesContext, useVizStore } from '../../store'; import { DroppableProvided } from 'react-beautiful-dnd'; -import { ChevronUpDownIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { ChevronUpDownIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'; import { useTranslation } from 'react-i18next'; -import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from '../../constants'; +import { COUNT_FIELD_ID, DEFAULT_DATASET, MEA_KEY_ID } from '../../constants'; import DropdownContext from '../../components/dropdownContext'; import { GLOBAL_CONFIG } from '../../config'; import { Draggable, DroppableStateSnapshot } from '@kanaries/react-beautiful-dnd'; import styled from 'styled-components'; -import SelectContext, { type ISelectContextOption } from '../../components/selectContext'; +import SelectContext from '../../components/selectContext'; import { refMapper } from '../fieldsContext'; +import { EditNamePopover } from '../renamePanel'; import { getFieldIdentifier } from '@/utils'; const PillActions = styled.div` @@ -29,7 +30,7 @@ interface SingleEncodeEditorProps { const SingleEncodeEditor: React.FC = (props) => { const { dkey, provided, snapshot } = props; const vizStore = useVizStore(); - const { allEncodings, config, allFields } = vizStore; + const { allEncodings, config, foldOptions, datasetJoinPaths, isMultiDataset } = vizStore; const folds = config.folds ?? []; const channelItem = allEncodings[dkey.id][0]; const { t } = useTranslation(); @@ -41,13 +42,7 @@ const SingleEncodeEditor: React.FC = (props) => { })); }, []); - const foldOptions = useMemo(() => { - const validFoldBy = allFields.filter((f) => f.analyticType === 'measure' && f.fid !== MEA_VAL_ID); - return validFoldBy.map((f) => ({ - key: f.fid, - label: f.name, - })); - }, [allFields]); + const datasetNames = useContext(DatasetNamesContext); return (
@@ -61,6 +56,8 @@ const SingleEncodeEditor: React.FC = (props) => { {channelItem && ( {(provided, snapshot) => { + const hasMultiJoins = datasetJoinPaths[channelItem.dataset ?? DEFAULT_DATASET]?.length > 1; + return (
= (props) => { )} - {channelItem.fid !== MEA_KEY_ID && {channelItem.name}}{' '} + {channelItem.fid !== MEA_KEY_ID && ( + + {isMultiDataset && channelItem.dataset ? `${datasetNames?.[channelItem.dataset] ?? channelItem.dataset}.` : ''} + {channelItem.name} + + )} + {hasMultiJoins && channelItem.joinPath && ( + vizStore.editFieldName(props.dkey.id, 0, name)} + desc={ +
+ This Field is Joined with below: +
{vizStore.renderJoinPath(channelItem.joinPath ?? [], datasetNames)}
+
+ } + > + +
+ )} {channelItem.analyticType === 'measure' && channelItem.fid !== COUNT_FIELD_ID && config.defaultAggregated && diff --git a/packages/graphic-walker/src/fields/fieldsContext.tsx b/packages/graphic-walker/src/fields/fieldsContext.tsx index df8618dd..98857bfa 100644 --- a/packages/graphic-walker/src/fields/fieldsContext.tsx +++ b/packages/graphic-walker/src/fields/fieldsContext.tsx @@ -93,9 +93,15 @@ export const FieldsContextWrapper: React.FC<{ children?: React.ReactNode | Itera if (destination.index === result.source.index) return; vizStore.reorderField(destination.droppableId as keyof DraggableFieldState, result.source.index, destination.index); } else { - let sourceKey = result.source.droppableId as keyof DraggableFieldState; - let targetKey = destination.droppableId as keyof DraggableFieldState; - vizStore.moveField(sourceKey, result.source.index, targetKey, destination.index); + let sourceKey = result.source.droppableId.split('_')[0] as keyof DraggableFieldState; + let targetKey = destination.droppableId.split('_')[0] as keyof DraggableFieldState; + if (result.draggableId.startsWith('_')) { + const pathId = result.draggableId.split('_')[1]; + const joinPath = vizStore.decodeJoinPath(pathId); + vizStore.moveField(sourceKey, result.source.index, targetKey, destination.index, joinPath, true); + } else { + vizStore.moveField(sourceKey, result.source.index, targetKey, destination.index, null, true); + } } }, [vizStore] diff --git a/packages/graphic-walker/src/fields/filterField/filterEditDialog.tsx b/packages/graphic-walker/src/fields/filterField/filterEditDialog.tsx index 24b2c4f1..be907968 100644 --- a/packages/graphic-walker/src/fields/filterField/filterEditDialog.tsx +++ b/packages/graphic-walker/src/fields/filterField/filterEditDialog.tsx @@ -1,7 +1,7 @@ import { observer } from 'mobx-react-lite'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import type { IAggregator, IComputationFunction, IFilterField, IFilterRule, IMutField } from '../../interfaces'; +import type { FieldIdentifier, IAggregator, IComputationFunction, IFilterField, IFilterRule, IMutField } from '../../interfaces'; import { ComputationContext, useCompututaion, useVizStore } from '../../store'; import Tabs, { RuleFormProps } from './tabs'; import DropdownSelect, { IDropdownSelectOption } from '../../components/dropdownSelect'; @@ -9,7 +9,7 @@ import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from '../../constants'; import { GLOBAL_CONFIG } from '../../config'; import { toWorkflow } from '../../utils/workflow'; import { useRefControledState } from '../../hooks'; -import { getFilterMeaAggKey, getMeaAggKey } from '../../utils'; +import { getFieldIdentifier, getFilterMeaAggKey, getMeaAggKey } from '../../utils'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; @@ -40,11 +40,11 @@ const EmptyForm: React.FC = () => ; export const PureFilterEditDialog = (props: { viewFilters: IFilterField[]; - options: { label: string; value: string }[]; + options: { label: string; value: FieldIdentifier }[]; meta: IMutField[]; editingFilterIdx: number | null; displayOffset?: number; - onSelectFilter: (field: string) => void; + onSelectFilter: (field: FieldIdentifier) => void; onWriteFilter: (index: number, rule: IFilterRule | null) => void; onSelectAgg?: (index: number, aggName: IAggregator | null) => void; onClose: () => void; @@ -102,7 +102,7 @@ export const PureFilterEditDialog = (props: { buttonClassName="w-96" className="mb-2" options={options} - selectedKey={uncontrolledField.fid} + selectedKey={getFieldIdentifier(uncontrolledField)} onSelect={onSelectFilter} />
@@ -138,55 +138,55 @@ export const PureFilterEditDialog = (props: { const FilterEditDialog: React.FC = observer(() => { const vizStore = useVizStore(); - const { editingFilterIdx, viewFilters, dimensions, measures, meta, allFields, viewDimensions, config } = vizStore; + const { editingFilterIdx, viewFilters, dimensions, measures, meta, allFields, viewDimensions, viewMeasures, config, multiViewInfo } = vizStore; const { timezoneDisplayOffset } = config; const computation = useCompututaion(); - const originalField = - editingFilterIdx !== null - ? viewFilters[editingFilterIdx]?.enableAgg - ? allFields.find((x) => x.fid === viewFilters[editingFilterIdx].fid) - : undefined - : undefined; + const originalField = editingFilterIdx !== null ? viewFilters[editingFilterIdx] : undefined; const filterAggName = editingFilterIdx !== null ? (viewFilters[editingFilterIdx]?.enableAgg ? viewFilters[editingFilterIdx].aggName : undefined) : undefined; const transformedComputation = useMemo((): IComputationFunction => { - if (originalField && viewDimensions.length > 0) { - const preWorkflow = toWorkflow( - [], + const useTransformedComputation = multiViewInfo.datasets.length > 1 || filterAggName; + if (useTransformedComputation) { + const { workflow, datasets } = toWorkflow( + viewFilters, allFields, viewDimensions, - [{ ...originalField, aggName: filterAggName }], - true, + viewMeasures.concat(filterAggName ? [{ ...originalField, aggName: filterAggName }] : []), + !!filterAggName, 'none', [], undefined, timezoneDisplayOffset - ).map((x) => { - if (x.type === 'view') { - return { - ...x, - query: x.query.map((q) => { - if (q.op === 'aggregate') { - return { ...q, measures: q.measures.map((m) => ({ ...m, asFieldKey: m.field })) }; - } - return q; - }), - }; - } - return x; - }); + ); return (query) => computation({ ...query, - workflow: preWorkflow.concat(query.workflow.filter((x) => x.type !== 'transform')), + workflow: workflow + .filter((x) => (filterAggName ? true : x.type !== 'view')) + .map((x) => { + if (x.type === 'view') { + return { + ...x, + query: x.query.map((q) => { + if (q.op === 'aggregate') { + return { ...q, measures: q.measures.map((m) => ({ ...m, asFieldKey: m.field })) }; + } + return q; + }), + }; + } + return x; + }) + .concat(query.workflow.filter((x) => x.type !== 'transform')), + datasets: Array.from(new Set(query.datasets.concat(datasets))), }); } else { return computation; } - }, [computation, viewDimensions, originalField, filterAggName]); + }, [computation, viewDimensions, viewMeasures, originalField, viewFilters, multiViewInfo.datasets.length, filterAggName]); const handelClose = React.useCallback(() => vizStore.closeFilterEditing(), [vizStore]); @@ -199,14 +199,16 @@ const FilterEditDialog: React.FC = observer(() => { [vizStore] ); - const handleSelectFilterField = (fieldKey) => { - const existingFilterIdx = viewFilters.findIndex((field) => field.fid === fieldKey); + const handleSelectFilterField = (fieldKey: FieldIdentifier) => { + const existingFilterIdx = viewFilters.findIndex((field) => getFieldIdentifier(field) === fieldKey); if (existingFilterIdx >= 0) { vizStore.setFilterEditing(existingFilterIdx); } else { - const sourceKey = dimensions.find((field) => field.fid === fieldKey) ? 'dimensions' : 'measures'; + const sourceKey = dimensions.find((field) => getFieldIdentifier(field) === fieldKey) ? 'dimensions' : 'measures'; const sourceIndex = - sourceKey === 'dimensions' ? dimensions.findIndex((field) => field.fid === fieldKey) : measures.findIndex((field) => field.fid === fieldKey); + sourceKey === 'dimensions' + ? dimensions.findIndex((field) => getFieldIdentifier(field) === fieldKey) + : measures.findIndex((field) => getFieldIdentifier(field) === fieldKey); if (editingFilterIdx !== null) { vizStore.modFilter(editingFilterIdx, sourceKey, sourceIndex); } @@ -215,12 +217,13 @@ const FilterEditDialog: React.FC = observer(() => { const allFieldOptions = React.useMemo(() => { return allFields + .filter((x) => x.dataset === viewFilters[editingFilterIdx || 0]?.dataset) .filter((x) => ![COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID].includes(x.fid)) .map((d) => ({ label: d.name, - value: d.fid, + value: getFieldIdentifier(d), })); - }, [allFields]); + }, [allFields, viewFilters[editingFilterIdx || 0]?.dataset]); const handleChangeAgg = (index: number, agg: IAggregator | null) => { vizStore.setFilterAggregator(index, agg ?? ''); @@ -236,7 +239,7 @@ const FilterEditDialog: React.FC = observer(() => { onClose={handelClose} onSelectFilter={handleSelectFilterField} onWriteFilter={handleWriteFilter} - viewFilters={viewFilters} + viewFilters={multiViewInfo.filters} onSelectAgg={handleChangeAgg} /> diff --git a/packages/graphic-walker/src/fields/filterField/filterPill.tsx b/packages/graphic-walker/src/fields/filterField/filterPill.tsx index a53bcc2c..af759363 100644 --- a/packages/graphic-walker/src/fields/filterField/filterPill.tsx +++ b/packages/graphic-walker/src/fields/filterField/filterPill.tsx @@ -1,10 +1,10 @@ import { observer } from 'mobx-react-lite'; -import React from 'react'; +import React, { useContext } from 'react'; import { DraggableProvided } from '@kanaries/react-beautiful-dnd'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; import { PencilSquareIcon } from '@heroicons/react/24/solid'; -import { useVizStore } from '../../store'; +import { DatasetNamesContext, useVizStore } from '../../store'; import { refMapper } from '../fieldsContext'; import { formatDate } from '../../utils'; import { parsedOffsetDate } from '../../lib/op/offset'; @@ -60,7 +60,7 @@ const Pill = styled.div` const FilterPill: React.FC = observer((props) => { const { provided, fIndex } = props; const vizStore = useVizStore(); - const { viewFilters, config } = vizStore; + const { viewFilters, config, isMultiDataset } = vizStore; const { timezoneDisplayOffset } = config; @@ -68,7 +68,11 @@ const FilterPill: React.FC = observer((props) => { const { t } = useTranslation('translation', { keyPrefix: 'filters' }); - const fieldName = field.enableAgg ? `${field.aggName}(${field.name})` : field.name; + const datasetNames = useContext(DatasetNamesContext); + + const datasetName = isMultiDataset && field.dataset ? `${datasetNames?.[field.dataset] ?? field.dataset}.` : ''; + + const fieldName = field.enableAgg ? `${datasetName}${field.aggName}(${field.name})` : `${datasetName}${field.name}`; return ( diff --git a/packages/graphic-walker/src/fields/filterField/simple.tsx b/packages/graphic-walker/src/fields/filterField/simple.tsx index 04eaa24f..20370764 100644 --- a/packages/graphic-walker/src/fields/filterField/simple.tsx +++ b/packages/graphic-walker/src/fields/filterField/simple.tsx @@ -345,7 +345,7 @@ export const SimpleTemporalRange: React.FC = ({ field, allFields, React.useEffect(() => { withComputedField(field, allFields, computationFunction, { timezoneDisplayOffset: displayOffset })((service) => - getTemporalRange(service, field.fid, field.offset) + getTemporalRange(service, field.fid, field.dataset, field.offset) ).then(([min, max, format]) => setRes([min, max, format, true])); }, [field.fid]); diff --git a/packages/graphic-walker/src/fields/filterField/tabs.tsx b/packages/graphic-walker/src/fields/filterField/tabs.tsx index 77a5cbbb..d057c5fa 100644 --- a/packages/graphic-walker/src/fields/filterField/tabs.tsx +++ b/packages/graphic-walker/src/fields/filterField/tabs.tsx @@ -751,7 +751,7 @@ export const FilterTemporalRangeRule: React.FC { withComputedField(field, allFields, computationFunction, { timezoneDisplayOffset: displayOffset })((service) => - getTemporalRange(service, field.fid, field.offset) + getTemporalRange(service, field.fid, field.dataset, field.offset) ).then(([min, max, format]) => setRes([min, max, format, true])); }, [field.fid]); diff --git a/packages/graphic-walker/src/fields/obComponents/obPill.tsx b/packages/graphic-walker/src/fields/obComponents/obPill.tsx index c0c35cac..6d073182 100644 --- a/packages/graphic-walker/src/fields/obComponents/obPill.tsx +++ b/packages/graphic-walker/src/fields/obComponents/obPill.tsx @@ -1,16 +1,19 @@ import { BarsArrowDownIcon, BarsArrowUpIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline'; import { observer } from 'mobx-react-lite'; -import React, { useMemo } from 'react'; +import React, { useMemo, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { DraggableProvided } from '@kanaries/react-beautiful-dnd'; -import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from '../../constants'; -import { IAggregator, IDraggableViewStateKey } from '../../interfaces'; -import { useVizStore } from '../../store'; +import { COUNT_FIELD_ID, DEFAULT_DATASET, MEA_KEY_ID, MEA_VAL_ID } from '../../constants'; +import { FieldIdentifier, IAggregator, IDraggableViewStateKey } from '../../interfaces'; +import { DatasetNamesContext, useVizStore } from '../../store'; import { Pill } from '../components'; import { GLOBAL_CONFIG } from '../../config'; import DropdownContext from '../../components/dropdownContext'; import SelectContext, { type ISelectContextOption } from '../../components/selectContext'; import { refMapper } from '../fieldsContext'; +import { PencilSquareIcon } from '@heroicons/react/24/outline'; +import Tooltip from '@/components/tooltip'; +import { EditNamePopover } from '../renamePanel'; interface PillProps { provided: DraggableProvided; @@ -20,7 +23,7 @@ interface PillProps { const OBPill: React.FC = (props) => { const { provided, dkey, fIndex } = props; const vizStore = useVizStore(); - const { config, allFields } = vizStore; + const { config, foldOptions, datasetJoinPaths, isMultiDataset } = vizStore; const field = vizStore.allEncodings[dkey.id][fIndex]; const { t } = useTranslation('translation', { keyPrefix: 'constant.aggregator' }); @@ -31,16 +34,11 @@ const OBPill: React.FC = (props) => { })); }, []); - const foldOptions = useMemo(() => { - const validFoldBy = allFields.filter((f) => f.analyticType === 'measure' && f.fid !== MEA_VAL_ID); - return validFoldBy.map((f) => ({ - key: f.fid, - label: f.name, - })); - }, [allFields]); - const folds = field.fid === MEA_KEY_ID ? config.folds ?? [] : null; + const datasetNames = useContext(DatasetNamesContext); + const hasMultiJoins = datasetJoinPaths[field.dataset ?? DEFAULT_DATASET]?.length > 1; + return ( = (props) => { {field.name} )} - {!folds && {field.name}} + {!folds && ( + + {isMultiDataset && field.dataset ? `${datasetNames?.[field.dataset] ?? field.dataset}.` : ''} + {field.name} + + )} + {hasMultiJoins && field.joinPath && ( + vizStore.editFieldName(props.dkey.id, props.fIndex, name)} + desc={ +
+ This Field is Joined with below: +
{vizStore.renderJoinPath(field.joinPath ?? [], datasetNames)}
+
+ } + > + +
+ )}   {field.analyticType === 'measure' && field.fid !== COUNT_FIELD_ID && config.defaultAggregated && field.aggName !== 'expr' && ( void; defaultValue: string }) => { + const [value, setValue] = useState(props.defaultValue); + const [open, setOpen] = useState(false); + return ( + { + setOpen(open); + if (open) { + setValue(props.defaultValue); + } else if (value !== props.defaultValue && value) { + props.onSubmit(value); + } + }} + > + {props.children} + +
+
+

Edit name

+
{props.desc}
+
+
+
+ + setValue(e.target.value)} + className="col-span-2 h-8" + onKeyDown={(e) => { + if (e.key === 'Enter') { + value !== props.defaultValue && value && props.onSubmit(value); + setOpen(false); + } + }} + /> +
+
+
+
+
+ ); +}; diff --git a/packages/graphic-walker/src/index.css b/packages/graphic-walker/src/index.css index f4e6bf3e..49e38986 100644 --- a/packages/graphic-walker/src/index.css +++ b/packages/graphic-walker/src/index.css @@ -3,6 +3,7 @@ code { } .App { + height: 100%; background-color: hsl(var(--background)); color: hsl(var(--foreground)); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, diff --git a/packages/graphic-walker/src/interfaces.ts b/packages/graphic-walker/src/interfaces.ts index 3a3d9535..79b55406 100644 --- a/packages/graphic-walker/src/interfaces.ts +++ b/packages/graphic-walker/src/interfaces.ts @@ -55,6 +55,8 @@ export interface IMutField { analyticType: IAnalyticType; path?: string[]; offset?: number; + dataset?: string; + foreign?: { dataset: string; fid: string }; } export interface IUncertainMutField { @@ -98,6 +100,8 @@ export interface IPaintMap { export interface IPaintDimension { fid: string; + joinPath?: IJoinPath[]; + dataset?: string; domain: | { type: 'nominal'; @@ -208,10 +212,22 @@ export interface IField { path?: string[]; offset?: number; aggergated?: boolean; + dataset?: string; + foreign?: { dataset: string; fid: string }; } export type ISortMode = 'none' | 'ascending' | 'descending'; + +export interface IJoinPath { + from: string; + fid: string; + to: string; + tid: string; +} export interface IViewField extends IField { sort?: ISortMode; + // used For field Identifier of transformed fields + originalFid?: string; + joinPath?: IJoinPath[]; } // shadow type of identifier of a Field, getting it using "getFieldIdentifier" in "@/utils" @@ -395,7 +411,7 @@ export interface IVisualConfig { geoKey?: string; geoUrl?: IGeoUrl; limit: number; - folds?: string[]; + folds?: FieldIdentifier[]; } export interface IVisualLayout { @@ -436,6 +452,7 @@ export interface IVisualLayout { background?: string; /** @default false */ scaleIncludeUnmatchedChoropleth?: boolean; + baseDataset?: string; showAllGeoshapeInChoropleth?: boolean; } @@ -445,8 +462,9 @@ export interface IVisualConfigNew { /** @default "generic" */ coordSystem?: ICoordMode; limit: number; - folds?: string[]; + folds?: FieldIdentifier[]; timezoneDisplayOffset?: number; + baseDataset?: string; } export interface IGeoUrl { @@ -606,6 +624,15 @@ export interface IVisFilter { rule: IFilterRule; } +export interface IDatasetForeign { + type: 'inner' | 'left' | 'right'; + keys: { dataset: string; as: string; field: string }[]; +} +export interface IJoinWorkflowStep { + type: 'join'; + foreigns: IDatasetForeign[]; +} + export interface IFilterWorkflowStep { type: 'filter'; filters: IVisFilter[]; @@ -627,10 +654,12 @@ export interface ISortWorkflowStep { by: string[]; } -export type IDataQueryWorkflowStep = IFilterWorkflowStep | ITransformWorkflowStep | IViewWorkflowStep | ISortWorkflowStep; +export type IDataQueryWorkflowStep = IJoinWorkflowStep | IFilterWorkflowStep | ITransformWorkflowStep | IViewWorkflowStep | ISortWorkflowStep; +export type IBasicDataQueryWorkflowStep = IFilterWorkflowStep | ITransformWorkflowStep | IViewWorkflowStep | ISortWorkflowStep; export interface IDataQueryPayload { workflow: IDataQueryWorkflowStep[]; + datasets: string[]; limit?: number; offset?: number; } @@ -862,6 +891,7 @@ export interface IChannelScales { export interface IAppI18nProps { i18nLang?: string; i18nResources?: { [lang: string]: Record }; + datasetNames?: Record; } export interface IThemeProps { @@ -935,7 +965,7 @@ export interface IVizStoreProps { /** @deprecated renamed to fields */ rawFields?: IMutField[]; fields?: IMutField[]; - onMetaChange?: (fid: string, meta: Partial) => void; + onMetaChange?: (fid: FieldIdentifier, meta: Partial) => void; defaultConfig?: IDefaultConfig; } @@ -945,8 +975,8 @@ export interface ILocalComputationProps { */ fieldKeyGuard?: boolean; /** @deprecated renamed to data */ - dataSource?: any[]; - data?: any[]; + dataSource?: any[] | Record; + data?: any[] | Record; computationTimeout?: number; } @@ -977,6 +1007,8 @@ export interface ISpecProps { export interface ITableSpecProps { pageSize?: number; + hideProfiling?: boolean; + hidePaginationAtOnepage?: boolean; displayOffset?: number; /** @deprecated use vizThemeConfig instead */ themeKey?: IThemeKey; diff --git a/packages/graphic-walker/src/lib/insights/explainBySelection.ts b/packages/graphic-walker/src/lib/insights/explainBySelection.ts index fc21d6f3..2367cfdb 100644 --- a/packages/graphic-walker/src/lib/insights/explainBySelection.ts +++ b/packages/graphic-walker/src/lib/insights/explainBySelection.ts @@ -1,4 +1,15 @@ -import { IAggregator, IExplainProps, IPredicate, IField, IRow, IViewField, IFilterField, IComputationFunction, IViewWorkflowStep, IDataQueryWorkflowStep } from '../../interfaces'; +import { + IAggregator, + IExplainProps, + IPredicate, + IField, + IRow, + IViewField, + IFilterField, + IComputationFunction, + IViewWorkflowStep, + IDataQueryWorkflowStep, +} from '../../interfaces'; import { filterByPredicates, getMeaAggKey } from '../../utils'; import { compareDistribution, compareDistributionKL, compareDistributionJS, normalizeWithParent } from '../../utils/normalization'; import { aggregate } from '../op/aggregate'; @@ -44,22 +55,44 @@ export async function explainBySelection(props: { op: 'bin', as: extendDimFid, num: QUANT_BIN_NUM, - params: [{ - type: 'field', - value: extendDim.fid, - }] - } - } + params: [ + { + type: 'field', + value: extendDim.fid, + }, + ], + }, + }, ], }); } for (let mea of viewMeasures) { - const overallWorkflow = toWorkflow(viewFilters, allFields, [extendDim], [mea], true, 'none', [], undefined, timezoneDisplayOffset); + const { workflow: overallWorkflow, datasets } = toWorkflow( + viewFilters, + allFields, + [extendDim], + [mea], + true, + 'none', + [], + undefined, + timezoneDisplayOffset + ); const fullOverallWorkflow = extraPreWorkflow ? [...extraPreWorkflow, ...overallWorkflow] : overallWorkflow; - const overallData = await dataQuery(computationFunction, fullOverallWorkflow); - const viewWorkflow = toWorkflow(viewFilters, allFields, [...viewDimensions, extendDim], [mea], true, 'none', [], undefined, timezoneDisplayOffset); + const overallData = await dataQuery(computationFunction, fullOverallWorkflow, datasets); + const { workflow: viewWorkflow, datasets: viewDatasets } = toWorkflow( + viewFilters, + allFields, + [...viewDimensions, extendDim], + [mea], + true, + 'none', + [], + undefined, + timezoneDisplayOffset + ); const fullViewWorkflow = extraPreWorkflow ? [...extraPreWorkflow, ...viewWorkflow] : viewWorkflow; - const viewData = await dataQuery(computationFunction, fullViewWorkflow); + const viewData = await dataQuery(computationFunction, fullViewWorkflow, viewDatasets); const subData = filterByPredicates(viewData, predicates); let outlierNormalization = normalizeWithParent(subData, overallData, [getMeaAggKey(mea.fid, mea.aggName ?? 'sum')], false); let outlierScore = compareDistributionJS( diff --git a/packages/graphic-walker/src/lib/insights/utils.ts b/packages/graphic-walker/src/lib/insights/utils.ts index a4329901..49a2bb34 100644 --- a/packages/graphic-walker/src/lib/insights/utils.ts +++ b/packages/graphic-walker/src/lib/insights/utils.ts @@ -1,3 +1,4 @@ +import { isSameField } from '@/utils'; import { IField } from '../../interfaces'; export function groupByAnalyticTypes(fields: IField[]) { @@ -10,5 +11,5 @@ export function groupByAnalyticTypes(fields: IField[]) { } export function complementaryFields(props: { selection: IField[]; all: IField[] }): IField[] { - return props.all.filter((f) => f.analyticType === 'dimension').filter((f) => !props.selection.find((vf) => vf.fid === f.fid)); + return props.all.filter((f) => f.analyticType === 'dimension').filter((f) => !props.selection.find(isSameField(f))); } diff --git a/packages/graphic-walker/src/lib/join.ts b/packages/graphic-walker/src/lib/join.ts new file mode 100644 index 00000000..3ffd9a51 --- /dev/null +++ b/packages/graphic-walker/src/lib/join.ts @@ -0,0 +1,34 @@ +import { IRow, IDatasetForeign } from '../interfaces'; + +function transformDataset(data: IRow[], name: string) { + return data.map(transformData(name)); +} + +function transformData(name: string) { + return (row: IRow) => Object.fromEntries(Object.entries(row).map(([k, v]) => [`${name}.${k}`, v])); +} + +export function join(rawDatasets: Record, foreigns: IDatasetForeign[]) { + let res: IRow[] | undefined; + const joined = new Set(); + foreigns.forEach((foreign) => { + if (!res) { + res = transformDataset(rawDatasets[foreign.keys[0].dataset], foreign.keys[0].as); + joined.add(foreign.keys[0].as); + } + const base = foreign.keys.find((x) => joined.has(x.as))!; + const links = foreign.keys.filter((x) => !joined.has(x.as)); + + const baseKey = `${base.as}.${base.field}`; + links.forEach(({ as, dataset, field }) => { + res = res!.flatMap((row) => + rawDatasets[dataset] + .filter((rowToJoin) => rowToJoin[field] === row[baseKey]) + .map(transformData(as)) + .map((additionData) => ({ ...additionData, ...row })) + ); + joined.add(as); + }); + }); + return res; +} diff --git a/packages/graphic-walker/src/lib/op/dateTime.test.ts b/packages/graphic-walker/src/lib/op/dateTime.test.ts new file mode 100644 index 00000000..f5f68fac --- /dev/null +++ b/packages/graphic-walker/src/lib/op/dateTime.test.ts @@ -0,0 +1,539 @@ +import dateTimeDrill from './dateTimeDrill'; +import dateTimeFeature from './dateTimeFeature'; + +function bind(a: any[], b: any[]) { + return a.map((v, i) => [v, b[i]]); +} + +const getParams = (value: T) => [ + { + type: 'field', + value: 'date', + } as const, + { + type: 'value', + value, + } as const, + { + type: 'displayOffset', + value: 0, + } as const, +]; + +const date = [ + '2010-01-01T07:41:08.000Z', + '2010-04-22T03:21:58.000Z', + '2010-12-31T15:38:24.000Z', + '2011-01-01T08:55:46.000Z', + '2011-08-15T14:23:12.000Z', + '2011-12-31T04:45:38.000Z', + '2012-01-01T21:53:07.000Z', + '2012-02-20T14:33:22.000Z', + '2012-12-31T01:49:05.000Z', + '2013-01-01T17:06:33.000Z', + '2013-10-05T02:11:42.000Z', + '2013-12-31T12:42:53.000Z', + '2014-01-01T13:45:49.000Z', + '2014-07-04T08:33:12.000Z', + '2014-12-30T16:39:56.000Z', + '2015-01-01T20:09:42.000Z', + '2015-11-11T18:25:08.000Z', + '2015-12-31T06:45:31.000Z', + '2016-01-01T11:33:45.000Z', + '2016-09-09T15:01:40.000Z', + '2016-12-31T04:03:11.000Z', + '2017-01-01T13:22:04.000Z', + '2017-06-03T09:38:09.000Z', + '2017-12-31T12:14:58.000Z', + '2018-01-01T22:54:44.000Z', + '2018-05-05T17:43:32.000Z', + '2018-12-31T04:45:25.000Z', +]; + +// export const DATE_TIME_DRILL_LEVELS = [ +// "year", "quarter", "month", "week", "day", "hour", "minute", "second", 'iso_year', 'iso_week', +// ] as const; +describe('drill', () => { + test('year', () => { + const { result } = dateTimeDrill('result', getParams('year'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + ]) + ); + }); + test('quarter', () => { + const { result } = dateTimeDrill( + 'result', + getParams('quarter'), + + { date } + ); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T00:00:00.000Z', + '2010-04-01T00:00:00.000Z', + '2010-10-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-07-01T00:00:00.000Z', + '2011-10-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-10-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-10-01T00:00:00.000Z', + '2013-10-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-07-01T00:00:00.000Z', + '2014-10-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-10-01T00:00:00.000Z', + '2015-10-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-07-01T00:00:00.000Z', + '2016-10-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-04-01T00:00:00.000Z', + '2017-10-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-04-01T00:00:00.000Z', + '2018-10-01T00:00:00.000Z', + ]) + ); + }); + test('month', () => { + const { result } = dateTimeDrill('result', getParams('month'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T00:00:00.000Z', + '2010-04-01T00:00:00.000Z', + '2010-12-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-08-01T00:00:00.000Z', + '2011-12-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-02-01T00:00:00.000Z', + '2012-12-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-10-01T00:00:00.000Z', + '2013-12-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-07-01T00:00:00.000Z', + '2014-12-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-11-01T00:00:00.000Z', + '2015-12-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-09-01T00:00:00.000Z', + '2016-12-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-06-01T00:00:00.000Z', + '2017-12-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-05-01T00:00:00.000Z', + '2018-12-01T00:00:00.000Z', + ]) + ); + }); + test('week', () => { + const { result } = dateTimeDrill('result', getParams('week'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2009-12-27T00:00:00.000Z', + '2010-04-18T00:00:00.000Z', + '2010-12-26T00:00:00.000Z', + '2010-12-26T00:00:00.000Z', + '2011-08-14T00:00:00.000Z', + '2011-12-25T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-02-19T00:00:00.000Z', + '2012-12-30T00:00:00.000Z', + '2012-12-30T00:00:00.000Z', + '2013-09-29T00:00:00.000Z', + '2013-12-29T00:00:00.000Z', + '2013-12-29T00:00:00.000Z', + '2014-06-29T00:00:00.000Z', + '2014-12-28T00:00:00.000Z', + '2014-12-28T00:00:00.000Z', + '2015-11-08T00:00:00.000Z', + '2015-12-27T00:00:00.000Z', + '2015-12-27T00:00:00.000Z', + '2016-09-04T00:00:00.000Z', + '2016-12-25T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-05-28T00:00:00.000Z', + '2017-12-31T00:00:00.000Z', + '2017-12-31T00:00:00.000Z', + '2018-04-29T00:00:00.000Z', + '2018-12-30T00:00:00.000Z', + ]) + ); + }); + test('day', () => { + const { result } = dateTimeDrill('result', getParams('day'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T00:00:00.000Z', + '2010-04-22T00:00:00.000Z', + '2010-12-31T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-08-15T00:00:00.000Z', + '2011-12-31T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-02-20T00:00:00.000Z', + '2012-12-31T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-10-05T00:00:00.000Z', + '2013-12-31T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-07-04T00:00:00.000Z', + '2014-12-30T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-11-11T00:00:00.000Z', + '2015-12-31T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-09-09T00:00:00.000Z', + '2016-12-31T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-06-03T00:00:00.000Z', + '2017-12-31T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-05-05T00:00:00.000Z', + '2018-12-31T00:00:00.000Z', + ]) + ); + }); + test('hour', () => { + const { result } = dateTimeDrill('result', getParams('hour'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T07:00:00.000Z', + '2010-04-22T03:00:00.000Z', + '2010-12-31T15:00:00.000Z', + '2011-01-01T08:00:00.000Z', + '2011-08-15T14:00:00.000Z', + '2011-12-31T04:00:00.000Z', + '2012-01-01T21:00:00.000Z', + '2012-02-20T14:00:00.000Z', + '2012-12-31T01:00:00.000Z', + '2013-01-01T17:00:00.000Z', + '2013-10-05T02:00:00.000Z', + '2013-12-31T12:00:00.000Z', + '2014-01-01T13:00:00.000Z', + '2014-07-04T08:00:00.000Z', + '2014-12-30T16:00:00.000Z', + '2015-01-01T20:00:00.000Z', + '2015-11-11T18:00:00.000Z', + '2015-12-31T06:00:00.000Z', + '2016-01-01T11:00:00.000Z', + '2016-09-09T15:00:00.000Z', + '2016-12-31T04:00:00.000Z', + '2017-01-01T13:00:00.000Z', + '2017-06-03T09:00:00.000Z', + '2017-12-31T12:00:00.000Z', + '2018-01-01T22:00:00.000Z', + '2018-05-05T17:00:00.000Z', + '2018-12-31T04:00:00.000Z', + ]) + ); + }); + test('minute', () => { + const { result } = dateTimeDrill('result', getParams('minute'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T07:41:00.000Z', + '2010-04-22T03:21:00.000Z', + '2010-12-31T15:38:00.000Z', + '2011-01-01T08:55:00.000Z', + '2011-08-15T14:23:00.000Z', + '2011-12-31T04:45:00.000Z', + '2012-01-01T21:53:00.000Z', + '2012-02-20T14:33:00.000Z', + '2012-12-31T01:49:00.000Z', + '2013-01-01T17:06:00.000Z', + '2013-10-05T02:11:00.000Z', + '2013-12-31T12:42:00.000Z', + '2014-01-01T13:45:00.000Z', + '2014-07-04T08:33:00.000Z', + '2014-12-30T16:39:00.000Z', + '2015-01-01T20:09:00.000Z', + '2015-11-11T18:25:00.000Z', + '2015-12-31T06:45:00.000Z', + '2016-01-01T11:33:00.000Z', + '2016-09-09T15:01:00.000Z', + '2016-12-31T04:03:00.000Z', + '2017-01-01T13:22:00.000Z', + '2017-06-03T09:38:00.000Z', + '2017-12-31T12:14:00.000Z', + '2018-01-01T22:54:00.000Z', + '2018-05-05T17:43:00.000Z', + '2018-12-31T04:45:00.000Z', + ]) + ); + }); + test('second', () => { + const { result } = dateTimeDrill('result', getParams('second'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T07:41:08.000Z', + '2010-04-22T03:21:58.000Z', + '2010-12-31T15:38:24.000Z', + '2011-01-01T08:55:46.000Z', + '2011-08-15T14:23:12.000Z', + '2011-12-31T04:45:38.000Z', + '2012-01-01T21:53:07.000Z', + '2012-02-20T14:33:22.000Z', + '2012-12-31T01:49:05.000Z', + '2013-01-01T17:06:33.000Z', + '2013-10-05T02:11:42.000Z', + '2013-12-31T12:42:53.000Z', + '2014-01-01T13:45:49.000Z', + '2014-07-04T08:33:12.000Z', + '2014-12-30T16:39:56.000Z', + '2015-01-01T20:09:42.000Z', + '2015-11-11T18:25:08.000Z', + '2015-12-31T06:45:31.000Z', + '2016-01-01T11:33:45.000Z', + '2016-09-09T15:01:40.000Z', + '2016-12-31T04:03:11.000Z', + '2017-01-01T13:22:04.000Z', + '2017-06-03T09:38:09.000Z', + '2017-12-31T12:14:58.000Z', + '2018-01-01T22:54:44.000Z', + '2018-05-05T17:43:32.000Z', + '2018-12-31T04:45:25.000Z', + ]) + ); + }); + + test('iso_year', () => { + const { result } = dateTimeDrill('result', getParams('iso_year'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2009-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2019-01-01T00:00:00.000Z', + ]) + ); + }); + test('iso_week', () => { + const { result } = dateTimeDrill('result', getParams('iso_week'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2009-12-28T00:00:00.000Z', + '2010-04-19T00:00:00.000Z', + '2010-12-27T00:00:00.000Z', + '2010-12-27T00:00:00.000Z', + '2011-08-15T00:00:00.000Z', + '2011-12-26T00:00:00.000Z', + '2011-12-26T00:00:00.000Z', + '2012-02-20T00:00:00.000Z', + '2012-12-31T00:00:00.000Z', + '2012-12-31T00:00:00.000Z', + '2013-09-30T00:00:00.000Z', + '2013-12-30T00:00:00.000Z', + '2013-12-30T00:00:00.000Z', + '2014-06-30T00:00:00.000Z', + '2014-12-29T00:00:00.000Z', + '2014-12-29T00:00:00.000Z', + '2015-11-09T00:00:00.000Z', + '2015-12-28T00:00:00.000Z', + '2015-12-28T00:00:00.000Z', + '2016-09-05T00:00:00.000Z', + '2016-12-26T00:00:00.000Z', + '2016-12-26T00:00:00.000Z', + '2017-05-29T00:00:00.000Z', + '2017-12-25T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-04-30T00:00:00.000Z', + '2018-12-31T00:00:00.000Z', + ]) + ); + }); +}); + +describe('feature', () => { + test('year', () => { + const { result } = dateTimeFeature('result', getParams('year'), { date }); + expect(result).toEqual([ + 2010, 2010, 2010, 2011, 2011, 2011, 2012, 2012, 2012, 2013, 2013, 2013, 2014, 2014, 2014, 2015, 2015, 2015, 2016, 2016, 2016, 2017, 2017, 2017, + 2018, 2018, 2018, + ]); + }); + test('quarter', () => { + const { result } = dateTimeFeature('result', getParams('quarter'), { date }); + expect(bind(date, result)).toEqual(bind(date, [1, 2, 4, 1, 3, 4, 1, 1, 4, 1, 4, 4, 1, 3, 4, 1, 4, 4, 1, 3, 4, 1, 2, 4, 1, 2, 4])); + }); + test('month', () => { + const { result } = dateTimeFeature('result', getParams('month'), { date }); + expect(bind(date, result)).toEqual(bind(date, [1, 4, 12, 1, 8, 12, 1, 2, 12, 1, 10, 12, 1, 7, 12, 1, 11, 12, 1, 9, 12, 1, 6, 12, 1, 5, 12])); + }); + test('week', () => { + const { result } = dateTimeFeature('result', getParams('week'), { date }); + expect(bind(date, result)).toEqual(bind(date, [0, 16, 52, 0, 33, 52, 1, 8, 53, 0, 39, 52, 0, 26, 52, 0, 45, 52, 0, 36, 52, 1, 22, 53, 0, 17, 52])); + }); + test('weekday', () => { + const { result } = dateTimeFeature('result', getParams('weekday'), { date }); + expect(bind(date, result)).toEqual(bind(date, [5, 4, 5, 6, 1, 6, 0, 1, 1, 2, 6, 2, 3, 5, 2, 4, 3, 4, 5, 5, 6, 0, 6, 0, 1, 6, 1])); + }); + test('day', () => { + const { result } = dateTimeFeature('result', getParams('day'), { date }); + expect(bind(date, result)).toEqual(bind(date, [1, 22, 31, 1, 15, 31, 1, 20, 31, 1, 5, 31, 1, 4, 30, 1, 11, 31, 1, 9, 31, 1, 3, 31, 1, 5, 31])); + }); + test('hour', () => { + const { result } = dateTimeFeature('result', getParams('hour'), { date }); + expect(bind(date, result)).toEqual(bind(date, [7, 3, 15, 8, 14, 4, 21, 14, 1, 17, 2, 12, 13, 8, 16, 20, 18, 6, 11, 15, 4, 13, 9, 12, 22, 17, 4])); + }); + test('minute', () => { + const { result } = dateTimeFeature('result', getParams('minute'), { date }); + expect(bind(date, result)).toEqual( + bind(date, [41, 21, 38, 55, 23, 45, 53, 33, 49, 6, 11, 42, 45, 33, 39, 9, 25, 45, 33, 1, 3, 22, 38, 14, 54, 43, 45]) + ); + }); + test('second', () => { + const { result } = dateTimeFeature('result', getParams('second'), { date }); + expect(bind(date, result)).toEqual(bind(date, [8, 58, 24, 46, 12, 38, 7, 22, 5, 33, 42, 53, 49, 12, 56, 42, 8, 31, 45, 40, 11, 4, 9, 58, 44, 32, 25])); + }); + test('iso_year', () => { + const { result } = dateTimeFeature('result', getParams('iso_year'), { date }); + expect(bind(date, result)).toEqual( + bind( + date, + [ + 2009, 2010, 2010, 2010, 2011, 2011, 2011, 2012, 2013, 2013, 2013, 2014, 2014, 2014, 2015, 2015, 2015, 2015, 2015, 2016, 2016, 2016, 2017, + 2017, 2018, 2018, 2019, + ] + ) + ); + }); + test('iso_week', () => { + const { result } = dateTimeFeature('result', getParams('iso_week'), { date }); + expect(bind(date, result)).toEqual(bind(date, [53, 16, 52, 52, 33, 52, 52, 8, 1, 1, 40, 1, 1, 27, 1, 1, 46, 53, 53, 36, 52, 52, 22, 52, 1, 18, 1])); + }); + test('iso_weekday', () => { + const { result } = dateTimeFeature('result', getParams('iso_weekday'), { date }); + expect(bind(date, result)).toEqual(bind(date, [5, 4, 5, 6, 1, 6, 7, 1, 1, 2, 6, 2, 3, 5, 2, 4, 3, 4, 5, 5, 6, 7, 6, 7, 1, 6, 1])); + }); +}); + +describe('edge', () => { + const edgeDates = [ + '2012-12-31T00:00:00.000Z', + '2016-01-03T00:00:00.000Z', + '2016-01-04T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-01-02T00:00:00.000Z', + '2017-01-03T00:00:00.000Z', + '2017-12-31T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-01-02T00:00:00.000Z', + ]; + test('week', () => { + const { result } = dateTimeFeature('result', getParams('week'), { date: edgeDates }); + expect(bind(edgeDates, result)).toEqual(bind(edgeDates, [53, 1, 1, 1, 1, 1, 53, 0, 0])); + }); + test('iso_week', () => { + const { result } = dateTimeFeature('result', getParams('iso_week'), { date: edgeDates }); + expect(bind(edgeDates, result)).toEqual(bind(edgeDates, [1, 53, 1, 52, 1, 1, 52, 1, 1])); + }); +}); diff --git a/packages/graphic-walker/src/lib/op/dateTimeDrill.ts b/packages/graphic-walker/src/lib/op/dateTimeDrill.ts index 0c630ae3..a2f86c7b 100644 --- a/packages/graphic-walker/src/lib/op/dateTimeDrill.ts +++ b/packages/graphic-walker/src/lib/op/dateTimeDrill.ts @@ -1,10 +1,16 @@ import { DATE_TIME_DRILL_LEVELS } from '../../constants'; import type { IExpParameter } from '../../interfaces'; import type { IDataFrame } from '../execExp'; -import { newOffsetDate } from './offset'; +import { OffsetDate, newOffsetDate } from './offset'; const formatDate = (date: Date) => date.getTime(); +const isoLargeYears = [ + 4, 9, 15, 20, 26, 32, 37, 43, 48, 54, 60, 65, 71, 76, 82, 88, 93, 99, 105, 111, 116, 122, 128, 133, 139, 144, 150, 156, 161, 167, 172, 178, 184, 189, 195, + 201, 207, 212, 218, 224, 229, 235, 240, 246, 252, 257, 263, 268, 274, 280, 285, 291, 296, 303, 308, 314, 320, 325, 331, 336, 342, 348, 353, 359, 364, 370, + 376, 381, 387, 392, 398, +]; + function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame): IDataFrame { const fieldKey = params.find((p) => p.type === 'field')?.value; const drillLevel = params.find((p) => p.type === 'value')?.value as (typeof DATE_TIME_DRILL_LEVELS)[number] | undefined; @@ -80,6 +86,40 @@ function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame [resKey]: newValues, }; } + case 'iso_year': { + const newValues = fieldValues.map((v) => { + const date = newDate(v); + const _Y = date.getFullYear(); + const dayInFirstWeek = toOffsetDate(_Y, 0, 4); + const firstMondayOfYear = newDate(newDate(dayInFirstWeek).setDate(dayInFirstWeek.getDate() - (dayInFirstWeek.getDay() || 7) + 1)); + if (date.getTime() < firstMondayOfYear.getTime()) { + return formatDate(toOffsetDate(_Y - 1, 0, 1)); + } + const nextDayInFirstWeek = toOffsetDate(_Y + 1, 0, 4); + const nextFirstMondayOfYear = newDate( + newDate(nextDayInFirstWeek).setDate(nextDayInFirstWeek.getDate() - (nextDayInFirstWeek.getDay() || 7) + 1) + ); + return formatDate(toOffsetDate(date.getTime() < nextFirstMondayOfYear.getTime() ? _Y : _Y + 1, 0, 1)); + }); + return { + ...data, + [resKey]: newValues, + }; + } + case 'iso_week': { + const newValues = fieldValues.map((v) => { + const today = newDate(v); + const date = newDate(today.setDate(today.getDate() - (today.getDay() || 7) + 1)); + const Y = date.getFullYear(); + const M = date.getMonth(); + const D = date.getDate(); + return formatDate(toOffsetDate(Y, M, D)); + }); + return { + ...data, + [resKey]: newValues, + }; + } case 'hour': { const newValues = fieldValues.map((v) => { const date = newDate(v); diff --git a/packages/graphic-walker/src/lib/op/dateTimeFeature.ts b/packages/graphic-walker/src/lib/op/dateTimeFeature.ts index 0f7110a1..ffde6b85 100644 --- a/packages/graphic-walker/src/lib/op/dateTimeFeature.ts +++ b/packages/graphic-walker/src/lib/op/dateTimeFeature.ts @@ -15,6 +15,19 @@ function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame const prepareDate = newOffsetDate(offset); const toOffsetDate = newOffsetDate(displayOffset); const newDate = ((...x: []) => toOffsetDate(prepareDate(...x))) as typeof prepareDate; + function getISOYear(v: any) { + const date = newDate(v); + const y = date.getFullYear(); + const dayInFirstWeek = toOffsetDate(y, 0, 4); + const firstMondayOfYear = newDate(newDate(dayInFirstWeek).setDate(dayInFirstWeek.getDate() - (dayInFirstWeek.getDay() || 7) + 1)); + if (date.getTime() < firstMondayOfYear.getTime()) { + return y - 1; + } + const nextY = y + 1; + const nextDayInFirstWeek = toOffsetDate(nextY, 0, 4); + const nextFirstMondayOfYear = newDate(newDate(nextDayInFirstWeek).setDate(nextDayInFirstWeek.getDate() - (nextDayInFirstWeek.getDay() || 7) + 1)); + return date.getTime() < nextFirstMondayOfYear.getTime() ? y : nextY; + } switch (drillLevel) { case 'year': { const newValues = fieldValues.map((v) => { @@ -50,13 +63,12 @@ function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame case 'week': { const newValues = fieldValues.map((v) => { const date = newDate(v); - const _Y = date.getFullYear(); - const _firstDayOfYear = newDate(_Y, 0, 1); - const _SundayOfFirstWeek = newDate(newDate(_firstDayOfYear).setDate(_firstDayOfYear.getDate() - _firstDayOfYear.getDay())); - const Y = date.getTime() - _SundayOfFirstWeek.getTime() > 1_000 * 60 * 60 * 24 * 7 ? _Y : _SundayOfFirstWeek.getFullYear(); - const firstDayOfYear = newDate(Y, 0, 1); - const SundayOfFirstWeek = newDate(newDate(firstDayOfYear).setDate(firstDayOfYear.getDate() - firstDayOfYear.getDay())); - const W = Math.floor((date.getTime() - SundayOfFirstWeek.getTime()) / (7 * 24 * 60 * 60 * 1_000)) + 1; + const Y = date.getFullYear(); + const firstDayOfYear = toOffsetDate(Y, 0, 1); + const SundayOfFirstWeek = newDate(firstDayOfYear.setDate(firstDayOfYear.getDate() - firstDayOfYear.getDay())); + const FirstSundayOfYear = + SundayOfFirstWeek.getFullYear() === Y ? SundayOfFirstWeek : newDate(SundayOfFirstWeek.setDate(SundayOfFirstWeek.getDate() + 7)); + const W = Math.floor((date.getTime() - FirstSundayOfYear.getTime()) / (7 * 24 * 60 * 60 * 1_000)) + 1; return W; }); return { @@ -74,6 +86,37 @@ function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame [resKey]: newValues, }; } + case 'iso_year': { + const newValues = fieldValues.map(getISOYear); + return { + ...data, + [resKey]: newValues, + }; + } + case 'iso_week': { + const newValues = fieldValues.map((v) => { + const date = newDate(v); + const y = getISOYear(v); + const dayInFirstWeek = toOffsetDate(y, 0, 4); + const firstMondayOfYear = newDate(newDate(dayInFirstWeek).setDate(dayInFirstWeek.getDate() - (dayInFirstWeek.getDay() || 7) + 1)); + const W = Math.floor((date.getTime() - firstMondayOfYear.getTime()) / (7 * 24 * 60 * 60 * 1_000)) + 1; + return W; + }); + return { + ...data, + [resKey]: newValues, + }; + } + case 'iso_weekday': { + const newValues = fieldValues.map((v) => { + const date = newDate(v); + return date.getDay() || 7; + }); + return { + ...data, + [resKey]: newValues, + }; + } case 'day': { const newValues = fieldValues.map((v) => { const date = newDate(v); diff --git a/packages/graphic-walker/src/lib/op/fold.ts b/packages/graphic-walker/src/lib/op/fold.ts index 8e7e0cc1..c05980d8 100644 --- a/packages/graphic-walker/src/lib/op/fold.ts +++ b/packages/graphic-walker/src/lib/op/fold.ts @@ -1,6 +1,6 @@ import { MEA_VAL_ID, MEA_KEY_ID } from '../../constants'; -import { IRow, IViewField } from '../../interfaces'; -import { getMeaAggKey, getMeaAggName } from '../../utils'; +import { FieldIdentifier, IRow, IViewField } from '../../interfaces'; +import { getFieldIdentifier, getFilterMeaAggKey, getMeaAggKey, getMeaAggName } from '../../utils'; import { IFoldQuery } from '../../interfaces'; export function fold(data: IRow[], query: IFoldQuery): IRow[] { @@ -34,7 +34,7 @@ export function fold2( allFields: IViewField[], viewMeasures: IViewField[], viewDimensions: IViewField[], - folds?: string[] + folds?: FieldIdentifier[] ) { const meaVal = viewMeasures.find((x) => x.fid === MEA_VAL_ID); if (viewDimensions.find((x) => x.fid === MEA_KEY_ID) && meaVal) { @@ -42,7 +42,7 @@ export function fold2( return []; } const foldedFields = (folds ?? []) - .map((x) => allFields.find((y) => y.fid === x)!) + .map((x) => allFields.find((y) => getFieldIdentifier(y) === x)!) .filter(Boolean) .filter(x => defaultAggregated || x.aggName !== 'expr') .map((x) => { diff --git a/packages/graphic-walker/src/lib/paint.ts b/packages/graphic-walker/src/lib/paint.ts index 892450a9..38274b0e 100644 --- a/packages/graphic-walker/src/lib/paint.ts +++ b/packages/graphic-walker/src/lib/paint.ts @@ -1,4 +1,6 @@ +import { buildMultiDatasetQuery } from '@/utils/route'; import { IPaintDimension, IPaintMap, IPaintMapV2, IRow } from '../interfaces'; +import produce from 'immer'; const circles = new Map(); diff --git a/packages/graphic-walker/src/lib/sql.ts b/packages/graphic-walker/src/lib/sql.ts index 3d1c352a..91175074 100644 --- a/packages/graphic-walker/src/lib/sql.ts +++ b/packages/graphic-walker/src/lib/sql.ts @@ -595,7 +595,7 @@ export function walkFid(sql: string): string[] { return Array.from(set); } -export function replaceFid(sql: string, fields: IMutField[]): string { +export function replaceFid(sql: string, fields: IMutField[], transformFid = (x: string) => x): string { const dict = new Map(); fields.forEach((f) => { dict.set((f.name ?? f.fid).toLowerCase(), f); @@ -603,7 +603,7 @@ export function replaceFid(sql: string, fields: IMutField[]): string { const item = parseSQLExpr(sql); const mapper = parser.astMapper(() => ({ ref: (r) => { - return parser.assignChanged(r, { name: dict.get(r.name.toLowerCase())?.fid || r.name }); + return parser.assignChanged(r, { name: transformFid(dict.get(r.name.toLowerCase())?.fid || r.name) }); }, })); return parser.toSql.expr(mapper.expr(item)!); diff --git a/packages/graphic-walker/src/lib/vega.ts b/packages/graphic-walker/src/lib/vega.ts index 897a0f9b..6e4c482a 100644 --- a/packages/graphic-walker/src/lib/vega.ts +++ b/packages/graphic-walker/src/lib/vega.ts @@ -70,10 +70,6 @@ export function toVegaSpec({ const rowFacetField = rowLeftFacetFields.length > 0 ? rowLeftFacetFields[rowLeftFacetFields.length - 1] : NULL_FIELD; const colFacetField = colLeftFacetFields.length > 0 ? colLeftFacetFields[colLeftFacetFields.length - 1] : NULL_FIELD; - const geomFieldIds = [...rows, ...columns, color, opacity, size] - .filter((f) => Boolean(f)) - .filter((f) => f!.aggName !== 'expr') - .map((f) => (f as IViewField).fid); const spec: any = { data: { @@ -84,7 +80,6 @@ export function toVegaSpec({ name: 'geom', select: { type: 'point', - fields: geomFieldIds.map(encodeFid), }, }, ], @@ -123,7 +118,7 @@ export function toVegaSpec({ column: colFacetField, xOffset: NULL_FIELD, yOffset: NULL_FIELD, - details, + details: details.map(guard).filter((x) => x !== NULL_FIELD), defaultAggregated, stack, geomType, diff --git a/packages/graphic-walker/src/locales/en-US.json b/packages/graphic-walker/src/locales/en-US.json index b6bbb115..0c8b2bac 100644 --- a/packages/graphic-walker/src/locales/en-US.json +++ b/packages/graphic-walker/src/locales/en-US.json @@ -328,7 +328,10 @@ "day": "Day", "hour": "Hour", "minute": "Minute", - "second": "Second" + "second": "Second", + "iso_year": "ISO Year", + "iso_week": "ISO Week", + "iso_weekday": "ISO Weekday" } } }, diff --git a/packages/graphic-walker/src/locales/ja-JP.json b/packages/graphic-walker/src/locales/ja-JP.json index 9909ea86..e60742b6 100644 --- a/packages/graphic-walker/src/locales/ja-JP.json +++ b/packages/graphic-walker/src/locales/ja-JP.json @@ -322,7 +322,10 @@ "day": "日", "hour": "時", "minute": "分", - "second": "秒" + "second": "秒", + "iso_year": "ISO年", + "iso_week": "ISO週", + "iso_weekday": "ISO曜日" } } }, diff --git a/packages/graphic-walker/src/locales/zh-CN.json b/packages/graphic-walker/src/locales/zh-CN.json index 5f588882..b49ae418 100644 --- a/packages/graphic-walker/src/locales/zh-CN.json +++ b/packages/graphic-walker/src/locales/zh-CN.json @@ -328,7 +328,10 @@ "day": "日", "hour": "时", "minute": "分", - "second": "秒" + "second": "秒", + "iso_year": "ISO年", + "iso_week": "ISO周", + "iso_weekday": "ISO星期几" } } }, diff --git a/packages/graphic-walker/src/models/chat.ts b/packages/graphic-walker/src/models/chat.ts index 50e4bb2c..ba3d12ef 100644 --- a/packages/graphic-walker/src/models/chat.ts +++ b/packages/graphic-walker/src/models/chat.ts @@ -120,7 +120,7 @@ export function toVegaSimplified(chart: IChart) { } const actionMessageMapper: { - [a in Methods]: (data: IChart, ...a: PropsMap[a]) => string; + [a in Methods]?: (data: IChart, ...a: PropsMap[a]) => string; } = { [Methods.setConfig]: (_data, key, value) => `Set the ${key} config to ${JSON.stringify(value)}.`, [Methods.removeField]: (data, encoding, index) => { @@ -169,23 +169,12 @@ const actionMessageMapper: { const originalField = data.encodings[encoding][index]; return `Change the aggregator of ${originalField.name} field to ${aggName}.`; }, - [Methods.setGeoData]: () => ``, [Methods.setCoordSystem]: (_data, system) => `Change the mark to ${GLOBAL_CONFIG.GEOM_TYPES[system][0]}.`, - [Methods.createDateDrillField]: () => ``, - [Methods.createDateFeatureField]: () => ``, - [Methods.changeSemanticType]: () => ``, - [Methods.setFilterAggregator]: () => ``, - [Methods.addFoldField]: () => '', - [Methods.upsertPaintField]: () => '', - [Methods.addSQLComputedField]: () => '', - [Methods.removeAllField]: () => '', - [Methods.editAllField]: () => '', - [Methods.replaceWithNLPQuery]: () => '', }; function toMessage(data: IChart, action: VisActionOf) { const [type, ...props] = action; - return actionMessageMapper[type](data, ...props); + return actionMessageMapper[type]?.(data, ...props) ?? ''; } export function toChatMessage(history: VisSpecWithHistory): IChatMessage[] { diff --git a/packages/graphic-walker/src/models/visSpecHistory.ts b/packages/graphic-walker/src/models/visSpecHistory.ts index 944dc03c..ae8b165a 100644 --- a/packages/graphic-walker/src/models/visSpecHistory.ts +++ b/packages/graphic-walker/src/models/visSpecHistory.ts @@ -19,9 +19,11 @@ import { IField, IPaintMapV2, IDefaultConfig, + IJoinPath, + FieldIdentifier, } from '../interfaces'; import type { FeatureCollection } from 'geojson'; -import { arrayEqual, createCountField, createVirtualFields, isNotEmpty } from '../utils'; +import { arrayEqual, createCountField, createVirtualFields, getFieldIdentifier, isNotEmpty } from '../utils'; import { emptyEncodings, emptyVisualConfig, emptyVisualLayout, visSpecDecoder, forwardVisualConfigs } from '../utils/save'; import { AssertSameKey, KVTuple, insert, mutPath, remove, replace, uniqueId } from './utils'; import { WithHistory, atWith, create, freeze, performWith, redoWith, undoWith } from './withHistory'; @@ -61,15 +63,18 @@ export enum Methods { removeAllField, editAllField, replaceWithNLPQuery, + resetBaseDataset, + linkDataset, + renameField, } export type PropsMap = { [Methods.setConfig]: KVTuple; [Methods.removeField]: [keyof DraggableFieldState, number]; [Methods.reorderField]: [keyof DraggableFieldState, number, number]; [Methods.moveField]: [normalKeys, number, normalKeys, number, number | null]; - [Methods.cloneField]: [normalKeys, number, normalKeys, number, string, number | null]; + [Methods.cloneField]: [normalKeys, number, normalKeys, number, string, number | null, IJoinPath[] | null]; [Methods.createBinlogField]: [normalKeys, number, 'bin' | 'binCount' | 'log10' | 'log2' | 'log', string, number]; - [Methods.appendFilter]: [number, normalKeys, number, null]; + [Methods.appendFilter]: [number, normalKeys, number, null, IJoinPath[] | null]; [Methods.modFilter]: [number, normalKeys, number]; [Methods.writeFilter]: [number, IFilterRule | null]; [Methods.setName]: [string]; @@ -85,10 +90,13 @@ export type PropsMap = { [Methods.setFilterAggregator]: [number, IAggregator | '']; [Methods.addFoldField]: [normalKeys, number, normalKeys, number, string, number | null]; [Methods.upsertPaintField]: [IPaintMap | IPaintMapV2 | null, string]; - [Methods.addSQLComputedField]: [string, string, string]; - [Methods.removeAllField]: [string]; - [Methods.editAllField]: [string, Partial]; + [Methods.addSQLComputedField]: [string, string, string, string | null]; + [Methods.removeAllField]: [string, FieldIdentifier | null]; + [Methods.editAllField]: [string, Partial, FieldIdentifier | null]; [Methods.replaceWithNLPQuery]: [string, string]; + [Methods.resetBaseDataset]: [string, string[]]; + [Methods.linkDataset]: [normalKeys, number, string, string]; + [Methods.renameField]: [normalKeys, number, string]; }; // ensure propsMap has all keys of methods type assertPropsMap = AssertSameKey; @@ -124,8 +132,8 @@ const actions: { [to]: insert(data.encodings[to], field, tindex).slice(0, limit ?? Infinity), })); }, - [Methods.cloneField]: (data, from, findex, to, tindex, newVarKey, limit) => { - const field = { ...data.encodings[from][findex] }; + [Methods.cloneField]: (data, from, findex, to, tindex, newVarKey, limit, joinPath) => { + const field = { ...data.encodings[from][findex], joinPath: joinPath ?? [] }; return mutPath(data, 'encodings', (e) => ({ ...e, [to]: insert(data.encodings[to], field, tindex).slice(0, limit ?? Infinity), @@ -153,13 +161,14 @@ const actions: { ], num, }, + dataset: originField.dataset, }; if (!isBin) { newField.aggName = 'sum'; } return mutPath(data, `encodings.${channel}`, (a) => a.concat(newField)); }, - [Methods.appendFilter]: (data, index, from, findex, _dragId) => { + [Methods.appendFilter]: (data, index, from, findex, _dragId, joinPath) => { const originField = data.encodings[from][findex]; return mutPath(data, 'encodings.filters', (filters) => insert( @@ -167,6 +176,7 @@ const actions: { { ...originField, rule: null, + joinPath: joinPath ?? [], }, index ) @@ -253,6 +263,7 @@ const actions: { : []), ], }, + dataset: originField.dataset, }; return mutPath(data, `encodings.${channel}`, (a) => a.concat(newField)); }, @@ -295,6 +306,7 @@ const actions: { : []), ], }, + dataset: originField.dataset, }; return mutPath(data, `encodings.${channel}`, (a) => a.concat(newField)); }, @@ -314,7 +326,7 @@ const actions: { data = actions[Methods.setConfig]( data, 'folds', - validFoldBy.filter((_, i) => i === 0).map((x) => x.fid) + validFoldBy.filter((_, i) => i === 0).map((x) => getFieldIdentifier(x)) ); } if (originalField.fid === MEA_VAL_ID) { @@ -325,10 +337,10 @@ const actions: { if (meaKeyIndexes.length === 1) { // there is no Measure Name in Chart, add it in Details channel (which has no limit) const [fromKey, fromIndex] = meaKeyIndexes[0]; - data = actions[Methods.cloneField](data, fromKey, fromIndex, 'details', 0, `${newVarKey}_auto`, Infinity); + data = actions[Methods.cloneField](data, fromKey, fromIndex, 'details', 0, `${newVarKey}_auto`, Infinity, null); } } - return actions[Methods.cloneField](data, from, findex, to, tindex, newVarKey, limit); + return actions[Methods.cloneField](data, from, findex, to, tindex, newVarKey, limit, null); }, [Methods.upsertPaintField]: (data, map, name) => { if (!map) { @@ -406,7 +418,7 @@ const actions: { return result; }); }, - [Methods.addSQLComputedField]: (data, fid, name, sql) => { + [Methods.addSQLComputedField]: (data, fid, name, sql, dataset) => { const [type, isAgg] = getSQLItemAnalyticType(parseSQLExpr(sql), data.encodings.dimensions.concat(data.encodings.measures)); const analyticType = type === 'quantitative' ? 'measure' : 'dimension'; return mutPath(data, `encodings.${analyticType}s`, (f) => @@ -422,17 +434,18 @@ const actions: { as: fid, params: [{ type: 'sql', value: sql }], }, + dataset: dataset ?? undefined, }) ); }, - [Methods.removeAllField]: (data, fid) => { + [Methods.removeAllField]: (data, fid, identifier) => { return mutPath( data, 'encodings', (e) => Object.fromEntries( Object.entries(e).map(([fname, fields]) => { - const newFields = fields.filter((x) => x.fid !== fid); + const newFields = fields.filter(identifier ? (x) => getFieldIdentifier(x) !== identifier : (x) => x.fid !== fid); if (fields.length === newFields.length) { return [fname, fields]; } @@ -441,9 +454,11 @@ const actions: { ) as typeof e ); }, - [Methods.editAllField]: (data, fid, newData) => { + [Methods.editAllField]: (data, fid, newData, identifier) => { if (Object.keys(newData).includes('name')) { - const originalField = data.encodings.dimensions.concat(data.encodings.measures).find((x) => x.fid === fid); + const originalField = data.encodings.dimensions + .concat(data.encodings.measures) + .find(identifier ? (x) => getFieldIdentifier(x) === identifier : (x) => x.fid === fid); // if name is changed, update all computed fields return produce(data, (draft) => { if (!originalField) return; @@ -472,9 +487,9 @@ const actions: { (e) => Object.fromEntries( Object.entries(e).map(([fname, fields]) => { - const hasField = fields.find((x) => x.fid === fid); + const hasField = fields.find(identifier ? (x) => getFieldIdentifier(x) !== identifier : (x) => x.fid !== fid); if (hasField) { - return [fname, fields.map((x) => (x.fid === fid ? { ...x, ...newData } : x))]; + return [fname, fields.map((x) => ((identifier ? getFieldIdentifier(x) !== identifier : x.fid === fid) ? { ...x, ...newData } : x))]; } return [fname, fields]; }) @@ -484,6 +499,52 @@ const actions: { [Methods.replaceWithNLPQuery]: (data, _query, response) => { return { ...JSON.parse(response), visId: data.visId, name: data.name }; }, + [Methods.resetBaseDataset]: (data, newBaseDataset, datasets) => { + const set = new Set(datasets); + return actions[Methods.setLayout]( + actions[Methods.setConfig]( + mutPath( + data, + 'encodings', + (e) => + Object.fromEntries( + Object.entries(e).map(([fname, fields]) => { + if (['dimensions', 'measures'].includes(fname)) { + return [fname, fields]; + } + const newFields = fields.filter((x) => !set.has(x.dataset)); + if (fields.length === newFields.length) { + return [fname, fields]; + } + return [fname, newFields]; + }) + ) as typeof e + ), + 'baseDataset', + newBaseDataset + ), + [['baseDataset', newBaseDataset]] + ); + }, + [Methods.linkDataset]: (data, channel, index, targetDataset, field) => { + return mutPath(data, `encodings.${channel}`, (f) => + replace(f, index, (x) => ({ + ...x, + foreign: { + dataset: targetDataset, + fid: field, + }, + })) + ); + }, + [Methods.renameField]: (data, channel, index, newName) => { + return mutPath(data, `encodings.${channel}`, (f) => + replace(f, index, (x) => ({ + ...x, + name: newName, + })) + ); + }, }; const diffChangedEncodings = (prev: IChart, next: IChart) => { @@ -580,6 +641,8 @@ export function newChart(fields: IMutField[], name: string, visId?: string, defa semanticType: f.semanticType, analyticType: f.analyticType, offset: f.offset, + dataset: f.dataset, + foreign: f.foreign, }) ) .concat(extraDimensions), @@ -594,6 +657,8 @@ export function newChart(fields: IMutField[], name: string, visId?: string, defa semanticType: f.semanticType, aggName: 'sum', offset: f.offset, + dataset: f.dataset, + foreign: f.foreign, }) ) .concat(extraMeasures), diff --git a/packages/graphic-walker/src/renderer/hooks.ts b/packages/graphic-walker/src/renderer/hooks.ts index 9b7c433e..fec486f4 100644 --- a/packages/graphic-walker/src/renderer/hooks.ts +++ b/packages/graphic-walker/src/renderer/hooks.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useRef } from 'react'; import { unstable_batchedUpdates } from 'react-dom'; -import type { IFilterField, IRow, IViewField, IDataQueryWorkflowStep, IComputationFunction } from '../interfaces'; +import type { IFilterField, IRow, IViewField, IDataQueryWorkflowStep, IComputationFunction, FieldIdentifier } from '../interfaces'; import { useAppRootContext } from '../components/appRoot'; import { toWorkflow } from '../utils/workflow'; import { dataQuery } from '../computation'; @@ -15,7 +15,7 @@ interface UseRendererProps { sort: 'none' | 'ascending' | 'descending'; limit: number; computationFunction: IComputationFunction; - folds?: string[]; + folds?: FieldIdentifier[]; timezoneDisplayOffset?: number; } @@ -32,8 +32,18 @@ export const useRenderer = (props: UseRendererProps): UseRendererResult => { const [computing, setComputing] = useState(false); const taskIdRef = useRef(0); - const workflow = useMemo(() => { - return toWorkflow(filters, allFields, viewDimensions, viewMeasures, defaultAggregated, sort, folds, limit > 0 ? limit : undefined, timezoneDisplayOffset); + const { workflow, datasets } = useMemo(() => { + return toWorkflow( + filters, + allFields, + viewDimensions, + viewMeasures, + defaultAggregated, + sort, + folds, + limit > 0 ? limit : undefined, + timezoneDisplayOffset + ); }, [filters, allFields, viewDimensions, viewMeasures, defaultAggregated, sort, folds, limit]); const [viewData, setViewData] = useState([]); @@ -45,7 +55,7 @@ export const useRenderer = (props: UseRendererProps): UseRendererResult => { const taskId = ++taskIdRef.current; appRef.current?.updateRenderStatus('computing'); setComputing(true); - dataQuery(computationFunction, workflow, limit > 0 ? limit : undefined) + dataQuery(computationFunction, workflow, datasets, limit > 0 ? limit : undefined) .then((res) => fold2(res, defaultAggregated, allFields, viewMeasures, viewDimensions, folds)) .then((data) => { if (taskId !== taskIdRef.current) { diff --git a/packages/graphic-walker/src/renderer/index.tsx b/packages/graphic-walker/src/renderer/index.tsx index fd005542..e4ad0b15 100644 --- a/packages/graphic-walker/src/renderer/index.tsx +++ b/packages/graphic-walker/src/renderer/index.tsx @@ -29,6 +29,8 @@ import { GLOBAL_CONFIG } from '../config'; import { Item } from 'vega'; import { viewEncodingKeys } from '@/models/visSpec'; import LoadingLayer from '@/components/loadingLayer'; +import { getTimeFormat } from '@/lib/inferMeta'; +import { unexceptedUTCParsedPatternFormats } from '@/lib/op/offset'; interface RendererProps { vizThemeConfig: IThemeKey | GWGlobalConfig; @@ -52,6 +54,7 @@ const Renderer = forwardRef(function (props, r config: visualConfig, layout, currentVis: chart, + multiViewInfo, visIndex, visLength, sort, @@ -65,8 +68,7 @@ const Renderer = forwardRef(function (props, r }), [layout, overrideSize] ); - - const draggableFieldState = chart.encodings; + const draggableFieldState = { ...emptyEncodings, ...multiViewInfo.views, filters: multiViewInfo.filters }; const { i18n } = useTranslation(); @@ -142,29 +144,48 @@ const Renderer = forwardRef(function (props, r }); const handleGeomClick = useCallback( - (values: any, e: MouseEvent & { item: Item }) => { + (_values: any, e: MouseEvent & { item: Item }) => { e.stopPropagation(); if (GLOBAL_CONFIG.EMBEDED_MENU_LIST.length > 0) { runInAction(() => { vizStore.showEmbededMenu([e.clientX, e.clientY]); - vizStore.setFilters(values); }); - const selectedMarkObject = values.vlPoint.or[0]; + const viewKeys = new Set( + viewEncodingKeys(visualConfig.geoms[0]) + .flatMap((k) => encodings[k] as IViewField[]) + .map((x) => x.fid) + ); + // getting fields from event, because vega cannot pass selection including dot in key. + const selectedMarkObject = Object.fromEntries(Object.entries(e.item.datum).filter(([k]) => viewKeys.has(k))); // check selected fields include temporal, and return temporal timestamp to original data const allFields = viewEncodingKeys(visualConfig.geoms[0]).flatMap((k) => encodings[k] as IViewField[]); const selectedTemporalFields = Object.keys(selectedMarkObject) .map((k) => allFields.find((x) => x.fid === k)) .filter((x): x is IViewField => !!x && x.semanticType === 'temporal'); if (selectedTemporalFields.length > 0) { + const displayOffset = visualConfig.timezoneDisplayOffset ?? new Date().getTimezoneOffset(); selectedTemporalFields.forEach((f) => { + const offset = f.offset ?? new Date().getTimezoneOffset(); const set = new Set(viewData.map((x) => x[f.fid] as string | number)); - selectedMarkObject[f.fid] = [...set.values()].find((x) => new Date(x).getTime() === selectedMarkObject[f.fid]); + selectedMarkObject[f.fid] = Array.from(set).find((x) => { + const format = getTimeFormat(x); + let offsetTime = displayOffset * -60000; + if (format !== 'timestamp') { + offsetTime += offset * 60000; + if (!unexceptedUTCParsedPatternFormats.includes(format)) { + // the raw data will be parsed as local timezone, so reduce the offset with the local time zone. + offsetTime -= new Date().getTimezoneOffset() * 60000; + } + } + const time = new Date(x).getTime() + offsetTime; + return time === selectedMarkObject[f.fid]; + }); }); } if (e.item.mark.marktype === 'line') { // use the filter in mark group const keys = new Set(Object.keys(e.item.mark.group.datum ?? {})); - vizStore.updateSelectedMarkObject(Object.fromEntries(Object.entries(selectedMarkObject).filter(([k]) => keys.has(k)))); + vizStore.updateSelectedMarkObject(Object.fromEntries(Object.entries(selectedMarkObject).filter(([k]) => keys.has(k)))); } else { vizStore.updateSelectedMarkObject(selectedMarkObject); } diff --git a/packages/graphic-walker/src/renderer/pureRenderer.tsx b/packages/graphic-walker/src/renderer/pureRenderer.tsx index 007d45a3..484840e3 100644 --- a/packages/graphic-walker/src/renderer/pureRenderer.tsx +++ b/packages/graphic-walker/src/renderer/pureRenderer.tsx @@ -26,6 +26,9 @@ import { GWGlobalConfig } from '../vis/theme'; import { VizAppContext } from '../store/context'; import { useCurrentMediaTheme } from '../utils/media'; import LoadingLayer from '@/components/loadingLayer'; +import { transformMultiDatasetFields } from '@/utils/route'; +import { viewEncodingKeys } from '@/models/visSpec'; +import { emptyEncodings } from '@/utils/save'; type IPureRendererProps = { className?: string; @@ -49,6 +52,7 @@ type IPureRendererProps = { channelScales?: IChannelScales; scales?: IChannelScales; overrideSize?: IVisualLayout['size']; + disableCollapse?: boolean; }; type LocalProps = { @@ -87,6 +91,7 @@ const PureRenderer = forwardRef { if (props.type === 'remote') { @@ -162,6 +167,23 @@ const PureRenderer = forwardRef(null); + const encoding = useMemo(() => { + const viewsEncodings: Partial> = {}; + viewEncodingKeys(visualConfig.geoms[0]).forEach((k) => { + viewsEncodings[k] = visualState[k]; + }); + + const { filters, views } = transformMultiDatasetFields({ + filters: visualState.filters, + views: viewsEncodings, + }); + return { + ...emptyEncodings, + ...views, + filters, + }; + }, [visualState, visualConfig.geoms]); + return ( {waiting && } -
+
{isSpatial && (
@@ -182,15 +205,16 @@ const PureRenderer = forwardRef )} -
+
diff --git a/packages/graphic-walker/src/renderer/specRenderer.tsx b/packages/graphic-walker/src/renderer/specRenderer.tsx index e4eaaf36..164a8108 100644 --- a/packages/graphic-walker/src/renderer/specRenderer.tsx +++ b/packages/graphic-walker/src/renderer/specRenderer.tsx @@ -4,8 +4,7 @@ import React, { forwardRef, useMemo, useContext } from 'react'; import PivotTable from '../components/pivotTable'; import LeafletRenderer, { LEAFLET_DEFAULT_HEIGHT, LEAFLET_DEFAULT_WIDTH } from '../components/leafletRenderer'; import ReactVega, { IReactVegaHandler } from '../vis/react-vega'; -import { DraggableFieldState, IDarkMode, IRow, IThemeKey, IVisualConfigNew, IVisualLayout, VegaGlobalConfig, IChannelScales } from '../interfaces'; -import LoadingLayer from '../components/loadingLayer'; +import { DraggableFieldState, IRow, IThemeKey, IVisualConfigNew, IVisualLayout, VegaGlobalConfig, IChannelScales } from '../interfaces'; import { getTheme } from '../utils/useTheme'; import { GWGlobalConfig } from '../vis/theme'; import { uiThemeContext, themeContext } from '@/store/theme'; @@ -23,25 +22,14 @@ interface SpecRendererProps { locale?: string; scales?: IChannelScales; onReportSpec?: (spec: string) => void; + disableCollapse?: boolean; } /** * Sans-store renderer of GraphicWalker. * This is a pure component, which means it will not depend on any global state. */ const SpecRenderer = forwardRef(function ( - { - name, - layout, - data, - draggableFieldState, - visualConfig, - onGeomClick, - onChartResize, - locale, - onReportSpec, - vizThemeConfig, - scales, - }, + { name, layout, data, draggableFieldState, visualConfig, onGeomClick, onChartResize, locale, onReportSpec, vizThemeConfig, scales, disableCollapse }, ref ) { // const { draggableFieldState, visualConfig } = vizStore; @@ -121,7 +109,16 @@ const SpecRenderer = forwardRef(function ( }, [themeConfig, mediaTheme, zeroScale, resolve, background, format.normalizedNumberFormat, format.numberFormat, format.timeFormat]); if (isPivotTable) { - return ; + return ( + + ); } const isSpatial = coordSystem === 'geographic'; diff --git a/packages/graphic-walker/src/root.tsx b/packages/graphic-walker/src/root.tsx index af145e7f..fb7a08df 100644 --- a/packages/graphic-walker/src/root.tsx +++ b/packages/graphic-walker/src/root.tsx @@ -96,7 +96,12 @@ export const TableWalker = observer( return ( }> - + diff --git a/packages/graphic-walker/src/services.ts b/packages/graphic-walker/src/services.ts index b7085d9c..02aa1516 100644 --- a/packages/graphic-walker/src/services.ts +++ b/packages/graphic-walker/src/services.ts @@ -1,4 +1,4 @@ -import { IRow, IMutField, Specification, IFilterFiledSimple, IExpression, IViewQuery, IViewField } from './interfaces'; +import { IRow, IMutField, Specification, IFilterFiledSimple, IExpression, IViewQuery, IViewField, IDatasetForeign } from './interfaces'; import { INestNode } from './components/pivotTable/inteface'; /* eslint import/no-webpack-loader-syntax:0 */ // @ts-ignore @@ -13,6 +13,7 @@ import TransformDataWorker from './workers/transform.worker?worker&inline'; import ViewQueryWorker from './workers/viewQuery.worker?worker&inline'; import BuildMetricTableWorker from './workers/buildMetricTable.worker?worker&inline'; import SortWorker from './workers/sort.worker?worker&inline'; +import JoinWorker from './workers/join.worker?worker&inline'; function workerService(worker: Worker, data: R): Promise { return new Promise((resolve, reject) => { @@ -119,6 +120,21 @@ export const applyFilter = async (data: IRow[], filters: readonly IFilterFiledSi } }; +export const joinDataService = async (rawDatasets: Record, foreigns: IDatasetForeign[]): Promise => { + const worker = new JoinWorker(); + try { + const res: IRow[] = await workerService(worker, { + rawDatasets, + foreigns, + }); + return res; + } catch (error: any) { + throw new Error(error.message); + } finally { + worker.terminate(); + } +}; + export const transformDataService = async (data: IRow[], trans: { key: string; expression: IExpression }[]): Promise => { if (data.length === 0) return data; const worker = new TransformDataWorker(); diff --git a/packages/graphic-walker/src/store/commonStore.ts b/packages/graphic-walker/src/store/commonStore.ts index 9753a3cd..6580b9ed 100644 --- a/packages/graphic-walker/src/store/commonStore.ts +++ b/packages/graphic-walker/src/store/commonStore.ts @@ -57,10 +57,11 @@ export class CommonStore { this.tmpDSName = name; } - public updateTempDS(rawData: IRow[]) { + public updateTempDS(rawData: IRow[], name = 'New Dataset') { const result = transData(rawData); this.tmpDataSource = result.dataSource; this.tmpDSRawFields = result.fields; + this.tmpDSName = name; } /** * update temp dataset (standard) with dataset info diff --git a/packages/graphic-walker/src/store/context.ts b/packages/graphic-walker/src/store/context.ts index 57ef65ef..444b9ab0 100644 --- a/packages/graphic-walker/src/store/context.ts +++ b/packages/graphic-walker/src/store/context.ts @@ -1,5 +1,5 @@ -import { ComputationContext } from '.'; +import { ComputationContext, DatasetNamesContext } from '.'; import { composeContext } from '../utils/context'; import { portalContainerContext, themeContext, vegaThemeContext } from './theme'; -export const VizAppContext = composeContext({ ComputationContext, themeContext, vegaThemeContext, portalContainerContext }); +export const VizAppContext = composeContext({ ComputationContext, themeContext, vegaThemeContext, portalContainerContext, DatasetNamesContext }); diff --git a/packages/graphic-walker/src/store/dataStore.ts b/packages/graphic-walker/src/store/dataStore.ts index 31eed5c3..80bf97e8 100644 --- a/packages/graphic-walker/src/store/dataStore.ts +++ b/packages/graphic-walker/src/store/dataStore.ts @@ -48,7 +48,7 @@ export class DataStore { dataSources.push({ data: ds.data, id: dsId, - metaId: id, + metaId: dsId, name, }); }); @@ -79,6 +79,10 @@ export class DataStore { }; } + createVis(key: string, fields: IMutField[]) { + this.visDict[key] = [exportFullRaw(fromFields(fields, 'Chart 1'))]; + } + addDataSource(data: { data: IRow[]; fields: IMutField[]; name: string }) { const metaKey = encodeMeta(data.fields); if (!this.metaMap[metaKey]) { diff --git a/packages/graphic-walker/src/store/index.tsx b/packages/graphic-walker/src/store/index.tsx index aa9d034c..48236ed2 100644 --- a/packages/graphic-walker/src/store/index.tsx +++ b/packages/graphic-walker/src/store/index.tsx @@ -1,6 +1,6 @@ import React, { useContext, useMemo, useEffect, createContext, useRef } from 'react'; import { VizSpecStore } from './visualSpecStore'; -import { IComputationFunction, IDefaultConfig, IMutField, IRow } from '../interfaces'; +import { FieldIdentifier, IComputationFunction, IDefaultConfig, IMutField, IRow } from '../interfaces'; function createKeepAliveContext(create: (...args: U) => T) { const dict: Record = {}; @@ -19,7 +19,7 @@ const getVizStore = createKeepAliveContext( meta: IMutField[], opts?: { empty?: boolean; - onMetaChange?: (fid: string, diffMeta: Partial) => void; + onMetaChange?: (fid: FieldIdentifier, diffMeta: Partial) => void; defaultConfig?: IDefaultConfig; } ) => new VizSpecStore(meta, opts) @@ -34,7 +34,7 @@ interface VizStoreWrapperProps { storeRef?: React.MutableRefObject; children?: React.ReactNode; meta: IMutField[]; - onMetaChange?: (fid: string, meta: Partial) => void; + onMetaChange?: (fid: FieldIdentifier, meta: Partial) => void; defaultConfig?: IDefaultConfig; } @@ -82,6 +82,7 @@ export function useVizStore() { } export const ComputationContext = createContext(async () => []); +export const DatasetNamesContext = createContext | undefined>(undefined); export function useCompututaion() { return useContext(ComputationContext); diff --git a/packages/graphic-walker/src/store/visualSpecStore.ts b/packages/graphic-walker/src/store/visualSpecStore.ts index 8987f9bc..4378b41a 100644 --- a/packages/graphic-walker/src/store/visualSpecStore.ts +++ b/packages/graphic-walker/src/store/visualSpecStore.ts @@ -1,4 +1,4 @@ -import { computed, makeAutoObservable, observable } from 'mobx'; +import { computed, makeAutoObservable, observable, toJS } from 'mobx'; import { VisSpecWithHistory, convertChart, @@ -39,19 +39,21 @@ import { IPaintMap, IPaintMapV2, IDefaultConfig, + IJoinPath, FieldIdentifier, } from '../interfaces'; import { GLOBAL_CONFIG } from '../config'; -import { COUNT_FIELD_ID, DATE_TIME_DRILL_LEVELS, DATE_TIME_FEATURE_LEVELS, PAINT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from '../constants'; +import { COUNT_FIELD_ID, DATE_TIME_DRILL_LEVELS, DATE_TIME_FEATURE_LEVELS, PAINT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID, DEFAULT_DATASET } from '../constants'; import { toWorkflow } from '../utils/workflow'; import { KVTuple, uniqueId } from '../models/utils'; import { INestNode } from '../components/pivotTable/inteface'; -import { getSort, getSortedEncoding } from '../utils'; +import { deduper, getFieldIdentifier, getSort, getSortedEncoding, isSameField } from '../utils'; import { getSQLItemAnalyticType, parseSQLExpr } from '../lib/sql'; import { IPaintMapAdapter } from '../lib/paint'; import { toChatMessage } from '@/models/chat'; import { viewEncodingKeys } from '@/models/visSpec'; +import { encodePath, getMap, getReachedDatasets, getRoute, transformMultiDatasetFields } from '@/utils/route'; const encodingKeys = (Object.keys(emptyEncodings) as (keyof DraggableFieldState)[]).filter((dkey) => !GLOBAL_CONFIG.META_FIELD_KEYS.includes(dkey)); export class VizSpecStore { @@ -69,7 +71,6 @@ export class VizSpecStore { showVisualConfigPanel: boolean = false; showGeoJSONConfigPanel: boolean = false; removeConfirmIdx: number | null = null; - filters: Filters = {}; tableCollapsedHeaderMap: Map = new Map(); selectedMarkObject: Record = {}; showLogSettingPanel: boolean = false; @@ -82,16 +83,17 @@ export class VizSpecStore { lastErrorMessage: string = ''; showAskvizFeedbackIndex: number | undefined = 0; lastSpec: string = ''; - editingComputedFieldFid: string | undefined = undefined; + editingComputedFieldFid: FieldIdentifier | undefined = undefined; defaultConfig: IDefaultConfig | undefined; + linkingDataset: string | undefined = undefined; - onMetaChange?: (fid: string, diffMeta: Partial) => void; + onMetaChange?: (fid: FieldIdentifier, diffMeta: Partial) => void; constructor( meta: IMutField[], options?: { empty?: boolean; - onMetaChange?: (fid: string, diffMeta: Partial) => void; + onMetaChange?: (fid: FieldIdentifier, diffMeta: Partial) => void; defaultConfig?: IDefaultConfig; } ) { @@ -103,7 +105,6 @@ export class VizSpecStore { makeAutoObservable(this, { visList: observable.shallow, allEncodings: computed.struct, - filters: observable.ref, tableCollapsedHeaderMap: observable.ref, }); } @@ -179,8 +180,37 @@ export class VizSpecStore { return result; } + get datasets() { + return Array.from(new Set(this.meta.map((x) => x.dataset ?? DEFAULT_DATASET))); + } + + get baseDataset() { + return this.layout.baseDataset ?? this.datasets[0]; + } + + get logicBaseDataset() { + return this.config.baseDataset ?? this.datasets[0]; + } + + get isMultiDataset() { + return this.datasets.length > 1; + } + + get routeMap() { + return getMap(this.meta); + } + + get basePath() { + return getRoute(this.baseDataset, this.logicBaseDataset, this.routeMap); + } + + get unReachedDatasets() { + const reached = getReachedDatasets(this.baseDataset, this.routeMap); + return this.datasets.filter((x) => !reached.has(x)); + } + get viewEncodings() { - const result: Record = {}; + const result: Partial> = {}; viewEncodingKeys(this.config.geoms[0]).forEach((k) => { result[k] = this.currentEncodings[k]; }); @@ -230,6 +260,21 @@ export class VizSpecStore { return toChatMessage(this.visList[this.visIndex]); } + get datasetJoinPaths() { + return Object.fromEntries( + this.datasets.map( + (dataset) => + [ + dataset, + deduper( + this.viewEncodingFields.filter((x) => x.dataset === dataset).map((x) => x.joinPath ?? []), + encodePath + ), + ] as const + ) + ); + } + get paintFields() { if (!this.currentVis.config.defaultAggregated) { const { columns, rows } = this.currentEncodings; @@ -321,15 +366,71 @@ export class VizSpecStore { return this.paintFields; } - private appendFilter(index: number, sourceKey: keyof Omit, sourceIndex: number) { + get multiViewInfo() { + return transformMultiDatasetFields({ + filters: toJS(this.viewFilters), + views: toJS(this.viewEncodings), + }); + } + + get foldOptions() { + const validFoldBy = this.allFields.filter((f) => f.analyticType === 'measure' && f.fid !== MEA_VAL_ID); + return validFoldBy.map((f) => ({ + key: getFieldIdentifier(f), + label: f.name, + })); + } + + private appendFilter(index: number, sourceKey: keyof Omit, sourceIndex: number, joinPath: IJoinPath[] | null) { const oriF = this.currentEncodings[sourceKey][sourceIndex]; if (oriF.fid === MEA_KEY_ID || oriF.fid === MEA_VAL_ID || oriF.fid === COUNT_FIELD_ID || oriF.aggName === 'expr') { return; } - this.visList[this.visIndex] = performers.appendFilter(this.visList[this.visIndex], index, sourceKey, sourceIndex, null); + this.visList[this.visIndex] = performers.appendFilter(this.visList[this.visIndex], index, sourceKey, sourceIndex, null, joinPath); this.editingFilterIdx = index; } + setViewBaseDataset(dataset: string) { + if (this.unReachedDatasets.includes(dataset)) { + const reached = getReachedDatasets(dataset, this.routeMap); + const unreached = this.datasets.filter((x) => !reached.has(x)); + this.visList[this.visIndex] = performers.resetBaseDataset(this.visList[this.visIndex], dataset, unreached); + } + this.visList[this.visIndex] = performers.setLayout(this.visList[this.visIndex], [['baseDataset', dataset]]); + } + + setLinkingDataset(dataset?: string) { + this.linkingDataset = dataset; + } + + encodeJoinPath(path: IJoinPath[]) { + const base = this.datasets.length; + let result = 1; + path.forEach((x) => { + const i = this.datasets.findIndex((y) => y === x.from); + const j = (this.routeMap[x.from] ?? []).findIndex((y) => y.to === x.to && y.tid === x.tid && y.fid === x.fid); + if (i > -1 && j > -1) { + result = (result * this.routeMap[x.from].length + j) * base + i; + } + }); + return result.toString(36); + } + + decodeJoinPath(id: string) { + const base = this.datasets.length; + let num = parseInt(id, 36); + const result: IJoinPath[] = []; + while (num > 1) { + const i = num % base; + num = (num - i) / base; + const arr = this.routeMap[this.datasets[i]]; + const j = num % arr.length; + num = (num - j) / arr.length; + result.unshift(arr[j]); + } + return result; + } + undo() { this.visList[this.visIndex] = undo(this.visList[this.visIndex]); } @@ -346,7 +447,7 @@ export class VizSpecStore { this.meta = meta; } - setOnMetaChange(onMetaChange?: (fid: string, diffMeta: Partial) => void) { + setOnMetaChange(onMetaChange?: (fid: FieldIdentifier, diffMeta: Partial) => void) { this.onMetaChange = onMetaChange; } @@ -415,16 +516,24 @@ export class VizSpecStore { } reorderField(stateKey: keyof DraggableFieldState, sourceIndex: number, destinationIndex: number) { + if (this.isMultiDataset) return; if (GLOBAL_CONFIG.META_FIELD_KEYS.includes(stateKey)) return; if (sourceIndex === destinationIndex) return; this.visList[this.visIndex] = performers.reorderField(this.visList[this.visIndex], stateKey, sourceIndex, destinationIndex); } - moveField(sourceKey: keyof DraggableFieldState, sourceIndex: number, destinationKey: keyof DraggableFieldState, destinationIndex: number) { + moveField( + sourceKey: keyof DraggableFieldState, + sourceIndex: number, + destinationKey: keyof DraggableFieldState, + destinationIndex: number, + joinPath: IJoinPath[] | null, + isFromDrag?: boolean + ) { if (sourceKey === 'filters') { return this.removeField(sourceKey, sourceIndex); } else if (destinationKey === 'filters') { - return this.appendFilter(destinationIndex, sourceKey, sourceIndex); + return this.appendFilter(destinationIndex, sourceKey, sourceIndex, joinPath); } const oriF = this.currentEncodings[sourceKey][sourceIndex]; const sourceMeta = GLOBAL_CONFIG.META_FIELD_KEYS.includes(sourceKey); @@ -434,6 +543,7 @@ export class VizSpecStore { } const limit = GLOBAL_CONFIG.CHANNEL_LIMIT[destinationKey] ?? Infinity; if (destMeta === sourceMeta) { + if (this.isMultiDataset && destMeta && isFromDrag) return; this.visList[this.visIndex] = performers.moveField(this.visList[this.visIndex], sourceKey, sourceIndex, destinationKey, destinationIndex, limit); } else if (destMeta) { this.visList[this.visIndex] = performers.removeField(this.visList[this.visIndex], sourceKey, sourceIndex); @@ -458,7 +568,8 @@ export class VizSpecStore { destinationKey, destinationIndex, uniqueId(), - limit + limit, + joinPath ); } } @@ -507,7 +618,7 @@ export class VizSpecStore { if (!origianlField) { return; } - this.visList[this.visIndex] = performers.editAllField(this.visList[this.visIndex], origianlField.fid, { name: newName }); + this.visList[this.visIndex] = performers.editAllField(this.visList[this.visIndex], origianlField.fid, { name: newName }, getFieldIdentifier(origianlField)); } public createDateTimeDrilledField( @@ -640,12 +751,9 @@ export class VizSpecStore { closeEmbededMenu() { this.vizEmbededMenu.show = false; } - setFilters(props: Filters) { - this.filters = props; - } - updateCurrentDatasetMetas(fid: string, diffMeta: Partial) { - const field = this.meta.find((f) => f.fid === fid); + updateCurrentDatasetMetas(fid: FieldIdentifier, diffMeta: Partial) { + const field = this.meta.find((f) => getFieldIdentifier(f) === fid); if (field) { for (let mk in diffMeta) { field[mk] = diffMeta[mk]; @@ -753,41 +861,77 @@ export class VizSpecStore { this.lastSpec = spec; } - setComputedFieldFid(fid?: string) { + setComputedFieldFid(fid?: FieldIdentifier) { this.editingComputedFieldFid = fid; } - upsertComputedField(fid: string, name: string, sql: string) { + upsertComputedField(fid: FieldIdentifier, name: string, sql: string, dataset: string | null) { if (fid === '') { - this.visList[this.visIndex] = performers.addSQLComputedField(this.visList[this.visIndex], uniqueId(), name, sql); + this.visList[this.visIndex] = performers.addSQLComputedField(this.visList[this.visIndex], uniqueId(), name, sql, dataset); } else { - const originalField = this.allFields.find((x) => x.fid === fid); + const originalField = this.allFields.find((x) => getFieldIdentifier(x) === fid); if (!originalField) return; const [semanticType, isAgg] = getSQLItemAnalyticType(parseSQLExpr(sql), this.allFields); const analyticType = semanticType === 'quantitative' ? 'measure' : 'dimension'; const newAggName = isAgg ? 'expr' : analyticType === 'dimension' ? undefined : 'sum'; const preAggName = originalField.aggName === 'expr' ? 'expr' : originalField.aggName === undefined ? undefined : 'sum'; - this.visList[this.visIndex] = performers.editAllField(this.visList[this.visIndex], fid, { - name, - analyticType, - semanticType, - ...(preAggName !== newAggName ? { aggName: newAggName } : {}), - expression: { as: fid, op: 'expr', params: [{ type: 'sql', value: sql }] }, - }); + this.visList[this.visIndex] = performers.editAllField( + this.visList[this.visIndex], + originalField.fid, + { + name, + analyticType, + semanticType, + ...(preAggName !== newAggName ? { aggName: newAggName } : {}), + expression: { as: originalField.fid, op: 'expr', params: [{ type: 'sql', value: sql }] }, + }, + fid + ); } } removeComputedField(sourceKey: keyof DraggableFieldState, sourceIndex: number) { const oriF = this.currentEncodings[sourceKey][sourceIndex]; if (oriF.computed) { - this.visList[this.visIndex] = performers.removeAllField(this.visList[this.visIndex], oriF.fid); + this.visList[this.visIndex] = performers.removeAllField(this.visList[this.visIndex], oriF.fid, getFieldIdentifier(oriF)); } } + linkDataset(fid: FieldIdentifier, targetDataset: string, targetField: string) { + const oriF = this.allFields.find((x) => getFieldIdentifier(x) === fid); + if (!oriF) return; + const source = oriF.analyticType === 'dimension' ? 'dimensions' : 'measures'; + const index = this.currentEncodings[source].findIndex(isSameField(oriF)); + this.visList[this.visIndex] = performers.linkDataset(this.visList[this.visIndex], source, index, targetDataset, targetField); + } + replaceWithNLPQuery(query: string, response: string) { this.visList[this.visIndex] = performers.replaceWithNLPQuery(this.visList[this.visIndex], query, response); } + + renderJoinPath(path: IJoinPath[], datasetNames?: Record) { + return path + .map(({ fid, from, tid, to }) => [ + this.allFields.find(isSameField({ fid, dataset: from })), + this.allFields.find(isSameField({ fid: tid, dataset: to })), + ]) + .map((x) => + x + .filter(Boolean) + .map((f) => { + const { dataset = DEFAULT_DATASET, name } = f!; + const datasetName = datasetNames?.[dataset] ?? dataset; + return `${datasetName}.${name}`; + }) + .join('->') + ) + .join('\n'); + } + + editFieldName(sourceKey: keyof Omit, sourceIndex: number, newName: string) { + this.visList[this.visIndex] = performers.renameField(this.visList[this.visIndex], sourceKey, sourceIndex, newName); + } } export function renderSpec(spec: Specification, meta: IMutField[], name: string, visId: string) { diff --git a/packages/graphic-walker/src/utils/index.ts b/packages/graphic-walker/src/utils/index.ts index 9ddd6d52..7752921b 100644 --- a/packages/graphic-walker/src/utils/index.ts +++ b/packages/graphic-walker/src/utils/index.ts @@ -1,6 +1,7 @@ import i18next from 'i18next'; +import "../locales/i18n"; import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from '../constants'; -import { IRow, Filters, IViewField, IFilterField, IKeyWord, IField, FieldIdentifier } from '../interfaces'; +import { IRow, Filters, IViewField, IFilterField, IKeyWord, FieldIdentifier, IField } from '../interfaces'; import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -265,8 +266,8 @@ export function createVirtualFields(): IViewField[] { ]; } -export function getFieldIdentifier(field: IField): FieldIdentifier { - return field.fid as FieldIdentifier; +export function getFieldIdentifier(field: { fid: string; dataset?: string; originalFid?: string }): FieldIdentifier { + return JSON.stringify([field.originalFid ?? field.fid, field.dataset]) as FieldIdentifier; } export function getRange(nums: number[]): [number, number] { @@ -428,3 +429,15 @@ export function arrayEqual(list1: any[], list2: any[]): boolean { } return true; } + +export const deduper = (items: T[], keyF: (k: T) => string) => { + const map = new Map(); + items.forEach((x) => map.set(keyF(x), x)); + return [...map.values()]; +}; + +export const isSameField = (x?: { fid: string; dataset?: string }) => { + if (!x) return () => false; + const myid = getFieldIdentifier(x); + return (y: { fid: string; dataset?: string }) => getFieldIdentifier(y) === myid; +}; diff --git a/packages/graphic-walker/src/utils/route.ts b/packages/graphic-walker/src/utils/route.ts new file mode 100644 index 00000000..2c44c247 --- /dev/null +++ b/packages/graphic-walker/src/utils/route.ts @@ -0,0 +1,318 @@ +import { MEA_KEY_ID, MEA_VAL_ID, COUNT_FIELD_ID, PAINT_FIELD_ID, DEFAULT_DATASET } from '@/constants'; +import { IDatasetForeign, IExpression, IFilterField, IJoinPath, IMutField, IPaintMapV2, IViewField } from '@/interfaces'; +import { reverse } from 'lodash-es'; +import { produce } from 'immer'; + +export function mergePaths(paths: IJoinPath[]) { + const result: IJoinPath[] = []; + if (paths.length === 0) return []; + paths.forEach((route) => { + if (result.length) { + const lastRoute = result[result.length - 1]; + if (route.from === lastRoute.to && route.fid === lastRoute.tid && route.to === lastRoute.from && route.tid === lastRoute.fid) { + result.pop(); + return; + } + } + result.push(route); + }); + return result; +} + +export function reversePath({ fid, from, tid, to }: IJoinPath) { + return { fid: tid, from: to, tid: fid, to: from }; +} + +export function reversePaths(paths: IJoinPath[]) { + return reverse(paths.slice()).map(reversePath); +} + +export function getMap(fields: IMutField[]) { + const map: Record = {}; + fields + .filter((f) => f.foreign && f.dataset) + .forEach((f) => { + if (!map[f.dataset!]) { + map[f.dataset!] = []; + } + map[f.dataset!].push({ + from: f.dataset!, + fid: f.fid, + to: f.foreign!.dataset, + tid: f.foreign!.fid, + }); + if (!map[f.foreign!.dataset!]) { + map[f.foreign!.dataset!] = []; + } + map[f.foreign!.dataset].push({ + to: f.dataset!, + tid: f.fid, + from: f.foreign!.dataset, + fid: f.foreign!.fid, + }); + }); + return map; +} + +export function getRoute(from: string, to: string, map: Record): IJoinPath[] | null { + const current = [{ pos: from, path: [] as IJoinPath[] }]; + const visited = new Set([from]); + while (current.length) { + const { pos: now, path } = current.shift()!; + if (now === to) { + return path; + } + map[now].forEach((v) => { + if (!visited.has(v.to)) { + current.push({ pos: v.to, path: path.concat([v]) }); + visited.add(v.to); + } + }); + } + return null; +} + +export function getReachedDatasets(from: string, map: Record) { + const current = [from]; + const visited = new Set([from]); + while (current.length) { + const now = current.shift()!; + (map[now] ?? []).forEach((v) => { + if (!visited.has(v.to)) { + current.push(v.to); + visited.add(v.to); + } + }); + } + return visited; +} + +const cyrb53 = (str, seed = 0) => { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +}; + +export function encodePath(path: IJoinPath[]) { + return cyrb53(path.map((p) => `${p.from}_${p.fid}_${p.to}_${p.tid}`).join('_')).toString(36); +} + +export const BASE_DATASET_AS = encodePath([]); + +const reversedFids = new Set([MEA_KEY_ID, MEA_VAL_ID, COUNT_FIELD_ID, PAINT_FIELD_ID]); + +export interface IFieldTransformer { + get(): { fid: string; dataset?: string; joinPath?: IJoinPath[] }; + update(fid: string): void; +} + +export function buildMultiDatasetQuery(transformers: IFieldTransformer[]): { + foreignKeys: IDatasetForeign[] | null; + datasets: string[]; + processFid: (path?: IJoinPath[]) => (x: string) => string; +} { + if (transformers.length === 0) { + return { + foreignKeys: null, + datasets: [], + processFid: () => (x) => x, + }; + } + const endPointDatasets = new Set( + transformers.map((f) => { + const { dataset = DEFAULT_DATASET, joinPath = [] } = f.get(); + return JSON.stringify([dataset, joinPath]); + }) + ); + const [baseDataset, path] = JSON.parse(Array.from(endPointDatasets).sort()[0]); + const basePath = reversePaths(path); + const getActualPath = (path: IJoinPath[]) => mergePaths(path.concat(basePath)); + const joinedDatasets = new Set([BASE_DATASET_AS]); + const usedDatasets = new Set([baseDataset]); + const foreignKeys: IDatasetForeign[] = []; + const updater: (() => void)[] = []; + transformers.forEach((field) => { + const { fid, joinPath = [] } = field.get(); + if (reversedFids.has(fid)) { + return; + } + const actualPath = getActualPath(joinPath); + actualPath.forEach((_, i, arr) => { + const path = arr.slice(i); + const [node, ...restPath] = path; + const key = encodePath(path); + if (!joinedDatasets.has(key)) { + joinedDatasets.add(key); + usedDatasets.add(node.from); + usedDatasets.add(node.to); + foreignKeys.push({ + type: 'inner', + keys: [ + { dataset: node.from, field: node.fid, as: key }, + { dataset: node.to, field: node.tid, as: encodePath(restPath) }, + ], + }); + } + }); + updater.push(() => field.update(`${encodePath(actualPath)}.${fid}`)); + }); + if (usedDatasets.size > 1) { + updater.forEach((f) => f()); + return { + foreignKeys: sortForeignKeys(foreignKeys), + datasets: Array.from(usedDatasets), + processFid: (path?: IJoinPath[]) => { + const prefix = encodePath(getActualPath(path ?? [])); + return (x: string) => `${prefix}.${x}`; + }, + }; + } + return { + foreignKeys: null, + datasets: Array.from(usedDatasets), + processFid: () => (x: string) => x, + }; +} + +function sortForeignKeys(keys: IDatasetForeign[]): IDatasetForeign[] { + if (keys.length === 0) return keys; + let now = keys[0].keys[0].as; + const reached = new Set([now]); + const queue = keys.slice(); + const result: IDatasetForeign[] = []; + let loopedItems: IDatasetForeign | null = null; + while (queue.length) { + const item = queue.shift()!; + const linkedItem = item.keys.find((x) => reached.has(x.as)); + if (linkedItem) { + item.keys.forEach((x) => reached.add(x.as)); + result.push({ type: item.type, keys: [linkedItem, ...item.keys.filter((x) => x !== linkedItem)] }); + } else { + if (loopedItems === item) { + console.error('meet looped item', keys, item); + break; + } + queue.push(item); + loopedItems = item; + } + } + return result; +} + +/** + * return transformers that mutables the computed of the field. + */ +function createTransformerForComputed( + field: T +): IFieldTransformer[] { + const { dataset, expression, joinPath } = field; + if (!expression) { + return []; + } + const result: IFieldTransformer[] = [ + { + get() { + return { fid: expression.as, dataset, joinPath }; + }, + update(fid) { + expression.as = fid; + }, + }, + ]; + if (expression.op === 'expr') { + // delayed process in processExpression + } else if (expression.op === 'paint') { + expression.params.forEach((p) => { + if (p.type === 'newmap') { + p.value.facets.forEach((f) => + f.dimensions.forEach((d) => { + result.push({ + get() { + return d; + }, + update(fid) { + d.fid = fid; + }, + }); + }) + ); + } + }); + } else { + expression.params.forEach((p) => { + if (p.type === 'field') { + result.push({ + get() { + return { fid: p.value, dataset, joinPath }; + }, + update(fid) { + p.value = fid; + }, + }); + } + }); + } + return result; +} + +export function transformMultiDatasetFields>( + fieldsSet: Readonly<{ views: V; filters: IFilterField[] }> +) { + return produce( + fieldsSet as typeof fieldsSet & { + foreignKeys: IDatasetForeign[] | null; + datasets: string[]; + processFid: (path?: IJoinPath[]) => (x: string) => string; + }, + (fields) => { + const transformers: IFieldTransformer[] = []; + Object.keys(fields.views).forEach((k) => { + const item = fields.views[k]; + if (!item) return; + (item instanceof Array ? item : [item]).forEach((f) => { + if (f.computed && f.expression) { + transformers.push(...createTransformerForComputed(f)); + } + transformers.push({ + get() { + return f; + }, + update(fid) { + f.originalFid = f.fid; + f.fid = fid; + }, + }); + }); + }); + fields.filters.forEach((f) => { + if (f.computed && f.expression) { + transformers.push(...createTransformerForComputed(f)); + } + transformers.push({ + get() { + return f; + }, + update(fid) { + f.originalFid = f.fid; + f.fid = fid; + }, + }); + }); + + const { datasets, foreignKeys, processFid } = buildMultiDatasetQuery(transformers); + fields.datasets = datasets; + fields.foreignKeys = foreignKeys; + fields.processFid = processFid; + } + ); +} diff --git a/packages/graphic-walker/src/utils/workflow.ts b/packages/graphic-walker/src/utils/workflow.ts index 9fba5e04..5922a678 100644 --- a/packages/graphic-walker/src/utils/workflow.ts +++ b/packages/graphic-walker/src/utils/workflow.ts @@ -14,56 +14,68 @@ import type { IMutField, IPaintMapV2, IVisSpec, + IJoinWorkflowStep, + FieldIdentifier, + IJoinPath, } from '../interfaces'; import type { VizSpecStore } from '../store/visualSpecStore'; -import { getFilterMeaAggKey, getMeaAggKey, getSort } from '.'; -import { MEA_KEY_ID, MEA_VAL_ID } from '../constants'; +import { deduper, getFieldIdentifier, getFilterMeaAggKey, getMeaAggKey, getSort, isSameField } from '.'; +import { DEFAULT_DATASET, MEA_KEY_ID, MEA_VAL_ID } from '../constants'; import { parseChart } from '../models/visSpecHistory'; import { replaceFid, walkFid } from '../lib/sql'; import { replaceAggForFold } from '../lib/op/fold'; import { viewEncodingKeys } from '@/models/visSpec'; +import { encodePath, transformMultiDatasetFields } from './route'; -const walkExpression = (expression: IExpression, each: (field: string) => void): void => { - for (const param of expression.params) { +const walkExpression = ( + field: { + dataset?: string; + joinPath?: IJoinPath[]; + expression: IExpression; + }, + each: (field: { fid: string; dataset?: string; joinPath?: IJoinPath[] }) => void +): void => { + for (const param of field.expression.params) { if (param.type === 'field') { - each(param.value); + each({ fid: param.value, dataset: field.dataset, joinPath: field.joinPath }); } else if (param.type === 'expression') { - walkExpression(param.value, each); + walkExpression({ dataset: field.dataset, joinPath: field.joinPath, expression: param.value }, each); } else if (param.type === 'sql') { - walkFid(param.value).forEach(each); + walkFid(param.value).forEach((fid) => each({ fid, dataset: field.dataset, joinPath: field.joinPath })); } else if (param.type === 'map') { - each(param.value.x); - each(param.value.y); + each({ fid: param.value.x, dataset: field.dataset, joinPath: field.joinPath }); + each({ fid: param.value.y, dataset: field.dataset, joinPath: field.joinPath }); } else if (param.type === 'newmap') { - param.value.facets.flatMap((x) => x.dimensions).forEach((x) => each(x.fid)); + param.value.facets.flatMap((x) => x.dimensions).forEach((x) => each({ fid: x.fid, dataset: x.dataset ?? DEFAULT_DATASET, joinPath: x.joinPath })); } } }; -const deduper = (items: T[], keyF: (k: T) => string) => { - const map = new Map(); - items.forEach((x) => map.set(keyF(x), x)); - return [...map.values()]; -}; - -const treeShake = ( - computedFields: readonly { key: string; expression: IExpression }[], - viewKeys: readonly string[] -): { key: string; expression: IExpression }[] => { - const usedFields = new Set(viewKeys); - let result = computedFields.filter((f) => usedFields.has(f.key)); +const treeShake = ( + computedFields: T[], + viewKeys: { fid: string; dataset?: string; joinPath?: IJoinPath[] }[] +): (T & { joinPath?: IJoinPath[] })[] => { + const keyF = (item: { fid: string; dataset?: string; joinPath?: IJoinPath[] }) => + JSON.stringify([getFieldIdentifier(item), encodePath(item.joinPath ?? [])]); + let result = viewKeys.flatMap((i) => computedFields.filter(isSameField(i)).map((x) => ({ ...x, joinPath: i.joinPath }))); let currentFields = result.slice(); - let rest = computedFields.filter((f) => !usedFields.has(f.key)); - while (currentFields.length && rest.length) { - const dependencies = new Set(); + const reachedFields = new Set(result.map(keyF)); + while (currentFields.length) { + const dependencies = new Map< + string, + { + fid: string; + dataset?: string; + joinPath?: IJoinPath[]; + } + >(); for (const f of currentFields) { - walkExpression(f.expression, (field) => dependencies.add(field)); + walkExpression(f, (field) => dependencies.set(keyF(field), field)); } - const nextFields = rest.filter((f) => dependencies.has(f.key)); - const deps = computedFields.filter((f) => dependencies.has(f.key)); - result = deps.concat(result.filter((f) => !dependencies.has(f.key))); - currentFields = nextFields; - rest = rest.filter((f) => !dependencies.has(f.key)); + const deps = Array.from(dependencies.values()).flatMap((d) => computedFields.filter(isSameField(d)).map((x) => ({ ...x, joinPath: d.joinPath }))); + result = deps.concat(result.filter((f) => !dependencies.has(keyF(f)))); + currentFields = deps.filter((x) => !reachedFields.has(keyF(x))); + deps.forEach((x) => reachedFields.add(keyF(x))); } return result; }; @@ -125,7 +137,7 @@ export const createFilter = (f: IFilterField): IVisFilter => { }; export const toWorkflow = ( - viewFilters: VizSpecStore['viewFilters'], + viewFiltersRaw: VizSpecStore['viewFilters'], allFields: IViewField[], viewDimensionsRaw: IViewField[], viewMeasuresRaw: IViewField[], @@ -134,20 +146,42 @@ export const toWorkflow = ( folds = [] as string[], limit?: number, timezoneDisplayOffset?: number -): IDataQueryWorkflowStep[] => { +): { + workflow: IDataQueryWorkflowStep[]; + datasets: string[]; +} => { + const viewDimensionsGuarded = viewDimensionsRaw.filter((x) => x.fid !== MEA_KEY_ID); + const viewMeasuresGuarded = viewMeasuresRaw.filter((x) => x.fid !== MEA_VAL_ID); const hasFold = viewDimensionsRaw.find((x) => x.fid === MEA_KEY_ID) && viewMeasuresRaw.find((x) => x.fid === MEA_VAL_ID); - const viewDimensions = viewDimensionsRaw.filter((x) => x.fid !== MEA_KEY_ID); - const viewMeasures = viewMeasuresRaw.filter((x) => x.fid !== MEA_VAL_ID); if (hasFold) { const aggName = viewMeasuresRaw.find((x) => x.fid === MEA_VAL_ID)!.aggName; const newFields = folds - .map((k) => allFields.find((x) => x.fid === k)!) + .map((k) => allFields.find((x) => getFieldIdentifier(x) === k)!) .filter(Boolean) .map((x) => replaceAggForFold(x, aggName)); - viewDimensions.push(...newFields.filter((x) => x?.analyticType === 'dimension')); - viewMeasures.push(...newFields.filter((x) => x?.analyticType === 'measure')); + viewDimensionsGuarded.push(...newFields.filter((x) => x?.analyticType === 'dimension')); + viewMeasuresGuarded.push(...newFields.filter((x) => x?.analyticType === 'measure')); } - const viewKeys = new Set([...viewDimensions, ...viewMeasures, ...viewFilters].map((f) => f.fid)); + const allComputedRaw = treeShake( + allFields.filter((f) => f.computed && f.expression) as (IViewField & { expression: IExpression })[], + deduper([...viewDimensionsGuarded, ...viewMeasuresGuarded, ...viewFiltersRaw], (x) => + JSON.stringify([getFieldIdentifier(x), encodePath(x.joinPath ?? [])]) + ) + ); + const { + datasets, + filters: viewFilters, + foreignKeys, + processFid, + views: { viewDimensions, viewMeasures, allComputed }, + } = transformMultiDatasetFields({ + filters: viewFiltersRaw, + views: { + viewDimensions: viewDimensionsGuarded, + viewMeasures: viewMeasuresGuarded, + allComputed: allComputedRaw, + }, + }); let filterWorkflow: IFilterWorkflowStep | null = null; let transformWorkflow: ITransformWorkflowStep | null = null; @@ -156,16 +190,8 @@ export const toWorkflow = ( let sortWorkflow: ISortWorkflowStep | null = null; let aggFilterWorkflow: IFilterWorkflowStep | null = null; - // TODO: apply **fold** before filter - - const buildFilter = (f: IFilterField) => { - const filter = createFilter(f); - viewKeys.add(filter.fid); - return filter; - }; - // First, to apply filters on the detailed data - const filters = viewFilters.filter((f) => !f.computed && f.rule && !f.enableAgg).map(buildFilter); + const filters = viewFilters.filter((f) => !f.computed && f.rule && !f.enableAgg).map(createFilter); if (filters.length) { filterWorkflow = { type: 'filter', @@ -174,17 +200,18 @@ export const toWorkflow = ( } // Second, to transform the data by rows 1 by 1 - const computedFields = treeShake( - allFields - .filter((f) => f.computed && f.expression && !(f.expression.op === 'expr' && f.aggName === 'expr')) - .map((f) => { - return { - key: f.fid, - expression: processExpression(f.expression!, allFields, { timezoneDisplayOffset }), - }; - }), - [...viewKeys] - ); + const computedFields = allComputed + .filter((f) => !(f.expression.op === 'expr' && f.aggName === 'expr')) + .map((f) => { + return { + key: f.fid, + expression: processExpression( + f.expression!, + allFields.filter((x) => x.dataset === f.dataset), + { timezoneDisplayOffset, transformFid: processFid(f.joinPath) } + ), + }; + }); if (computedFields.length) { transformWorkflow = { type: 'transform', @@ -193,7 +220,7 @@ export const toWorkflow = ( } // Third, apply filter on the transformed data - const computedFilters = viewFilters.filter((f) => f.computed && f.rule && !f.enableAgg).map(buildFilter); + const computedFilters = viewFilters.filter((f) => f.computed && f.rule && !f.enableAgg).map(createFilter); if (computedFilters.length) { computedWorkflow = { type: 'filter', @@ -206,17 +233,19 @@ export const toWorkflow = ( // 1. If any of the measures is aggregated, then we apply the aggregation // 2. If there's no measure in the view, then we apply the aggregation const aggergatedFilter = viewFilters.filter((f) => f.enableAgg && f.aggName && f.rule); - const aggergatedComputed = treeShake( - allFields - .filter((f) => f.computed && f.expression && f.expression.op === 'expr' && f.aggName === 'expr') - .map((f) => { - return { - key: f.fid, - expression: processExpression(f.expression!, allFields, { timezoneDisplayOffset }), - }; - }), - [...viewKeys] - ); + const aggergatedComputed = allComputed + .filter((f) => f.expression.op === 'expr' && f.aggName === 'expr') + .map((f) => { + return { + key: f.fid, + expression: processExpression( + f.expression!, + allFields.filter((x) => x.dataset === f.dataset), + { timezoneDisplayOffset, transformFid: processFid(f.joinPath) } + ), + }; + }); + const aggregateOn = viewMeasures .filter((f) => f.aggName) .map((f) => [f.fid, f.aggName as string]) @@ -270,7 +299,7 @@ export const toWorkflow = ( if (aggergated && viewDimensions.length > 0 && aggergatedFilter.length > 0) { aggFilterWorkflow = { type: 'filter', - filters: aggergatedFilter.map(buildFilter), + filters: aggergatedFilter.map(createFilter), }; } @@ -282,7 +311,15 @@ export const toWorkflow = ( }; } + const joinWorkflow: IJoinWorkflowStep | null = foreignKeys + ? { + type: 'join', + foreigns: foreignKeys, + } + : null; + const steps: IDataQueryWorkflowStep[] = [ + joinWorkflow!, filterWorkflow!, transformWorkflow!, computedWorkflow!, @@ -290,7 +327,7 @@ export const toWorkflow = ( aggFilterWorkflow!, sortWorkflow!, ].filter(Boolean); - return steps; + return { workflow: steps, datasets }; }; export const addTransformForQuery = ( @@ -344,6 +381,21 @@ export const addFilterForQuery = (query: IDataQueryPayload, filters: IVisFilter[ }; }; +export const addJoinForQuery = (query: IDataQueryPayload, join: IJoinWorkflowStep[]): IDataQueryPayload => { + if (join.length === 0) return query; + return { + ...query, + workflow: [...join, ...query.workflow.filter((x) => x.type !== 'join')], + }; +}; + +export const changeDatasetForQuery = (query: IDataQueryPayload, datasets: string[]) => { + return { + ...query, + datasets, + }; +}; + export function chartToWorkflow(chart: IVisSpec | IChart): IDataQueryPayload { const parsedChart = parseChart(chart); const viewEncodingFields = viewEncodingKeys(parsedChart.config?.geoms?.[0] ?? 'auto').flatMap((k) => parsedChart.encodings?.[k] ?? []); @@ -351,7 +403,7 @@ export function chartToWorkflow(chart: IVisSpec | IChart): IDataQueryPayload { const columns = parsedChart.encodings?.columns ?? []; const limit = parsedChart.config?.limit ?? -1; return { - workflow: toWorkflow( + ...toWorkflow( parsedChart.encodings?.filters ?? [], [...(parsedChart.encodings?.dimensions ?? []), ...(parsedChart.encodings?.measures ?? [])], viewEncodingFields.filter((x) => x.analyticType === 'dimension'), @@ -366,14 +418,20 @@ export function chartToWorkflow(chart: IVisSpec | IChart): IDataQueryPayload { }; } -export const processExpression = (exp: IExpression, allFields: IMutField[], config: { timezoneDisplayOffset?: number }): IExpression => { +export const processExpression = ( + exp: IExpression, + allFields: IMutField[], + config: { timezoneDisplayOffset?: number; transformFid?: (fid: string) => string } +): IExpression => { + const { transformFid = (x) => x } = config; if (exp?.op === 'expr') { + // not processed with multi dataset yet, process transformFid here return { ...exp, params: [ { type: 'sql', - value: replaceFid(exp.params.find((x) => x.type === 'sql')!.value, allFields).trim(), + value: replaceFid(exp.params.find((x) => x.type === 'sql')!.value, allFields, transformFid).trim(), }, ], }; @@ -402,8 +460,8 @@ export const processExpression = (exp: IExpression, allFields: IMutField[], conf return { type: 'map' as const, value: { - x: x.value.x, - y: x.value.y, + x: transformFid(x.value.x), + y: transformFid(x.value.y), domainX: x.value.domainX, domainY: x.value.domainY, map: x.value.map, @@ -423,6 +481,7 @@ export const processExpression = (exp: IExpression, allFields: IMutField[], conf ...x.value.dict, '255': { name: '' }, }; + // facets multi dataset is already dealt in createTransformerForComputed const colors = Array.from(new Set(x.value.usedColor.concat(1))); return { type: 'newmap', diff --git a/packages/graphic-walker/src/vanilla.tsx b/packages/graphic-walker/src/vanilla.tsx index 138a2c8a..f94c953e 100644 --- a/packages/graphic-walker/src/vanilla.tsx +++ b/packages/graphic-walker/src/vanilla.tsx @@ -20,7 +20,16 @@ function FullGraphicWalker(props: IGWProps) { uiTheme={props.uiTheme} > {(p) => { - return ; + return ( + + ); }} ); diff --git a/packages/graphic-walker/src/vis/spec/encode.ts b/packages/graphic-walker/src/vis/spec/encode.ts index a222b41a..d718c07d 100644 --- a/packages/graphic-walker/src/vis/spec/encode.ts +++ b/packages/graphic-walker/src/vis/spec/encode.ts @@ -32,24 +32,37 @@ export function availableChannels(geomType: string): Set { } function encodeTimeunit(unit: (typeof DATE_TIME_DRILL_LEVELS)[number]) { switch (unit) { + case 'iso_year': + case 'year': + return 'utcyear'; case 'quarter': - return 'yearquarter'; + return 'utcyearquarter'; case 'month': - return 'yearmonth'; + return 'utcyearmonth'; + case 'iso_week': case 'week': - return 'yearweek'; + return 'utcyearweek'; case 'day': - return 'yearmonthdate'; + return 'utcyearmonthdate'; case 'hour': - return 'yearmonthdatehours'; + return 'utcyearmonthdatehours'; case 'minute': - return 'yearmonthdatehoursminutes'; + return 'utcyearmonthdatehoursminutes'; case 'second': - return 'yearmonthdatehoursminutesseconds'; + return 'utcyearmonthdatehoursminutesseconds'; } return unit; } +function isoTimeformat(unit: string) { + switch (unit) { + case 'iso_year': + return '%G'; + case 'iso_week': + return '%G W%V'; + } +} + export function encodeFid(fid: string) { return fid .replace(/([\"\'\.\[\]\/\\])/g, '\\$1') @@ -100,6 +113,9 @@ export function channelEncode(props: IEncodeProps) { encoding[c].scale = { type: 'utc' }; } if (field.semanticType === 'temporal' && field.timeUnit) { + if (field.timeUnit.startsWith('iso')) { + encoding[c].format = isoTimeformat(field.timeUnit); + } encoding[c].timeUnit = encodeTimeunit(field.timeUnit); } if (c === 'color' && field.expression?.op === 'paint') { diff --git a/packages/graphic-walker/src/vis/spec/tooltip.ts b/packages/graphic-walker/src/vis/spec/tooltip.ts index 16640ad3..36319d03 100644 --- a/packages/graphic-walker/src/vis/spec/tooltip.ts +++ b/packages/graphic-walker/src/vis/spec/tooltip.ts @@ -13,7 +13,8 @@ export function addTooltipEncode(encoding: { [key: string]: any }, details: Read title: encoding[ck].title, } as Record, (draft) => { - if (encoding[ck].timeUnit) { + if (encoding[ck].timeUnit && !encoding[ck].format) { + // timeUnit overrides format draft.timeUnit = encoding[ck].timeUnit; } if (encoding[ck].scale) { @@ -30,8 +31,8 @@ export function addTooltipEncode(encoding: { [key: string]: any }, details: Read }) .concat( details.map((f) => ({ - field: defaultAggregated ? getMeaAggKey(f.fid, f.aggName) : f.fid, - title: defaultAggregated ? getMeaAggName(f.name, f.aggName) : f.name, + field: defaultAggregated && f.analyticType === 'measure' ? getMeaAggKey(f.fid, f.aggName) : f.fid, + title: defaultAggregated && f.analyticType === 'measure' ? getMeaAggName(f.name, f.aggName) : f.name, type: f.semanticType, })) ); diff --git a/packages/graphic-walker/src/visualSettings/index.tsx b/packages/graphic-walker/src/visualSettings/index.tsx index 153184f7..5bd9386a 100644 --- a/packages/graphic-walker/src/visualSettings/index.tsx +++ b/packages/graphic-walker/src/visualSettings/index.tsx @@ -21,6 +21,7 @@ import { GlobeAmericasIcon, DocumentPlusIcon, PaintBrushIcon, + KeyIcon, } from '@heroicons/react/24/outline'; import { observer } from 'mobx-react-lite'; import React, { SVGProps, useCallback, useMemo } from 'react'; @@ -39,6 +40,7 @@ import LimitSetting from '../components/limitSetting'; import { omitRedundantSeparator } from './utils'; import { Button } from '@/components/ui/button'; import { classNames } from '@/utils'; +import { EMPTY_FIELD_ID } from '@/constants'; interface IVisualSettings { darkModePreference: IDarkMode; @@ -384,8 +386,12 @@ const VisualSettings: React.FC = ({ rendererHandler, csvHandler vizStore.setVisualLayout('showTableSummary', checked); }, }, + + ...(vizStore.isMultiDataset + ? [{ key: 'foreign:add', label: 'Add a foreign Key', icon: KeyIcon, onClick: () => vizStore.setLinkingDataset(vizStore.baseDataset) }] + : []), ...(experimentalFeatures?.computedField - ? [{ key: 'field:add', label: 'Add Computed Field', icon: DocumentPlusIcon, onClick: () => vizStore.setComputedFieldFid('') }] + ? [{ key: 'field:add', label: 'Add Computed Field', icon: DocumentPlusIcon, onClick: () => vizStore.setComputedFieldFid(EMPTY_FIELD_ID) }] : []), '-', { diff --git a/packages/graphic-walker/src/workers/join.worker.ts b/packages/graphic-walker/src/workers/join.worker.ts new file mode 100644 index 00000000..e3f62816 --- /dev/null +++ b/packages/graphic-walker/src/workers/join.worker.ts @@ -0,0 +1,20 @@ +import { IDatasetForeign, IRow } from '../interfaces'; +import { join } from '../lib/join'; + +const main = (e: { + data: { + rawDatasets: Record; + foreigns: IDatasetForeign[]; + }; +}) => { + try { + const { rawDatasets, foreigns } = e.data; + const ans = join(rawDatasets, foreigns); + self.postMessage(ans); + } catch (err: any) { + console.error(err.stack); + self.postMessage(err.stack); + } +}; + +self.addEventListener('message', main, false); diff --git a/packages/playground/src/examples/pages/ds.stories.tsx b/packages/playground/src/examples/pages/ds.stories.tsx index a250ba85..d9adf42e 100644 --- a/packages/playground/src/examples/pages/ds.stories.tsx +++ b/packages/playground/src/examples/pages/ds.stories.tsx @@ -11,13 +11,10 @@ export default function DataSourceSegment() { const provider = getProvider(); return ( - {(p) => { + {(props) => { return ( diff --git a/packages/playground/src/examples/pages/inModal.stories.tsx b/packages/playground/src/examples/pages/inModal.stories.tsx index 78362c89..f14e7f05 100644 --- a/packages/playground/src/examples/pages/inModal.stories.tsx +++ b/packages/playground/src/examples/pages/inModal.stories.tsx @@ -1,47 +1,52 @@ -import { useContext, useEffect, useRef } from 'react'; +import { useContext, useState } from 'react'; import spec from '../specs/student-chart.json'; -import { GraphicWalker, VizSpecStore, grayTheme } from '@kanaries/graphic-walker'; +import { GraphicWalker, grayTheme, IChart } from '@kanaries/graphic-walker'; import { themeContext } from '../context'; import { useFetch, IDataSource } from '../util'; export default function GraphicWalkerInModal() { - const ref = useRef(null); const { theme } = useContext(themeContext); const { dataSource, fields } = useFetch('https://pub-2422ed4100b443659f588f2382cfc7b1.r2.dev/datasets/ds-students-service.json'); + const [open, setOpen] = useState(false); - useEffect(() => { - setTimeout(() => { - if (ref.current) { - ref.current.importCode(spec as never); - } - }, 0); - }, []); return ( -
-
- -
+
+ + {open && ( +
setOpen(false)} + style={{ + position: 'fixed', + inset: 0, + backdropFilter: 'blur(10px)', + zIndex: 9999, + }} + > +
{ + e.stopPropagation(); + e.preventDefault(); + }} + style={{ + position: 'absolute', + borderRadius: 20, + border: '1px solid gray', + left: 80, + right: 80, + top: 80, + bottom: 80, + overflow: 'auto', + padding: 20, + boxSizing: 'border-box', + }} + > + +
+
+ )}
); } diff --git a/packages/playground/src/examples/pages/renderer.tsx b/packages/playground/src/examples/pages/renderer.tsx index d34e961a..49f5bae2 100644 --- a/packages/playground/src/examples/pages/renderer.tsx +++ b/packages/playground/src/examples/pages/renderer.tsx @@ -4,7 +4,7 @@ import Comp from './renderer.stories'; export default function GraphicWalkerComponent() { return ( - + ); diff --git a/yarn.lock b/yarn.lock index 6f44f48c..6679963b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -652,27 +652,27 @@ resolved "https://registry.npmjs.org/@eslint/js/-/js-8.54.0.tgz#4fab9a2ff7860082c304f750e94acd644cf984cf" integrity sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ== -"@floating-ui/core@^1.0.0", "@floating-ui/core@^1.4.2": +"@floating-ui/core@^1.0.0": version "1.5.0" resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz#5c05c60d5ae2d05101c3021c1a2a350ddc027f8c" integrity sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg== dependencies: "@floating-ui/utils" "^0.1.3" -"@floating-ui/core@^1.6.0": +"@floating-ui/core@^1.5.3", "@floating-ui/core@^1.6.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== dependencies: "@floating-ui/utils" "^0.2.1" -"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.2.1": - version "1.5.3" - resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa" - integrity sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA== +"@floating-ui/dom@^1.5.4": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.3.tgz#954e46c1dd3ad48e49db9ada7218b0985cee75ef" + integrity sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw== dependencies: - "@floating-ui/core" "^1.4.2" - "@floating-ui/utils" "^0.1.3" + "@floating-ui/core" "^1.0.0" + "@floating-ui/utils" "^0.2.0" "@floating-ui/dom@^1.6.1": version "1.6.1" @@ -682,13 +682,6 @@ "@floating-ui/core" "^1.6.0" "@floating-ui/utils" "^0.2.1" -"@floating-ui/react-dom@^1.3.0": - version "1.3.0" - resolved "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz#4d35d416eb19811c2b0e9271100a6aa18c1579b3" - integrity sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g== - dependencies: - "@floating-ui/dom" "^1.2.1" - "@floating-ui/react-dom@^2.0.0": version "2.0.8" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d" @@ -696,33 +689,33 @@ dependencies: "@floating-ui/dom" "^1.6.1" -"@floating-ui/react@^0.19.0": - version "0.19.2" - resolved "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.2.tgz#c6e4d2097ed0dca665a7c042ddf9cdecc95e9412" - integrity sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w== +"@floating-ui/react@^0.26.5": + version "0.26.10" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.10.tgz#d4a4878bcfaed70963ec0eaa67a71bead5924ee5" + integrity sha512-sh6f9gVvWQdEzLObrWbJ97c0clJObiALsFe0LiR/kb3tDRKwEhObASEH2QyfdoO/ZBPzwxa9j+nYFo+sqgbioA== dependencies: - "@floating-ui/react-dom" "^1.3.0" - aria-hidden "^1.1.3" - tabbable "^6.0.1" + "@floating-ui/react-dom" "^2.0.0" + "@floating-ui/utils" "^0.2.0" + tabbable "^6.0.0" "@floating-ui/utils@^0.1.3": version "0.1.6" resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== -"@floating-ui/utils@^0.2.1": +"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": version "0.2.1" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== -"@headlessui-float/react@^0.11.4": - version "0.11.4" - resolved "https://registry.npmjs.org/@headlessui-float/react/-/react-0.11.4.tgz#56bdf96dc8c206ae7adda8b1960f354f32876320" - integrity sha512-4I72p196YZuASAnXQSgiS7Rrby2rjPOe0b55mWo0Kq3RS2CPBmSK5yLlN3vfiF2XKqY5Ko3/AFStLirnrXSkTw== +"@headlessui-float/react@^0.13.2": + version "0.13.2" + resolved "https://registry.yarnpkg.com/@headlessui-float/react/-/react-0.13.2.tgz#45b34e76b9ab2e022b136fb75a8aa73d57f70cf0" + integrity sha512-+SWOuKhfMvNZ6yqPsLAC4/N8FnHASBXIJxJQ9kqDZmeP08V5nzfnsQh1UxKJPktpfPWQkPaL+RoONxNqkQfM8A== dependencies: - "@floating-ui/core" "^1.0.0" - "@floating-ui/dom" "^1.0.0" - "@floating-ui/react" "^0.19.0" + "@floating-ui/core" "^1.5.3" + "@floating-ui/dom" "^1.5.4" + "@floating-ui/react" "^0.26.5" "@headlessui/react@1.7.12": version "1.7.12" @@ -1297,6 +1290,22 @@ "@radix-ui/react-primitive" "1.0.3" "@radix-ui/react-use-callback-ref" "1.0.1" +"@radix-ui/react-hover-card@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-hover-card/-/react-hover-card-1.0.7.tgz#684bca2504432566357e7157e087051aa3577948" + integrity sha512-OcUN2FU0YpmajD/qkph3XzMcK/NmSk9hGWnjV68p6QiZMgILugusgQwnLSDs3oFSJYGKf3Y49zgFedhGh04k9A== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-popper" "1.1.3" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-icons@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-icons/-/react-icons-1.3.0.tgz#c61af8f323d87682c5ca76b856d60c2312dbcb69" @@ -2061,9 +2070,9 @@ "@types/reactcss" "*" "@types/react-dom@*", "@types/react-dom@^18.2.15", "@types/react-dom@^18.x": - version "18.3.0" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" - integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== + version "18.2.22" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.22.tgz#d332febf0815403de6da8a97e5fe282cbe609bae" + integrity sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ== dependencies: "@types/react" "*" @@ -2075,11 +2084,12 @@ "@types/react" "*" "@types/react@*", "@types/react@^18.2.37", "@types/react@^18.x": - version "18.3.2" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.2.tgz#462ae4904973bc212fa910424d901e3d137dbfcd" - integrity sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w== + version "18.2.66" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.66.tgz#d2eafc8c4e70939c5432221adb23d32d76bfe451" + integrity sha512-OYTmMI4UigXeFMF/j4uv0lBBEbongSgptPrHBxqME44h9+yNov+oL6Z3ocJKo0WyXR84sQUNeyIp9MRfckvZpg== dependencies: "@types/prop-types" "*" + "@types/scheduler" "*" csstype "^3.0.2" "@types/reactcss@*": @@ -2089,6 +2099,11 @@ dependencies: "@types/react" "*" +"@types/scheduler@*": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.23.0.tgz#0a6655b3e2708eaabca00b7372fafd7a792a7b09" + integrity sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw== + "@types/semver@^7.5.0": version "7.5.5" resolved "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz#deed5ab7019756c9c90ea86139106b0346223f35" @@ -2391,7 +2406,7 @@ argparse@^2.0.1: resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-hidden@^1.1.1, aria-hidden@^1.1.3: +aria-hidden@^1.1.1: version "1.2.3" resolved "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz#14aeb7fb692bbb72d69bebfa47279c1fd725e954" integrity sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ== @@ -5472,9 +5487,9 @@ svg-path-contours@^2.0.0: normalize-svg-path "^0.1.0" vec2-copy "^1.0.0" -tabbable@^6.0.1: +tabbable@^6.0.0: version "6.2.0" - resolved "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== table-layout@^3.0.0: