diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/README.md b/README.md index d6c6aa50f..b2477f6a9 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,6 @@ export interface IGWProps { i18nLang?: string; i18nResources?: { [lang: string]: Record }; keepAlive?: boolean | string; - fieldKeyGuard?: boolean; vizThemeConfig?: IThemeKey; apperence?: IDarkMode; storeRef?: React.RefObject; diff --git a/packages/duckdb-wasm-computation/src/index.ts b/packages/duckdb-wasm-computation/src/index.ts index 8e62f4283..cbb2db5a2 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 bac198084..4814121db 100644 --- a/packages/graphic-walker/package.json +++ b/packages/graphic-walker/package.json @@ -48,6 +48,7 @@ }, "types": "./dist/index.d.ts", "dependencies": { + "@headlessui-float/react": "^0.13.2", "@headlessui/react": "1.7.12", "@heroicons/react": "^2.2.0", "@kanaries/react-beautiful-dnd": "^0.1.1", @@ -98,14 +99,14 @@ "postcss": "^8.3.7", "postinstall-postinstall": "^2.1.0", "rbush": "^3.0.1", - "re-resizable": "^6.9.8", + "re-resizable": "^6.11.2", "react-color": "^2.19.3", "react-dropzone": "^14.2.3", "react-error-boundary": "^4.0.11", "react-i18next": "^11.18.6", "react-leaflet": "^4.2.1", "react-resizable-panels": "^1.0.10", - "react-resize-detector": "^9.1.1", + "react-resize-detector": "^12.3.0", "react-shadow": "^20.6.0", "rxjs": "^7.3.0", "tailwind-merge": "^2.2.1", diff --git a/packages/graphic-walker/src/App.tsx b/packages/graphic-walker/src/App.tsx index 911e54f46..480de2d4b 100644 --- a/packages/graphic-walker/src/App.tsx +++ b/packages/graphic-walker/src/App.tsx @@ -31,11 +31,11 @@ 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'; import RenamePanel from './components/renameField'; +import FieldConfigDialog from './components/fieldConfigDialog'; import { ErrorContext } from './utils/reportError'; import { ErrorBoundary } from 'react-error-boundary'; import Errorpanel from './components/errorpanel'; @@ -52,7 +52,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 & @@ -79,6 +81,7 @@ export const VizApp = observer(function VizApp(props: BaseVizProps) { chart, vlSpec, onError, + datasetNames, hideSegmentNav, hideProfiling, } = props; @@ -161,12 +164,13 @@ export const VizApp = observer(function VizApp(props: BaseVizProps) { return ( - Something went wrong} onError={props.onError}> + Something went wrong} onError={console.log}>
@@ -234,8 +238,10 @@ 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), @@ -341,7 +335,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 1697dbda1..66c4f66bf 100644 --- a/packages/graphic-walker/src/Renderer.tsx +++ b/packages/graphic-walker/src/Renderer.tsx @@ -20,13 +20,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'; @@ -143,6 +142,7 @@ export const RendererApp = observer(function VizApp(props: BaseVizProps) { themeContext={darkMode} vegaThemeContext={{ vizThemeConfig: currentTheme, setVizThemeConfig: setCurrentTheme }} portalContainerContext={portal} + DatasetNamesContext={props.datasetNames} >
@@ -175,12 +175,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, @@ -190,24 +190,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; @@ -269,7 +271,7 @@ const FilterSection = observer(function FilterSection() { return (
{vizStore.viewFilters.map((filter, idx) => ( - handleWriteFilter(idx, rule)} /> + handleWriteFilter(idx, rule)} /> ))}
); @@ -290,19 +292,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), @@ -314,7 +303,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 e59952aa6..165cf4acc 100644 --- a/packages/graphic-walker/src/Table.tsx +++ b/packages/graphic-walker/src/Table.tsx @@ -5,16 +5,17 @@ import { useTranslation } from 'react-i18next'; import { IAppI18nProps, IErrorHandlerProps, IComputationContextProps, ITableProps, ITableSpecProps, IComputationProps, IMutField, IThemeKey } from './interfaces'; import { GWGlobalConfig } from './vis/theme'; 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 & @@ -75,8 +76,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}> @@ -85,20 +91,36 @@ export const TableApp = observer(function VizApp(props: BaseTableProps) { themeContext={darkMode} vegaThemeContext={{ vizThemeConfig: currentTheme, setVizThemeConfig: setCurrentTheme }} portalContainerContext={portal} + DatasetNamesContext={props.datasetNames} >
- 1 && ( + + + {datasets.map((ds) => ( + {props.datasetNames?.[ds] ?? ds} + ))} + + + )} + { - vizStore.updateCurrentDatasetMetas(fid, diffMeta); - } : undefined} + onMetaChange={ + vizStore.onMetaChange + ? (fid, fIndex, diffMeta) => { + vizStore.updateCurrentDatasetMetas(getFieldIdentifier(metas[fIndex]), diffMeta); + } + : undefined + } size={pageSize} - metas={metas} + metas={tableMeta} computation={wrappedComputation} displayOffset={props.displayOffset} hidePaginationAtOnepage={props.hidePaginationAtOnepage} hideProfiling={props.hideProfiling} + profilingComputation={props.profilingComputation} + cellStyle={props.cellStyle} disableFilter={props.disableFilter} disableSorting={props.disableSorting} hideSemanticType={props.hideSemanticType} @@ -126,21 +148,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), @@ -152,7 +159,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 7bca7338b..ee8696fc3 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 d3ebcb584..583bafca3 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 c0a9012dd..5ba358244 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({hideProfiling}: {hideProfiling?: boolean}) { 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; + cellStyle?: (value: string | number, field: IMutField, row: IRow, dark: boolean) => React.CSSProperties; disableFilter?: boolean; disableSorting?: boolean; hideSemanticType?: boolean; @@ -94,9 +99,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({ @@ -107,7 +112,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++; } @@ -133,22 +137,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)); @@ -283,292 +288,357 @@ const DataTable = forwardRef( const [statLoading, setStatLoading] = useState(false); // Get count when filter changed - useEffect(() => { - const f = filters.filter((x) => x.rule).map((x) => ({ ...x, rule: x.rule })); - setStatLoading(true); - computation({ - workflow: [ - ...(!disableFilter && f && f.length > 0 - ? [ - { - type: 'filter', - filters: f, - } as IFilterWorkflowStep, - ] - : []), - { - type: 'view', - query: [ - { - op: 'aggregate', - groupBy: [], - measures: [ - { - field: '*', - agg: 'count', - asFieldKey: 'count', - }, - ], - }, - ], - }, - ], - }).then((v) => { - setTotal(v[0]?.count ?? 0); - setStatLoading(false); - }); - }, [disableFilter, filters, computation]); +const DataTable = forwardRef( + ( + props: DataTableProps, + ref: ForwardedRef<{ + getFilters: () => IVisFilter[]; + }> + ) => { + const { size = 10, onMetaChange, metas, computation, disableFilter, disableSorting, hideSemanticType, displayOffset, hidePaginationAtOnepage, hideProfiling } = props; + const [pageIndex, setPageIndex] = useState(0); + const { t } = useTranslation(); + const computationFunction = computation; - const from = pageIndex * size; - const to = Math.min((pageIndex + 1) * size - 1, total - 1); + const semanticTypeList = useMemo<{ value: string; label: string }[]>(() => { + return SEMANTIC_TYPE_LIST.map((st) => ({ + value: st, + label: t(`constant.semantic_type.${st}`), + })); + }, []); - useEffect(() => { - if (from > total) { - setPageIndex(0); - } - }, [from, total]); + const [rows, setRows] = useState([]); + const [dataLoading, setDataLoading] = useState(false); + const taskIdRef = useRef(0); - useEffect(() => { - setDataLoading(true); - const taskId = ++taskIdRef.current; - dataReadRaw(computationFunction, size, pageIndex, { - sorting: disableSorting ? undefined : sorting, - filters: filters.filter((x) => x.rule).map((x) => ({ ...x, rule: x.rule! })), - }) - .then((data) => { - if (taskId === taskIdRef.current) { - setDataLoading(false); - setRows(data); + const [sorting, setSorting] = useState<{ fid: FieldIdentifier; sort: 'ascending' | 'descending' } | undefined>(); + + const { filters, editingFilterIdx, onClose, onDeleteFilter, onSelectFilter, onWriteFilter, options } = useFilters(metas); + + const filtersRef = useRef(filters); + filtersRef.current = filters; + + useImperativeHandle(ref, () => ({ + getFilters: () => filtersRef.current.filter(x => x.rule) as IVisFilter[], + })); + + 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 })); + setStatLoading(true); + computation({ + workflow: [ + ...(!disableFilter && f && f.length > 0 + ? [ + { + type: 'filter', + filters: f, + } as IFilterWorkflowStep, + ] + : []), + { + type: 'view', + query: [ + { + op: 'aggregate', + groupBy: [], + measures: [ + { + field: '*', + agg: 'count', + asFieldKey: 'count', + }, + ], + }, + ], + }, + ], + datasets, + }).then((v) => { + setTotal(v[0]?.count ?? 0); + setStatLoading(false); + }); + }, [disableFilter, filters, computation, datasets]); + + const from = pageIndex * size; + const to = Math.min((pageIndex + 1) * size - 1, total - 1); + + useEffect(() => { + if (from > total) { + setPageIndex(0); + } + }, [from, total]); + + useEffect(() => { + setDataLoading(true); + const taskId = ++taskIdRef.current; + 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! })), } - }) - .catch((err) => { - if (taskId === taskIdRef.current) { - console.error(err); - setDataLoading(false); - setRows([]); + ) + .then((data) => { + if (taskId === taskIdRef.current) { + setDataLoading(false); + setRows(data); + } + }) + .catch((err) => { + if (taskId === taskIdRef.current) { + console.error(err); + setDataLoading(false); + setRows([]); + } + }); + return () => { + taskIdRef.current++; + }; + }, [computationFunction, pageIndex, size, sorting, filters, datasets]); + + const filteredComputation = useMemo((): IComputationFunction => { + const filterRules = filters.filter((f) => f.rule).map((f) => createFilter(f)); + return (query) => computation(addFilterForQuery(query, filterRules)); + }, [computation, filters]); + + const filteredProfilingComputation = useMemo((): IComputationFunction | IComputationFunction[] | undefined => { + if (props.profilingComputation) { + const filterRules = filters.filter((f) => f.rule).map((f) => createFilter(f)); + const profilingComputation = props.profilingComputation; + if (Array.isArray(profilingComputation)) { + return profilingComputation.map((comp) => (query) => comp(addFilterForQuery(query, filterRules))); + } else { + return (query) => profilingComputation(addFilterForQuery(query, filterRules)); } - }); - return () => { - taskIdRef.current++; - }; - }, [computationFunction, pageIndex, size, sorting, filters, disableSorting]); + } + return undefined; + }, [props.profilingComputation, filters]); - const filteredComputation = useMemo((): IComputationFunction => { - const filterRules = filters.filter((f) => f.rule).map(createFilter); - return (query) => computation(addFilterForQuery(query, filterRules)); - }, [computation, filters]); + const loading = statLoading || dataLoading; - const loading = statLoading || dataLoading; + const displayMetas = useMemo(() => metas.filter((x) => !x.disable), [metas]); - const headers = useMemo(() => getHeaders(metas), [metas]); + const headers = useMemo(() => getHeaders(displayMetas), [displayMetas]); - const [isSticky, setIsSticky] = useState(false); + const [isSticky, setIsSticky] = useState(false); - const obRef = useRef(null); - const stickyDector = useCallback((node: HTMLDivElement) => { - obRef.current?.disconnect(); - if (node) { - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - setIsSticky(!entry.isIntersecting); + const obRef = useRef(); + const stickyDector = useCallback((node: HTMLDivElement) => { + obRef.current?.disconnect(); + if (node) { + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + setIsSticky(!entry.isIntersecting); + }); }); - }); - observer.observe(node); - obRef.current = observer; - } - }, []); - return ( - - {!disableFilter && filters.length > 0 && ( -
- Filters: - {filters.map((x, i) => ( - onSelectFilter(x.fid)} onRemove={() => onDeleteFilter(i)} /> - ))} -
- )} - {!(hidePaginationAtOnepage && total <= size) && ( - - )} -
-
- - - {headers.map((row) => ( - - {row.map((f, i) => ( - + ))} + + )} + + + {rows.map((row, index) => ( + + {displayMetas.map((field) => { + const value = fieldValue({ field, item: row, displayOffset }); + return ( + + ); + })} + + ))} + +
- {f.type === 'name' && ( -
- {f.value} -
- )} - {f.type === 'field' && ( -
-
- {!hideSemanticType && !onMetaChange && ( - - - - )} - {!hideSemanticType && onMetaChange && ( - { - onMetaChange(f.value.fid, f.fIndex, { - semanticType: value as IMutField['semanticType'], - }); - }} - > - + )} + {!(hidePaginationAtOnepage && total <= size) && ( + + )} +
+
+ + + {headers.map((row) => ( + + {row.map((f, i) => ( + - ))} - - ))} - {!props.hideProfiling && ( - - {metas.map((field) => ( - - ))} - - )} - - - {rows.map((row, index) => ( - - {metas.map((field) => { - const value = fieldValue({ field, item: row, displayOffset }); - return ( - + ))} + {!hideProfiling && ( + + {displayMetas.map((field) => ( + - ))} - -
+ {f.type === 'name' && ( +
+ {f.value} +
+ )} + {f.type === 'field' && ( +
+
+ {!hideSemanticType && !onMetaChange && ( + - + )} + {!hideSemanticType && onMetaChange && ( + { + onMetaChange(f.value.fid, f.fIndex, { + semanticType: value as IMutField['semanticType'], + }); + }} + > + + + + + )} + {f.value.basename || f.value.name || f.value.fid} + + {sorting?.fid === getFieldIdentifier(f.value) && ( +
+ {sorting.sort === 'ascending' && } + {sorting.sort === 'descending' && } +
+ )} + {!disableFilter && ( +
onSelectFilter(getFieldIdentifier(f.value))} + > + +
)}
- { - if (disableSorting) return; - setSorting((s) => { - if (s?.fid === f.value.fid && s.sort === 'descending') { - return { - fid: f.value.fid, - sort: 'ascending', - }; - } - return { - fid: f.value.fid, - sort: 'descending', - }; - }); - }} - > - {f.value.basename || f.value.name || f.value.fid} - - {!disableSorting && sorting?.fid === f.value.fid && ( -
- {sorting.sort === 'ascending' && } - {sorting.sort === 'descending' && } -
- )} - {!disableFilter && ( -
onSelectFilter(f.value.fid)} - > - -
- )} -
- )} -
- -
+ ))} +
- - - ); - })} -
-
- - {loading && } - {!disableFilter && ( - -
- -
-
- )} - - ); -}); + +
+ +
+
+ + {loading && } + {!disableFilter && ( + +
+ +
+
+ )} +
+ ); + } +); + export default DataTable; diff --git a/packages/graphic-walker/src/components/dataTable/profiling.tsx b/packages/graphic-walker/src/components/dataTable/profiling.tsx index 7fd518aff..082fd6450 100644 --- a/packages/graphic-walker/src/components/dataTable/profiling.tsx +++ b/packages/graphic-walker/src/components/dataTable/profiling.tsx @@ -12,13 +12,25 @@ import { getTheme } from '../../utils/useTheme'; export interface FieldProfilingProps { field: string; - computation: IComputationFunction; + dataset: string; + computation: IComputationFunction | 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>>(); + const [secStat, setSecStat] = useState>>(); useEffect(() => { - profileNonmialField(wrapComputationWithTag(computation, "profiling"), field).then(setStat); + if (Array.isArray(computation)) { + profileNonmialField(wrapComputationWithTag(computation[0], 'profiling'), field, dataset).then(setStat); + profileNonmialField(wrapComputationWithTag(computation[1], 'profiling'), field, dataset).then(setSecStat); + } else { + profileNonmialField(wrapComputationWithTag(computation, 'profiling'), field, dataset).then(setStat); + } }, [computation, field]); if (!isNotEmpty(stat)) { @@ -33,59 +45,88 @@ function NominalProfiling({ computation, field, valueRenderer = (s) => `${s}` }: return displayValue; }; - const [meta, tops] = stat; - // shows top 2 when the maximum quantity is more than 1.3x the average quantity, and over 1%. - // or there are lower than 10 unique values. - const showsTops = meta.distinctTotal < 10 || (tops[0].count > (1.3 * meta.total) / meta.distinctTotal && tops[0].count > meta.total / 100); + const renderer = (data: typeof stat) => { + const [meta, tops] = data; + // shows top 2 when the maximum quantity is more than 1.3x the average quantity, and over 1%. + // or there are lower than 10 unique values. + const showsTops = meta.distinctTotal < 10 || (tops[0].count > (1.3 * meta.total) / meta.distinctTotal && tops[0].count > meta.total / 100); - if (meta.distinctTotal === 1) { - return
= {render(tops[0].value)}
; - } + if (meta.distinctTotal === 1) { + return
= {render(tops[0].value)}
; + } - return ( -
- {showsTops && ( - <> - {tops.map(({ count, value }, idx) => { - const displayValue = render(value); - return ( - -
-
{displayValue}
-
{Math.floor((100 * count) / meta.total)}%
+ return ( +
+ {showsTops && ( + <> + {tops.map(({ count, value }, idx) => { + const displayValue = render(value); + return ( + +
+
{displayValue}
+
{Math.floor((100 * count) / meta.total)}%
+
+
+ ); + })} + {meta.distinctTotal > tops.length && ( +
+
+ Other ({meta.distinctTotal - tops.length}) +
+
+ {100 - tops.reduce((totalPercent, { count }) => totalPercent + Math.floor((100 * count) / meta.total), 0)}%
- - ); - })} - {meta.distinctTotal > tops.length && ( -
-
- Other ({meta.distinctTotal - tops.length}) -
-
- {100 - tops.reduce((totalPercent, { count }) => totalPercent + Math.floor((100 * count) / meta.total), 0)}%
-
- )} - - )} - {!showsTops && ( - <> -
{meta.distinctTotal}
-
unique values
- - )} + )} + + )} + {!showsTops && ( + <> +
{meta.distinctTotal}
+
unique values
+ + )} +
+ ); + }; + + if (!secStat) { + return renderer(stat); + } + + return ( +
+ {renderer(stat)} + {renderer(secStat)}
); } -const formatter = format('~s'); +const formatter = (x) => { + const abs = Math.abs(x); + if (abs > 0) { + const log = Math.floor(Math.log10(abs)); + if (Math.abs(log) > 26) { + return format('~e')(x); + } + } + return format('~s')(x); +}; -function QuantitativeProfiling({ computation, field }: FieldProfilingProps) { +function QuantitativeProfiling({ computation, field, dataset }: FieldProfilingProps) { const [stat, setStat] = useState>>(); + const [secStat, setSecStat] = useState>>(); useEffect(() => { - profileQuantitativeField(wrapComputationWithTag(computation, "profiling"), field).then(setStat); + if (Array.isArray(computation)) { + profileQuantitativeField(wrapComputationWithTag(computation[0], 'profiling'), field, dataset).then(setStat); + profileQuantitativeField(wrapComputationWithTag(computation[1], 'profiling'), field, dataset).then(setSecStat); + } else { + profileQuantitativeField(wrapComputationWithTag(computation, 'profiling'), field, dataset).then(setStat); + } }, [computation, field]); + if (!isNotEmpty(stat)) { return
Loading...
; } @@ -94,7 +135,7 @@ function QuantitativeProfiling({ computation, field }: FieldProfilingProps) { } return (
- +
{formatter(stat.min)}
{formatter(stat.max)}
@@ -103,7 +144,13 @@ function QuantitativeProfiling({ computation, field }: FieldProfilingProps) { ); } -function BinRenderer({ data }: { data: Awaited> }) { +function BinRenderer({ + data, + extraData, +}: { + data: Awaited>; + extraData?: Awaited>; +}) { const mediaTheme = useContext(themeContext); const { vizThemeConfig } = useContext(vegaThemeContext); @@ -126,35 +173,50 @@ function BinRenderer({ data }: { data: Awaited { + return { + data: { + values: d.binValues.map(({ from, to, count }) => ({ + value: `${formatter(from)} - ${formatter(to)}`, + from, + to, + count, + })), + }, + mark: { type: 'bar', opacity, tooltip: { content: 'data' } }, + encoding: { + x: { + field: 'from', + type: 'quantitative', + axis: false, + bin: { + binned: true, + step: (d.max - d.min) / 10, + }, + }, + x2: { + field: 'to', + type: 'quantitative', + axis: false, + }, + y: { + field: 'count', + type: 'quantitative', + axis: false, + }, + tooltip: [ + { field: 'value', type: 'ordinal', title: 'Value' }, + { field: 'count', type: 'quantitative', title: 'Count' }, + ], + }, + }; + }; const spec = { width: width - 10, height: 70, autosize: 'fit', - data: { - values: data.binValues.map(({ from, to, count }) => ({ - value: `${formatter(from)} - ${formatter(to)}`, - sort: from, - count, - })), - }, - mark: { type: 'bar', opacity: 0.96, tooltip: { content: 'data' } }, - encoding: { - x: { - field: 'value', - type: 'ordinal', - sort: { op: 'sum', field: 'sort' }, - axis: false, - }, - y: { - field: 'count', - type: 'quantitative', - axis: false, - }, - tooltip: [ - { field: 'value', type: 'ordinal', title: 'Value' }, - { field: 'count', type: 'quantitative', title: 'Count' }, - ], - }, + ...(extraData ? { layer: [getSpec(data, 0.5), getSpec(extraData, 0.8)] } : getSpec(data)), config: { view: { stroke: null } }, }; embed(node, spec as unknown as VisualizationSpec, { @@ -167,7 +229,7 @@ function BinRenderer({ data }: { data: Awaited; } diff --git a/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx b/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx index 701f304e8..f086e8184 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'; @@ -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 6d8256601..cc7a7109f 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 (