diff --git a/packages/graphic-walker/README.md b/packages/graphic-walker/README.md index b7ded9ea..bafe445d 100644 --- a/packages/graphic-walker/README.md +++ b/packages/graphic-walker/README.md @@ -1,7 +1,7 @@ # Graphic Walker -Graphic Walker is a lite tableau style visual analysis interface. It is used for cases when users have specific analytic target or user want to analysis further result based on the recommanded results by Rath's auto insights. +Graphic Walker is a lite tableau style visual analysis interface. It is used for cases when users have specific analytic target or user want to analysis further result based on the recommended results by Rath's auto insights. -** You can also use Graphic Walker as a lite tableau style analysis app independently. It can be used as an independent app or an embeding module. ** +** You can also use Graphic Walker as a lite tableau style analysis app independently. It can be used as an independent app or an embedding module. ** Main features: @@ -45,7 +45,7 @@ npm run dev ## I18n Support -Graphic Walker now support _English_ (as `"en"` or `"en-US"`) and _Chinese_ (as `"zh"` or `"zh-CN"`) with built-in locale resources. You can simply provide a valid string value (enumerated above) as `props.i18nLang` to set a language or synchronize your global i18n language with the component like the example given as follow. +GraphicWalker now support _English_ (as `"en"` or `"en-US"`) and _Chinese_ (as `"zh"` or `"zh-CN"`) with built-in locale resources. You can simply provide a valid string value (enumerated above) as `props.i18nLang` to set a language or synchronize your global i18n language with the component like the example given as follow. ```typescript const YourApp = props => { @@ -63,14 +63,15 @@ const YourApp = props => { ### Customize I18n -If you need i18n support to cover languages not supported currently, or to totally rewrite the content of any built-in resource(s), you can also provide your resource(s) as `props.i18nResources` to Graphic Walker like this. +If you need i18n support to cover languages not supported currently, or to totally rewrite the content of any built-in resource(s), you can also provide your resource(s) as `props.i18nResources` to GraphicWalker like this. ```typescript const yourResources = { 'de-DE': { + 'key': 'value', ... }, - 'fr-FE': { + 'fr-FR': { ... }, }; @@ -88,3 +89,5 @@ const YourApp = props => { /> } ``` + +GraphicWalker uses `react-i18next` to support i18n, which is based on `i18next`, so your translation resources should follow [this format](https://www.i18next.com/misc/json-format). You can simply fork and edit `/locales/en-US.json` to start your translation. diff --git a/packages/graphic-walker/package.json b/packages/graphic-walker/package.json index 25873440..a3a37df0 100644 --- a/packages/graphic-walker/package.json +++ b/packages/graphic-walker/package.json @@ -31,6 +31,7 @@ "autoprefixer": "^10.3.5", "i18next": "^21.9.1", "i18next-browser-languagedetector": "^6.1.5", + "immer": "^9.0.15", "mobx": "^6.3.3", "mobx-react-lite": "^3.2.1", "postcss": "^8.3.7", diff --git a/packages/graphic-walker/src/App.tsx b/packages/graphic-walker/src/App.tsx index 5caef497..88c12473 100644 --- a/packages/graphic-walker/src/App.tsx +++ b/packages/graphic-walker/src/App.tsx @@ -21,6 +21,7 @@ import { Specification } from 'visual-insights'; import VisNav from './segments/visNav'; import { useTranslation } from 'react-i18next'; import { mergeLocaleRes, setLocaleLanguage } from './locales/i18n'; +import Menubar from './visualSettings/menubar'; export interface EditorProps { @@ -95,6 +96,7 @@ const App: React.FC = props => { {/* {}} /> */} +
diff --git a/packages/graphic-walker/src/insightBoard/mainBoard.tsx b/packages/graphic-walker/src/insightBoard/mainBoard.tsx index 06fa95b8..bb6d84ff 100644 --- a/packages/graphic-walker/src/insightBoard/mainBoard.tsx +++ b/packages/graphic-walker/src/insightBoard/mainBoard.tsx @@ -26,7 +26,7 @@ interface SubSpace { interface InsightMainBoardProps { dataSource: IRow[]; - fields: IField[]; + fields: Readonly; filters?: Filters; viewDs: IField[]; viewMs: IField[]; diff --git a/packages/graphic-walker/src/insightBoard/std2vegaSpec.ts b/packages/graphic-walker/src/insightBoard/std2vegaSpec.ts index 0f63d1f5..061030b0 100644 --- a/packages/graphic-walker/src/insightBoard/std2vegaSpec.ts +++ b/packages/graphic-walker/src/insightBoard/std2vegaSpec.ts @@ -17,7 +17,7 @@ export function baseVis( measures: string[], predicates: IPredicate[] | null, aggregatedMeasures: Array<{ op: string; field: string; as: string }>, - fields: IField[], + fields: Readonly, type: IReasonType, defaultAggregated?: boolean, defaultStack?: boolean diff --git a/packages/graphic-walker/src/insightBoard/utils.ts b/packages/graphic-walker/src/insightBoard/utils.ts index 70230b5a..abd3ad8f 100644 --- a/packages/graphic-walker/src/insightBoard/utils.ts +++ b/packages/graphic-walker/src/insightBoard/utils.ts @@ -26,7 +26,7 @@ export function mergeMeasures(measures1: IMeasure[], measures2: IMeasure[]): IMe return merged; } -export function formatFieldName(fid: string, fields: IField[]) { +export function formatFieldName(fid: string, fields: Readonly) { const target = fields.find(f => f.fid === fid); return target ? target.name : fid; } \ No newline at end of file diff --git a/packages/graphic-walker/src/locales/en-US.json b/packages/graphic-walker/src/locales/en-US.json index 6c577f08..d72af9d2 100644 --- a/packages/graphic-walker/src/locales/en-US.json +++ b/packages/graphic-walker/src/locales/en-US.json @@ -69,6 +69,10 @@ "autoTitle": "Chart {{idx}}" }, "tabpanel": { + "menubar": { + "undo": "Undo", + "redo": "Redo" + }, "settings": { "toggle": { "aggregation": "Enable Aggregation", diff --git a/packages/graphic-walker/src/locales/zh-CN.json b/packages/graphic-walker/src/locales/zh-CN.json index c55946ef..a473ee19 100644 --- a/packages/graphic-walker/src/locales/zh-CN.json +++ b/packages/graphic-walker/src/locales/zh-CN.json @@ -69,6 +69,10 @@ "autoTitle": "图表 {{idx}}" }, "tabpanel": { + "menubar": { + "undo": "撤销", + "redo": "重做" + }, "settings": { "toggle": { "aggregation": "聚合度量", diff --git a/packages/graphic-walker/src/segments/visNav.tsx b/packages/graphic-walker/src/segments/visNav.tsx index 5c3549f2..1168478a 100644 --- a/packages/graphic-walker/src/segments/visNav.tsx +++ b/packages/graphic-walker/src/segments/visNav.tsx @@ -24,11 +24,9 @@ const VisNav: React.FC = (props) => { const visSelectionHandler = useCallback((tabKey: string, tabIndex: number) => { if (tabKey === ADD_KEY) { - vizStore.saveVisChange(); vizStore.addVisualization(); vizStore.initMetaState(currentDataset) } else { - vizStore.saveVisChange(); vizStore.selectVisualization(tabIndex); } }, [currentDataset, vizStore]) diff --git a/packages/graphic-walker/src/store/visualSpecStore.ts b/packages/graphic-walker/src/store/visualSpecStore.ts index df26eaf5..791c8d4d 100644 --- a/packages/graphic-walker/src/store/visualSpecStore.ts +++ b/packages/graphic-walker/src/store/visualSpecStore.ts @@ -5,6 +5,8 @@ import { v4 as uuidv4 } from 'uuid'; import { Specification } from "visual-insights"; import { GEMO_TYPES } from "../config"; import { makeBinField, makeLogField } from "../utils/normalization"; +import produce from 'immer'; + interface IVisualConfig { defaultAggregated: boolean; @@ -103,38 +105,266 @@ function initVisualConfig (): IVisualConfig { } } +type DeepReadonly> = { + readonly [K in keyof T]: T[K] extends Record ? DeepReadonly : T[K]; +}; + interface IVisSpec { - name?: [string, Record?]; - visId: string; - encodings: DraggableFieldState; - config: IVisualConfig; + readonly visId: string; + readonly name?: [string, Record?]; + readonly encodings: DeepReadonly; + readonly config: DeepReadonly; } + +const MAX_HISTORY_SIZE = 20; + +class IVisSpecWithHistory { + + readonly visId: IVisSpec['visId']; + private snapshots: Pick[]; + private cursor: number; + + constructor(data: IVisSpec) { + this.visId = data.visId; + this.snapshots = [{ + name: data.name, + encodings: data.encodings, + config: data.config, + }]; + this.cursor = 0; + } + + private get frame(): Readonly { + return { + visId: this.visId, + ...this.snapshots[this.cursor]!, + }; + } + + private batchFlag = false; + + private commit(snapshot: Partial>): void { + if (this.batchFlag) { + // batch this commit + this.snapshots[this.cursor] = toJS({ + ...this.frame, + ...snapshot, + }); + + return; + } + + this.batchFlag = true; + + this.snapshots = [ + ...this.snapshots.slice(0, this.cursor + 1), + toJS({ + ...this.frame, + ...snapshot, + }), + ]; + + if (this.snapshots.length > MAX_HISTORY_SIZE) { + this.snapshots.splice(0, 1); + } + + this.cursor = this.snapshots.length - 1; + + requestAnimationFrame(() => this.batchFlag = false); + } + + public get canUndo() { + return this.cursor > 0; + } + + public undo(): boolean { + if (this.cursor === 0) { + return false; + } + + this.cursor -= 1; + + return true; + } + + public get canRedo() { + return this.cursor < this.snapshots.length - 1; + } + + public redo(): boolean { + if (this.cursor === this.snapshots.length - 1) { + return false; + } + + this.cursor += 1; + + return true; + } + + public rebase() { + this.snapshots = [this.snapshots[this.cursor]]; + this.cursor = 0; + } + + get name() { + return this.frame.name; + } + + set name(name: IVisSpec['name']) { + this.commit({ + name, + }); + } + + get encodings(): DeepReadonly { + return this.frame.encodings; + } + + set encodings(encodings: IVisSpec['encodings']) { + this.commit({ + encodings, + }); + } + + get config(): DeepReadonly { + return this.frame.config; + } + + set config(config: IVisSpec['config']) { + this.commit({ + config, + }); + } + +} + export class VizSpecStore { // public fields: IViewField[] = []; private commonStore: CommonStore; - public draggableFieldState: DraggableFieldState; + /** + * This segment will always refers to the state of the active tab - + * `this.visList[this.visIndex].encodings`. + * Notice that observing rule of `this.visList` is `"shallow"` + * so mobx will NOT compare every deep value of `this.visList`, + * because the active tab is the only item in the list that may change. + * @readonly + * Assignment or mutable operations applied to ANY members of this segment + * is strictly FORBIDDEN. + * Members of it can only be got as READONLY objects. + * + * If you're trying to change the value of it and let mobx catch the action to trigger an update, + * please use `this.useMutable()` to access to a writable reference + * (an `immer` draft) of `this.visList[this.visIndex]`. + */ + public readonly draggableFieldState: DeepReadonly; private reactions: IReactionDisposer[] = [] - public visualConfig: IVisualConfig; - public visList: IVisSpec[] = []; + /** + * This segment will always refers to the state of the active tab - + * `this.visList[this.visIndex].config`. + * Notice that observing rule of `this.visList` is `"shallow"` + * so mobx will NOT compare every deep value of `this.visList`, + * because the active tab is the only item in the list that may change. + * @readonly + * Assignment or mutable operations applied to ANY members of this segment + * is strictly FORBIDDEN. + * Members of it can only be got as READONLY objects. + * + * If you're trying to change the value of it and let mobx catch the action to trigger an update, + * please use `this.useMutable()` to access to a writable reference + * (an `immer` draft) of `this.visList[this.visIndex]`. + */ + public readonly visualConfig: Readonly; + public visList: IVisSpecWithHistory[] = []; public visIndex: number = 0; + public canUndo = false; + public canRedo = false; constructor (commonStore: CommonStore) { this.commonStore = commonStore; this.draggableFieldState = initEncoding(); this.visualConfig = initVisualConfig(); - this.visList.push({ + this.visList.push(new IVisSpecWithHistory({ name: ['main.tablist.autoTitle', { idx: 1 }], visId: uuidv4(), - config: initVisualConfig(), - encodings: initEncoding() - }) + config: this.visualConfig, + encodings: this.draggableFieldState, + })); makeAutoObservable(this, { - visList: observable.shallow + visList: observable.shallow, }); // FIXME!!!!! - this.reactions.push(reaction(() => commonStore.currentDataset, (dataset) => { - this.initState(); - this.initMetaState(dataset); - })) + this.reactions.push( + reaction(() => commonStore.currentDataset, (dataset) => { + this.initState(); + this.initMetaState(dataset); + }), + reaction(() => this.visList[this.visIndex], frame => { + // @ts-ignore Allow assignment here to trigger watch + this.draggableFieldState = frame.encodings; + // @ts-ignore Allow assignment here to trigger watch + this.visualConfig = frame.config; + this.canUndo = frame.canUndo; + this.canRedo = frame.canRedo; + }), + ); + } + /** + * Allow to change any deep member of `encodings` or `config` + * in the active tab `this.visList[this.visIndex]`. + * + * - `tab.encodings` + * + * A mutable reference of `this.draggableFieldState` + * + * - `tab.config` + * + * A mutable reference of `this.visualConfig` + */ + private useMutable( + cb: (tab: { + encodings: DraggableFieldState; + config: IVisualConfig; + }) => void, + ) { + const { encodings, config } = produce({ + encodings: this.visList[this.visIndex].encodings, + config: this.visList[this.visIndex].config, + }, draft => { cb(draft) }); // notice that cb() may unexpectedly returns a non-nullable value + + this.visList[this.visIndex].encodings = encodings; + this.visList[this.visIndex].config = config; + + this.canUndo = this.visList[this.visIndex].canUndo; + this.canRedo = this.visList[this.visIndex].canRedo; + + // @ts-ignore Allow assignment here to trigger watch + this.visualConfig = config; + // @ts-ignore Allow assignment here to trigger watch + this.draggableFieldState = encodings; + } + public undo() { + if (this.visList[this.visIndex]?.undo()) { + this.canUndo = this.visList[this.visIndex].canUndo; + this.canRedo = this.visList[this.visIndex].canRedo; + // @ts-ignore Allow assignment here to trigger watch + this.visualConfig = this.visList[this.visIndex].config; + // @ts-ignore Allow assignment here to trigger watch + this.draggableFieldState = this.visList[this.visIndex].encodings; + } + } + public redo() { + if (this.visList[this.visIndex]?.redo()) { + this.canUndo = this.visList[this.visIndex].canUndo; + this.canRedo = this.visList[this.visIndex].canRedo; + // @ts-ignore Allow assignment here to trigger watch + this.visualConfig = this.visList[this.visIndex].config; + // @ts-ignore Allow assignment here to trigger watch + this.draggableFieldState = this.visList[this.visIndex].encodings; + } + } + private freezeHistory() { + this.visList[this.visIndex]?.rebase(); + this.canUndo = this.visList[this.visIndex].canUndo; + this.canRedo = this.visList[this.visIndex].canRedo; } /** * dimension fields in visualization @@ -165,65 +395,60 @@ export class VizSpecStore { return fields; } public addVisualization () { - this.visList.push({ + this.visList.push(new IVisSpecWithHistory({ name: ['main.tablist.autoTitle', { idx: this.visList.length + 1 }], visId: uuidv4(), config: initVisualConfig(), encodings: initEncoding() - }) + })); this.visIndex = this.visList.length - 1; - this.draggableFieldState = this.visList[this.visIndex].encodings; - this.visualConfig = this.visList[this.visIndex].config; } public selectVisualization (visIndex: number) { this.visIndex = visIndex; - this.draggableFieldState = this.visList[visIndex].encodings; - this.visualConfig = this.visList[visIndex].config } public setVisName (visIndex: number, name: string) { - this.visList[visIndex] = { - ...this.visList[visIndex], - name: [name], - }; - } - /** - * FIXME: tmp - */ - public saveVisChange () { - this.visList[this.visIndex].config = toJS(this.visualConfig); - this.visList[this.visIndex].encodings = toJS(this.draggableFieldState); + this.useMutable(() => { + this.visList[visIndex].name = [name]; + }); } public initState () { - this.draggableFieldState = initEncoding(); + this.useMutable(tab => { + tab.encodings = initEncoding(); + this.freezeHistory(); + }); } public initMetaState (dataset: DataSet) { - this.draggableFieldState.fields = dataset.rawFields.map((f) => ({ - dragId: uuidv4(), - fid: f.fid, - name: f.name || f.fid, - aggName: f.analyticType === 'measure' ? 'sum' : undefined, - analyticType: f.analyticType, - semanticType: f.semanticType - })) - this.draggableFieldState.dimensions = dataset.rawFields - .filter(f => f.analyticType === 'dimension') - .map((f) => ({ + this.useMutable(({ encodings }) => { + encodings.fields = dataset.rawFields.map((f) => ({ dragId: uuidv4(), fid: f.fid, name: f.name || f.fid, - semanticType: f.semanticType, + aggName: f.analyticType === 'measure' ? 'sum' : undefined, analyticType: f.analyticType, - })) - this.draggableFieldState.measures = dataset.rawFields - .filter(f => f.analyticType === 'measure') - .map((f) => ({ - dragId: uuidv4(), - fid: f.fid, - name: f.name || f.fid, - analyticType: f.analyticType, - semanticType: f.semanticType, - aggName: 'sum' - })) + semanticType: f.semanticType + })); + encodings.dimensions = dataset.rawFields + .filter(f => f.analyticType === 'dimension') + .map((f) => ({ + dragId: uuidv4(), + fid: f.fid, + name: f.name || f.fid, + semanticType: f.semanticType, + analyticType: f.analyticType, + })); + encodings.measures = dataset.rawFields + .filter(f => f.analyticType === 'measure') + .map((f) => ({ + dragId: uuidv4(), + fid: f.fid, + name: f.name || f.fid, + analyticType: f.analyticType, + semanticType: f.semanticType, + aggName: 'sum' + })); + }); + + this.freezeHistory(); // this.draggableFieldState.measures.push({ // dragId: uuidv4(), // fid: COUNT_FIELD_ID, @@ -234,25 +459,31 @@ export class VizSpecStore { // }) } public clearState () { - for (let key in this.draggableFieldState) { - if (!MetaFieldKeys.includes(key as keyof DraggableFieldState)) { - this.draggableFieldState[key] = [] + this.useMutable(({ encodings }) => { + for (let key in encodings) { + if (!MetaFieldKeys.includes(key as keyof DraggableFieldState)) { + encodings[key] = []; + } } - } + }); } - public setVisualConfig (configKey: keyof IVisualConfig, value: any) { - // this.visualConfig[configKey] = //value; - if (configKey === 'defaultAggregated' || configKey === 'defaultStack' || configKey === 'showActions' || configKey === 'interactiveScale') { - this.visualConfig[configKey] = Boolean(value); - } else if (configKey === 'geoms' && value instanceof Array) { - this.visualConfig[configKey] = value; - } else if (configKey === 'size' && value instanceof Object) { - this.visualConfig[configKey] = value; - } else if (configKey === 'sorted') { - this.visualConfig[configKey] = value; - } else { - console.error('unknow key' + configKey) - } + public setVisualConfig(configKey: K, value: IVisualConfig[K]) { + this.useMutable(({ config }) => { + switch (true) { + case ['defaultAggregated', 'defaultStack', 'showActions', 'interactiveScale'].includes(configKey): { + return (config as unknown as {[k: string]: boolean})[configKey] = Boolean(value); + } + case configKey === 'geoms' && Array.isArray(value): + case configKey === 'size' && typeof value === 'object': + case configKey === 'sorted': + { + return config[configKey] = value; + } + default: { + console.error('unknown key' + configKey); + } + } + }); } public transformCoord (coord: 'cartesian' | 'polar') { if (coord === 'polar') { @@ -260,81 +491,103 @@ export class VizSpecStore { } } public setChartLayout(props: {mode: IVisualConfig['size']['mode'], width?: number, height?: number }) { - const { - mode = this.visualConfig.size.mode, - width = this.visualConfig.size.width, - height = this.visualConfig.size.height - } = props - this.visualConfig.size.mode = mode; - this.visualConfig.size.width = width; - this.visualConfig.size.height = height; + this.useMutable(({ config }) => { + const { + mode = config.size.mode, + width = config.size.width, + height = config.size.height + } = props; + + config.size.mode = mode; + config.size.width = width; + config.size.height = height; + }); } public reorderField(stateKey: keyof DraggableFieldState, sourceIndex: number, destinationIndex: number) { if (MetaFieldKeys.includes(stateKey)) return; if (sourceIndex === destinationIndex) return; - const fields = this.draggableFieldState[stateKey]; - const [field] = fields.splice(sourceIndex, 1); - fields.splice(destinationIndex, 0, field); + + this.useMutable(({ encodings }) => { + const fields = encodings[stateKey]; + const [field] = fields.splice(sourceIndex, 1); + fields.splice(destinationIndex, 0, field); + }); } public moveField(sourceKey: keyof DraggableFieldState, sourceIndex: number, destinationKey: keyof DraggableFieldState, destinationIndex: number) { - let movingField: IViewField; - // 来源是不是metafield,是->clone;不是->直接删掉 - if (MetaFieldKeys.includes(sourceKey)) { - // use toJS for cloning - movingField = toJS(this.draggableFieldState[sourceKey][sourceIndex]) - movingField.dragId = uuidv4(); - } else { - [movingField] = this.draggableFieldState[sourceKey].splice(sourceIndex, 1); - } - // 目的地是metafields的情况,只有在来源也是metafields时,会执行字段类型转化操作 - if (MetaFieldKeys.includes(destinationKey)) { - if (!MetaFieldKeys.includes(sourceKey))return; - this.draggableFieldState[sourceKey].splice(sourceIndex, 1); - movingField.analyticType = destinationKey === 'dimensions' ? 'dimension' : 'measure'; - } - const limitSize = getChannelSizeLimit(destinationKey); - const fixedDestinationIndex = Math.min(destinationIndex, limitSize - 1); - const overflowSize = Math.max(0, this.draggableFieldState[destinationKey].length + 1 - limitSize); - this.draggableFieldState[destinationKey].splice(fixedDestinationIndex, overflowSize, movingField) + this.useMutable(({ encodings }) => { + let movingField: IViewField; + // 来源是不是metafield,是->clone;不是->直接删掉 + if (MetaFieldKeys.includes(sourceKey)) { + // use toJS for cloning + movingField = toJS(encodings[sourceKey][sourceIndex]) + movingField.dragId = uuidv4(); + } else { + [movingField] = encodings[sourceKey].splice(sourceIndex, 1); + } + // 目的地是metafields的情况,只有在来源也是metafields时,会执行字段类型转化操作 + if (MetaFieldKeys.includes(destinationKey)) { + if (!MetaFieldKeys.includes(sourceKey))return; + encodings[sourceKey].splice(sourceIndex, 1); + movingField.analyticType = destinationKey === 'dimensions' ? 'dimension' : 'measure'; + } + const limitSize = getChannelSizeLimit(destinationKey); + const fixedDestinationIndex = Math.min(destinationIndex, limitSize - 1); + const overflowSize = Math.max(0, encodings[destinationKey].length + 1 - limitSize); + encodings[destinationKey].splice(fixedDestinationIndex, overflowSize, movingField); + }); } public removeField(sourceKey: keyof DraggableFieldState, sourceIndex: number) { if (MetaFieldKeys.includes(sourceKey))return; - this.draggableFieldState[sourceKey].splice(sourceIndex, 1); + + this.useMutable(({ encodings }) => { + const fields = encodings[sourceKey]; + fields.splice(sourceIndex, 1); + }); } public transpose() { - const fieldsInCup = this.draggableFieldState.columns; - this.draggableFieldState.columns = this.draggableFieldState.rows; - this.draggableFieldState.rows = fieldsInCup; + this.useMutable(({ encodings }) => { + const fieldsInCup = encodings.columns; + + encodings.columns = encodings.rows; + encodings.rows = fieldsInCup as typeof encodings.rows; // assume this as writable + }); } public createBinField(stateKey: keyof DraggableFieldState, index: number) { - const originField = this.draggableFieldState[stateKey][index] - const binField: IViewField = { - fid: uuidv4(), - dragId: uuidv4(), - name: `bin(${originField.name})`, - semanticType: 'ordinal', - analyticType: 'dimension', - }; - this.draggableFieldState.dimensions.push(binField); - this.commonStore.currentDataset.dataSource = makeBinField(this.commonStore.currentDataset.dataSource, originField.fid, binField.fid) + this.useMutable(({ encodings }) => { + const originField = encodings[stateKey][index] + const binField: IViewField = { + fid: uuidv4(), + dragId: uuidv4(), + name: `bin(${originField.name})`, + semanticType: 'ordinal', + analyticType: 'dimension', + }; + encodings.dimensions.push(binField); + this.commonStore.currentDataset.dataSource = makeBinField(this.commonStore.currentDataset.dataSource, originField.fid, binField.fid) + }); } public createLogField(stateKey: keyof DraggableFieldState, index: number) { - const originField = this.draggableFieldState[stateKey][index]; - const logField: IViewField = { - fid: uuidv4(), - dragId: uuidv4(), - name: `log10(${originField.name})`, - semanticType: 'quantitative', - analyticType: originField.analyticType - } - this.draggableFieldState[stateKey].push(logField); - this.commonStore.currentDataset.dataSource = makeLogField(this.commonStore.currentDataset.dataSource, originField.fid, logField.fid) + this.useMutable(({ encodings }) => { + const originField = encodings[stateKey][index]; + const logField: IViewField = { + fid: uuidv4(), + dragId: uuidv4(), + name: `log10(${originField.name})`, + semanticType: 'quantitative', + analyticType: originField.analyticType + }; + encodings[stateKey].push(logField); + this.commonStore.currentDataset.dataSource = makeLogField(this.commonStore.currentDataset.dataSource, originField.fid, logField.fid) + }); } public setFieldAggregator (stateKey: keyof DraggableFieldState, index: number, aggName: string) { - const fields = this.draggableFieldState[stateKey] - if (fields[index]) { - fields[index].aggName = aggName; - } + this.useMutable(({ encodings }) => { + const fields = encodings[stateKey]; + + if (fields[index]) { + encodings[stateKey][index].aggName = aggName; + } + }); } public get sortCondition () { const { rows, columns } = this.draggableFieldState; @@ -349,57 +602,65 @@ export class VizSpecStore { return false; } public setFieldSort (stateKey: keyof DraggableFieldState, index: number, sortType: 'none' | 'ascending' | 'descending') { - this.draggableFieldState[stateKey][index].sort = sortType; + this.useMutable(({ encodings }) => { + encodings[stateKey][index].sort = sortType; + }); } public applyDefaultSort(sortType: 'none' | 'ascending' | 'descending' = 'ascending') { - const { rows, columns } = this.draggableFieldState; - const yField = rows.length > 0 ? rows[rows.length - 1] : null; - const xField = columns.length > 0 ? columns[columns.length - 1] : null; - - if (xField !== null && xField.analyticType === 'dimension' && yField !== null && yField.analyticType === 'measure') { - xField.sort = sortType - return - } - if (xField !== null && xField.analyticType === 'measure' && yField !== null && yField.analyticType === 'dimension') { - yField.sort = sortType - return - } + this.useMutable(({ encodings }) => { + const { rows, columns } = encodings; + const yField = rows.length > 0 ? rows[rows.length - 1] : null; + const xField = columns.length > 0 ? columns[columns.length - 1] : null; + + if (xField !== null && xField.analyticType === 'dimension' && yField !== null && yField.analyticType === 'measure') { + encodings.columns[columns.length - 1].sort = sortType; + return; + } + if (xField !== null && xField.analyticType === 'measure' && yField !== null && yField.analyticType === 'dimension') { + encodings.rows[rows.length - 1].sort = sortType; + return; + } + }); } public appendField (destinationKey: keyof DraggableFieldState, field: IViewField | undefined) { if (MetaFieldKeys.includes(destinationKey)) return; if (typeof field === 'undefined') return; - const cloneField = toJS(field); - cloneField.dragId = uuidv4(); - this.draggableFieldState[destinationKey].push(cloneField); + + this.useMutable(({ encodings }) => { + const cloneField = toJS(field); + cloneField.dragId = uuidv4(); + encodings[destinationKey].push(cloneField); + }); } public renderSpec (spec: Specification) { - const fields = this.draggableFieldState.fields; - // thi - // const [xField, yField, ] = spec.position; - this.clearState(); - if (spec.geomType && spec.geomType.length > 0) { - this.setVisualConfig('geoms', spec.geomType.map(g => geomAdapter(g))); - } - if (spec.facets && spec.facets.length > 0) { - const facets = (spec.facets || []).concat(spec.highFacets || []); - for (let facet of facets) { - this.appendField('rows', fields.find(f => f.fid === facet)); + this.useMutable(tab => { + const fields = tab.encodings.fields; + // thi + // const [xField, yField, ] = spec.position; + this.clearState(); + if (spec.geomType && spec.geomType.length > 0) { + this.setVisualConfig('geoms', spec.geomType.map(g => geomAdapter(g))); } - } - if (spec.position) { - if (spec.position.length > 0) this.appendField('columns', fields.find(f => f.fid === spec.position![0])); - if (spec.position.length > 1) this.appendField('rows', fields.find(f => f.fid === spec.position![1])); - } - if (spec.color && spec.color.length > 0) { - this.appendField('color', fields.find(f => f.fid === spec.color![0])); - } - if (spec.size && spec.size.length > 0) { - this.appendField('size', fields.find(f => f.fid === spec.size![0])); - } - if (spec.opacity && spec.opacity.length > 0) { - this.appendField('opacity', fields.find(f => f.fid === spec.opacity![0])); - } - + if (spec.facets && spec.facets.length > 0) { + const facets = (spec.facets || []).concat(spec.highFacets || []); + for (let facet of facets) { + this.appendField('rows', fields.find(f => f.fid === facet)); + } + } + if (spec.position) { + if (spec.position.length > 0) this.appendField('columns', fields.find(f => f.fid === spec.position![0])); + if (spec.position.length > 1) this.appendField('rows', fields.find(f => f.fid === spec.position![1])); + } + if (spec.color && spec.color.length > 0) { + this.appendField('color', fields.find(f => f.fid === spec.color![0])); + } + if (spec.size && spec.size.length > 0) { + this.appendField('size', fields.find(f => f.fid === spec.size![0])); + } + if (spec.opacity && spec.opacity.length > 0) { + this.appendField('opacity', fields.find(f => f.fid === spec.opacity![0])); + } + }); } public destroy () { this.reactions.forEach(rec => { diff --git a/packages/graphic-walker/src/vis/react-vega.tsx b/packages/graphic-walker/src/vis/react-vega.tsx index 99c547b5..640bd978 100644 --- a/packages/graphic-walker/src/vis/react-vega.tsx +++ b/packages/graphic-walker/src/vis/react-vega.tsx @@ -17,8 +17,8 @@ const CanvaContainer = styled.div<{rowSize: number; colSize: number;}>` const SELECTION_NAME = 'geom'; interface ReactVegaProps { - rows: IViewField[]; - columns: IViewField[]; + rows: Readonly; + columns: Readonly; dataSource: IRow[]; defaultAggregate?: boolean; defaultStack?: boolean; diff --git a/packages/graphic-walker/src/visualSettings/menubar.tsx b/packages/graphic-walker/src/visualSettings/menubar.tsx new file mode 100644 index 00000000..e778b827 --- /dev/null +++ b/packages/graphic-walker/src/visualSettings/menubar.tsx @@ -0,0 +1,110 @@ +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { useGlobalStore } from '../store'; +import styled from 'styled-components' +import { ArrowUturnLeftIcon, ArrowUturnRightIcon } from '@heroicons/react/24/solid'; +import { useTranslation } from 'react-i18next'; + + +export const MenubarContainer = styled.div({ + marginBlock: '0 0.6em', + marginInline: '0.2em', +}); + +const Button = styled.button(({ disabled = false }) => ({ + '&:hover': disabled ? {} : { + backgroundColor: 'rgba(243, 244, 246, 0.5)', + }, + color: disabled ? 'rgba(156, 163, 175, 0.5)' : 'rgb(55, 65, 81)', + boxShadow: disabled ? undefined : '1px 1px 2px #0002, inset 2px 2px 4px #0001', + '& > pre': { + display: 'inline-block', + marginInlineStart: '0.2em', + }, + marginInlineStart: '0.6em', + '&:first-child': { + marginInlineStart: '0', + }, + cursor: disabled ? 'default' : 'pointer', +})); + +interface ButtonWithShortcutProps { + label: string; + shortcut: string; + disabled: boolean; + handler: () => void; + icon?: JSX.Element; +} + +const ButtonWithShortcut: React.FC = ({ label, shortcut, disabled, handler, icon }) => { + const { t } = useTranslation('translation', { keyPrefix: 'main.tabpanel.menubar' }); + + const rule = React.useMemo(() => { + const keys = shortcut.split('+').map(d => d.trim()); + + return { + key: keys.filter( + d => /^[a-z]$/i.test(d) + )[0], + ctrlKey: keys.includes('Ctrl'), + shiftKey: keys.includes('Shift'), + altKey: keys.includes('Alt'), + }; + }, [shortcut]); + + React.useEffect(() => { + const cb = (ev: KeyboardEvent) => { + if ( + ev.ctrlKey === rule.ctrlKey + && ev.shiftKey === rule.shiftKey + && ev.altKey === rule.altKey + && ev.key.toLowerCase() === rule.key.toLowerCase() + ) { + handler(); + ev.stopPropagation(); + } + }; + + document.body.addEventListener('keydown', cb); + + return () => document.body.removeEventListener('keydown', cb); + }, [rule, handler]); + + return ( + + ); +}; + +const Menubar: React.FC = () => { + const { vizStore } = useGlobalStore(); + const { canUndo, canRedo } = vizStore; + + return ( + + } + /> + } + /> + + ); +} + +export default observer(Menubar); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 58985257..3944cbcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1302,6 +1302,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2", "@babel/runtime@^7.18.3", "@babel/runtime@^7.18.9": + version "7.19.0" + resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259" + integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/runtime@^7.14.8", "@babel/runtime@^7.8.7": version "7.15.4" resolved "https://registry.nlark.com/@babel/runtime/download/@babel/runtime-7.15.4.tgz?cache=0&sync_timestamp=1630619277795&other_urls=https%3A%2F%2Fregistry.nlark.com%2F%40babel%2Fruntime%2Fdownload%2F%40babel%2Fruntime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a" @@ -1309,13 +1316,6 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.18.3": - version "7.19.0" - resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259" - integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA== - dependencies: - regenerator-runtime "^0.13.4" - "@babel/template@^7.10.4", "@babel/template@^7.14.5", "@babel/template@^7.3.3": version "7.14.5" resolved "https://registry.nlark.com/@babel/template/download/@babel/template-7.14.5.tgz?cache=0&sync_timestamp=1623280386138&other_urls=https%3A%2F%2Fregistry.nlark.com%2F%40babel%2Ftemplate%2Fdownload%2F%40babel%2Ftemplate-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4" @@ -7780,6 +7780,11 @@ immer@8.0.1: resolved "https://registry.nlark.com/immer/download/immer-8.0.1.tgz?cache=0&sync_timestamp=1623232631798&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fimmer%2Fdownload%2Fimmer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" integrity sha1-nHPbaD4rOXXEJPsFcq9YiYd65lY= +immer@^9.0.15: + version "9.0.15" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.15.tgz#0b9169e5b1d22137aba7d43f8a81a495dd1b62dc" + integrity sha512-2eB/sswms9AEUSkOm4SbV5Y7Vmt/bKRwByd52jfLkW4OLYeaTP3EEiJ9agqU0O/tq6Dk62Zfj+TJSqfm1rLVGQ== + immer@^9.0.6: version "9.0.6" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" @@ -11803,6 +11808,14 @@ react-error-overlay@^6.0.9: resolved "https://registry.nlark.com/react-error-overlay/download/react-error-overlay-6.0.9.tgz?cache=0&sync_timestamp=1618847933355&other_urls=https%3A%2F%2Fregistry.nlark.com%2Freact-error-overlay%2Fdownload%2Freact-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" integrity sha1-PHQwEMk1lgjDdezWvHbzXZOZWwo= +react-i18next@^11.18.6: + version "11.18.6" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.18.6.tgz#e159c2960c718c1314f1e8fcaa282d1c8b167887" + integrity sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA== + dependencies: + "@babel/runtime" "^7.14.5" + html-parse-stringify "^3.0.1" + react-intl-universal@^2.6.6: version "2.6.6" resolved "https://registry.npmmirror.com/react-intl-universal/-/react-intl-universal-2.6.6.tgz#8212cdd052544455c2d95aa1924f02c31e9097ce" @@ -15290,11 +15303,6 @@ y18n@^5.0.5: resolved "https://registry.nlark.com/y18n/download/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha1-f0k00PfKjFb5UxSTndzS3ZHOHVU= -yallist@*, yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - yallist@^2.1.2: version "2.1.2" resolved "https://registry.nlark.com/yallist/download/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" @@ -15305,6 +15313,11 @@ yallist@^3.0.2: resolved "https://registry.nlark.com/yallist/download/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha1-27fa+b/YusmrRev2ArjLrQ1dCP0= +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2: version "1.10.2" resolved "https://registry.nlark.com/yaml/download/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"