diff --git a/packages/frontend/src/pages/visualInterface/index.tsx b/packages/frontend/src/pages/visualInterface/index.tsx index ccaad0c8..3ef9efe1 100644 --- a/packages/frontend/src/pages/visualInterface/index.tsx +++ b/packages/frontend/src/pages/visualInterface/index.tsx @@ -1,8 +1,7 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { observer } from 'mobx-react-lite'; import { GraphicWalker } from '@kanaries/graphic-walker'; import { useGlobalStore } from '../../store'; -import { useMemo } from 'react'; import { IMutField } from '@kanaries/graphic-walker/dist/interfaces'; import '@kanaries/graphic-walker/dist/style.css'; diff --git a/packages/graphic-walker/package.json b/packages/graphic-walker/package.json index 5ce05c1b..be8a59fa 100644 --- a/packages/graphic-walker/package.json +++ b/packages/graphic-walker/package.json @@ -29,6 +29,7 @@ "react-beautiful-dnd": "^13.1.0", "react-dom": "^17.0.2", "react-json-view": "^1.21.3", + "rxjs": "^7.3.0", "styled-components": "^5.3.0", "tailwindcss": "^2.2.15", "vega": "^5.20.2", diff --git a/packages/graphic-walker/src/Fields/AestheticFields.tsx b/packages/graphic-walker/src/Fields/AestheticFields.tsx index 073804fd..4b284ded 100644 --- a/packages/graphic-walker/src/Fields/AestheticFields.tsx +++ b/packages/graphic-walker/src/Fields/AestheticFields.tsx @@ -21,8 +21,9 @@ const AestheticFields: React.FC = props => { {...provided.droppableProps} ref={provided.innerRef} > + {provided.placeholder} {draggableFieldState[dkey.id].map((f, index) => ( - + {(provided, snapshot) => { return ( { {...provided.droppableProps} ref={provided.innerRef} > + {provided.placeholder} {dimensions.map((f, index) => ( {(provided, snapshot) => { diff --git a/packages/graphic-walker/src/Fields/posFields.tsx b/packages/graphic-walker/src/Fields/posFields.tsx index eb44fbfc..4579b9bd 100644 --- a/packages/graphic-walker/src/Fields/posFields.tsx +++ b/packages/graphic-walker/src/Fields/posFields.tsx @@ -23,8 +23,9 @@ const PosFields: React.FC = props => { {...provided.droppableProps} ref={provided.innerRef} > + {provided.placeholder} {draggableFieldState[dkey.id].map((f, index) => ( - + {(provided, snapshot) => { return ( void +} +const NULL_FIELD: Field = { + id: '', + name: '', + aggName: 'sum', + type: 'D' +} +const click$ = new Subject(); +const selection$ = new Subject(); +const geomClick$ = selection$.pipe( + op.withLatestFrom(click$), + op.filter(([values, _]) => { + if (Object.keys(values).length > 0) { + return true + } + return false + }) +); +function getFieldType(field: Field): 'quantitative' | 'nominal' | 'ordinal' | 'temporal' { + if (field.type === 'M') return 'quantitative'; + return 'nominal'; +} + +function getSingleView(xField: IViewField, yField: IViewField, color: IViewField, opacity: IViewField, size: IViewField, row: IViewField, col: IViewField, defaultAggregated: boolean, geomType: string) { + return { + mark: geomType, + encoding: { + x: { + field: xField.id, + type: getFieldType(xField), + aggregate: + xField.type === 'M' && + defaultAggregated && + (xField.aggName as any), + }, + y: { + field: yField.id, + type: getFieldType(yField), + aggregate: + yField.type === 'M' && + defaultAggregated && + (yField.aggName as any), + }, + row: row !== NULL_FIELD ? { + field: row.id, + type: getFieldType(row), + } : undefined, + column: col !== NULL_FIELD ? { + field: col.id, + type: getFieldType(col), + } : undefined, + color: color !== NULL_FIELD ? { + field: color.id, + type: getFieldType(color) + } : undefined, + opacity: opacity !== NULL_FIELD ? { + field: opacity.id, + type: getFieldType(opacity) + } : undefined, + size: size !== NULL_FIELD ? { + field: size.id, + type: getFieldType(size) + } : undefined + } + }; +} +const ReactVega: React.FC = props => { + const { + dataSource = [], + rows = [], + columns = [], + defaultAggregate = true, + geomType, + color, + opacity, + size, + onGeomClick + } = props; + const container = useRef(null); + useEffect(() => { + const clickSub = geomClick$.subscribe(([values, e]) => { + if (onGeomClick) { + onGeomClick(values, e); + } + }) + return () => { + clickSub.unsubscribe(); + } + }, []); + useEffect(() => { + if (container.current) { + const rowDims = rows.filter(f => f.type === 'D'); + const colDims = columns.filter(f => f.type === 'D'); + const rowMeas = rows.filter(f => f.type === 'M'); + const colMeas = columns.filter(f => f.type === 'M'); + + const yField = rows.length > 0 ? rows[rows.length - 1] : NULL_FIELD; + const xField = columns.length > 0 ? columns[columns.length - 1] : NULL_FIELD; + + const rowFacetFields = rowDims.slice(0, -1); + const colFacetFields = colDims.slice(0, -1); + const rowFacetField = rowFacetFields.length > 0 ? rowFacetFields[rowFacetFields.length - 1] : NULL_FIELD; + const colFacetField = colFacetFields.length > 0 ? colFacetFields[colFacetFields.length - 1] : NULL_FIELD; + + const rowRepeatFields = rowMeas.length === 0 ? rowDims.slice(-1) : rowMeas;//rowMeas.slice(0, -1); + const colRepeatFields = colMeas.length === 0 ? colDims.slice(-1) : colMeas;//colMeas.slice(0, -1); + + const rowRepeatField = rowRepeatFields.length > 0 ? rowRepeatFields[rowRepeatFields.length - 1] : NULL_FIELD; + const colRepeatField = colRepeatFields.length > 0 ? colRepeatFields[colRepeatFields.length - 1] : NULL_FIELD; + + const dimensions = [...rows, ...columns, color, opacity, size].filter(f => Boolean(f)).map(f => (f as Field).id) + + const spec: any = { + data: { + values: dataSource, + }, + selection: { + [SELECTION_NAME]: { + type: 'single', + fields: dimensions + } + } + }; + if (false) { + // const singleView = getSingleView( + // xField, + // yField, + // color ? color : NULL_FIELD, + // opacity ? opacity : NULL_FIELD, + // size ? size : NULL_FIELD, + // rowFacetField, + // colFacetField, + // defaultAggregate, + // geomType + // ); + // spec.mark = singleView.mark; + // spec.encoding = singleView.encoding; + } else { + spec.concat = []; + console.log('latest', rowRepeatFields, colRepeatFields, rowDims, colDims.slice(-1)) + for (let i = 0; i < rowRepeatFields.length; i++) { + for (let j = 0; j < colRepeatFields.length; j++) { + const singleView = getSingleView( + colRepeatFields[j] || NULL_FIELD, + rowRepeatFields[i] || NULL_FIELD, + color ? color : NULL_FIELD, + opacity ? opacity : NULL_FIELD, + size ? size : NULL_FIELD, + rowFacetField, + colFacetField, + defaultAggregate, + geomType + ); + spec.concat.push(singleView) + } + } + } + console.log(spec) + embed(container.current, spec, { mode: 'vega-lite', actions: false }).then(res => { + res.view.addEventListener('click', (e) => { + click$.next(e); + }) + res.view.addSignalListener(SELECTION_NAME, (name: any, values: any) => { + selection$.next(values); + }); + }); + } + }, [dataSource, rows, columns, defaultAggregate, geomType, color, opacity, size]); + return
+} + +export default ReactVega; diff --git a/packages/graphic-walker/src/vis/react-vega.tsx b/packages/graphic-walker/src/vis/react-vega.tsx index 190dc146..db5b1775 100644 --- a/packages/graphic-walker/src/vis/react-vega.tsx +++ b/packages/graphic-walker/src/vis/react-vega.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useRef } from 'react'; -import { Field, Record } from '../interfaces'; +import React, { useEffect, useState, useMemo } from 'react'; +import { Field, IViewField, Record } from '../interfaces'; import embed from 'vega-embed'; import { Subject } from 'rxjs' import * as op from 'rxjs/operators'; import { ScenegraphEvent } from 'vega'; + const SELECTION_NAME = 'geom'; interface ReactVegaProps { rows: Field[]; @@ -33,10 +34,54 @@ const geomClick$ = selection$.pipe( return false }) ); -function getFieldType (field: Field): 'quantitative' | 'nominal' | 'ordinal' | 'temporal' { +function getFieldType(field: Field): 'quantitative' | 'nominal' | 'ordinal' | 'temporal' { if (field.type === 'M') return 'quantitative'; return 'nominal'; } + +function getSingleView(xField: IViewField, yField: IViewField, color: IViewField, opacity: IViewField, size: IViewField, row: IViewField, col: IViewField, defaultAggregated: boolean, geomType: string) { + return { + mark: geomType, + encoding: { + x: { + field: xField.id, + type: getFieldType(xField), + aggregate: + xField.type === 'M' && + defaultAggregated && + (xField.aggName as any), + }, + y: { + field: yField.id, + type: getFieldType(yField), + aggregate: + yField.type === 'M' && + defaultAggregated && + (yField.aggName as any), + }, + row: row !== NULL_FIELD ? { + field: row.id, + type: getFieldType(row), + } : undefined, + column: col !== NULL_FIELD ? { + field: col.id, + type: getFieldType(col), + } : undefined, + color: color !== NULL_FIELD ? { + field: color.id, + type: getFieldType(color) + } : undefined, + opacity: opacity !== NULL_FIELD ? { + field: opacity.id, + type: getFieldType(opacity) + } : undefined, + size: size !== NULL_FIELD ? { + field: size.id, + type: getFieldType(size) + } : undefined + } + }; +} const ReactVega: React.FC = props => { const { dataSource = [], @@ -49,7 +94,9 @@ const ReactVega: React.FC = props => { size, onGeomClick } = props; - const container = useRef(null); + // const container = useRef(null); + // const containers = useRef<(HTMLDivElement | null)[]>([]); + const [viewPlaceholders, setViewPlaceholders] = useState[]>([]); useEffect(() => { const clickSub = geomClick$.subscribe(([values, e]) => { if (onGeomClick) { @@ -60,73 +107,121 @@ const ReactVega: React.FC = props => { clickSub.unsubscribe(); } }, []); + const rowDims = useMemo(() => rows.filter(f => f.type === 'D'), [rows]); + const colDims = useMemo(() => columns.filter(f => f.type === 'D'), [columns]); + const rowMeas = useMemo(() => rows.filter(f => f.type === 'M'), [rows]); + const colMeas = useMemo(() => columns.filter(f => f.type === 'M'), [columns]); + const rowFacetFields = useMemo(() => rowDims.slice(0, -1), [rowDims]); + const colFacetFields = useMemo(() => colDims.slice(0, -1), [colDims]); + const rowRepeatFields = useMemo(() => rowMeas.length === 0 ? rowDims.slice(-1) : rowMeas, [rowDims, rowMeas]);//rowMeas.slice(0, -1); + const colRepeatFields = useMemo(() => colMeas.length === 0 ? colDims.slice(-1) : colMeas, [rowDims, rowMeas]);//colMeas.slice(0, -1); + const allFieldIds = useMemo(() => [...rows, ...columns, color, opacity, size].filter(f => Boolean(f)).map(f => (f as Field).id), [rows, columns, color, opacity, size]); + + useEffect(() => { - if (container.current) { - const yField = rows.length > 0 ? rows[rows.length - 1] : NULL_FIELD; - const xField = columns.length > 0 ? columns[columns.length - 1] : NULL_FIELD; - const rowField = rows.length > 1 ? rows[rows.length - 2] : NULL_FIELD; - const columnField = columns.length > 1 ? columns[columns.length - 2] : NULL_FIELD; - const dimensions = [...rows, ...columns, color, opacity, size].filter(f => Boolean(f)).map(f => (f as Field).id) - embed(container.current, { - data: { - values: dataSource, - }, - mark: geomType as any, - selection: { - [SELECTION_NAME]: { - type: 'single', - fields: dimensions - } - }, - encoding: { - x: { - field: xField.id, - type: getFieldType(xField), - aggregate: - xField.type === 'M' && - defaultAggregate && - (xField.aggName as any), - }, - y: { - field: yField.id, - type: getFieldType(yField), - aggregate: - yField.type === 'M' && - defaultAggregate && - (yField.aggName as any), - }, - row: rowField !== NULL_FIELD ? { - field: rowField.id, - type: getFieldType(rowField), - } : undefined, - column: columnField !== NULL_FIELD ? { - field: columnField.id, - type: getFieldType(columnField), - } : undefined, - color: color && { - field: color.id, - type: getFieldType(color) - }, - opacity: opacity && { - field: opacity.id, - type: getFieldType(opacity) - }, - size: size && { - field: size.id, - type: getFieldType(size) - } - }, - }, { mode: 'vega-lite', actions: false }).then(res => { - res.view.addEventListener('click', (e) => { - click$.next(e); - }) - res.view.addSignalListener(SELECTION_NAME, (name: any, values: any) => { - selection$.next(values); + setViewPlaceholders(views => { + const viewNum = Math.max(1, rowRepeatFields.length * colRepeatFields.length) + const nextViews = new Array(viewNum).fill(null).map((v, i) => views[i] || React.createRef()) + return nextViews; + }) + }, [rowRepeatFields, colRepeatFields]) + + useEffect(() => { + + const yField = rows.length > 0 ? rows[rows.length - 1] : NULL_FIELD; + const xField = columns.length > 0 ? columns[columns.length - 1] : NULL_FIELD; + + const rowFacetField = rowFacetFields.length > 0 ? rowFacetFields[rowFacetFields.length - 1] : NULL_FIELD; + const colFacetField = colFacetFields.length > 0 ? colFacetFields[colFacetFields.length - 1] : NULL_FIELD; + + const spec: any = { + data: { + values: dataSource, + }, + selection: { + [SELECTION_NAME]: { + type: 'single', + fields: allFieldIds + } + } + }; + if (rowRepeatFields.length <= 1 && colRepeatFields.length <= 1) { + const singleView = getSingleView( + xField, + yField, + color ? color : NULL_FIELD, + opacity ? opacity : NULL_FIELD, + size ? size : NULL_FIELD, + rowFacetField, + colFacetField, + defaultAggregate, + geomType + ); + spec.mark = singleView.mark; + spec.encoding = singleView.encoding; + if (viewPlaceholders.length > 0 && viewPlaceholders[0].current) { + embed(viewPlaceholders[0].current, spec, { mode: 'vega-lite', actions: false }).then(res => { + res.view.addEventListener('click', (e) => { + click$.next(e); + }) + res.view.addSignalListener(SELECTION_NAME, (name: any, values: any) => { + selection$.next(values); + }); }); - }); + } + } else { + console.log('latest', rowRepeatFields, colRepeatFields, rowDims, colDims.slice(-1)) + for (let i = 0; i < rowRepeatFields.length; i++) { + for (let j = 0; j < colRepeatFields.length; j++) { + const singleView = getSingleView( + colRepeatFields[j] || NULL_FIELD, + rowRepeatFields[i] || NULL_FIELD, + color ? color : NULL_FIELD, + opacity ? opacity : NULL_FIELD, + size ? size : NULL_FIELD, + rowFacetField, + colFacetField, + defaultAggregate, + geomType + ); + const node = i * colRepeatFields.length + j < viewPlaceholders.length ? viewPlaceholders[i * colRepeatFields.length + j].current : null + const ans = { ...spec, ...singleView } + if (node) { + embed(node, ans, { mode: 'vega-lite', actions: false }).then(res => { + res.view.addEventListener('click', (e) => { + click$.next(e); + }) + res.view.addSignalListener(SELECTION_NAME, (name: any, values: any) => { + selection$.next(values); + }); + }) + } + } + } + } + + }, [ + dataSource, + allFieldIds, + rows, + columns, + defaultAggregate, + geomType, + color, + opacity, + size, + viewPlaceholders, + rowFacetFields, + colFacetFields, + rowRepeatFields, + colRepeatFields + ]); + return
+ {/*
*/} + { + viewPlaceholders.map((view, i) =>
) } - }, [dataSource, rows, columns, defaultAggregate, geomType, color, opacity, size]); - return
+
} export default ReactVega; diff --git a/yarn.lock b/yarn.lock index e357df54..f2a58dc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11549,6 +11549,13 @@ rxjs@6.x, rxjs@^6.5.2, rxjs@^6.5.4: dependencies: tslib "^1.9.0" +rxjs@^7.3.0: + version "7.3.0" + resolved "https://registry.nlark.com/rxjs/download/rxjs-7.3.0.tgz?cache=0&sync_timestamp=1627507111626&other_urls=https%3A%2F%2Fregistry.nlark.com%2Frxjs%2Fdownload%2Frxjs-7.3.0.tgz#39fe4f3461dc1e50be1475b2b85a0a88c1e938c6" + integrity sha1-Of5PNGHcHlC+FHWyuFoKiMHpOMY= + dependencies: + tslib "~2.1.0" + safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.nlark.com/safe-buffer/download/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -12798,6 +12805,11 @@ tslib@~2.0.3: resolved "https://registry.nlark.com/tslib/download/tslib-2.0.3.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.nlark.com%2Ftslib%2Fdownload%2Ftslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" integrity sha1-jgdBrEX8DCJuWKF7/D5kubxsphw= +tslib@~2.1.0: + version "2.1.0" + resolved "https://registry.nlark.com/tslib/download/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" + integrity sha1-2mCGDxwuyqVwOrfTm8Bba/mIuXo= + tslib@~2.2.0: version "2.2.0" resolved "https://registry.nlark.com/tslib/download/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"