From 8d0f065a93347e6f8a2edcf6ebc0f1fc30b41005 Mon Sep 17 00:00:00 2001 From: MiniPear Date: Tue, 23 May 2023 14:09:27 +0800 Subject: [PATCH] feat(api): use api and spec together --- __tests__/unit/api/options.spec.ts | 205 +++++++++++++++++++++++++++++ src/api/chart.ts | 28 ++-- src/api/node.ts | 8 ++ src/api/utils.ts | 145 +++++++++++++++++--- 4 files changed, 351 insertions(+), 35 deletions(-) create mode 100644 __tests__/unit/api/options.spec.ts diff --git a/__tests__/unit/api/options.spec.ts b/__tests__/unit/api/options.spec.ts new file mode 100644 index 0000000000..c766c6f6c8 --- /dev/null +++ b/__tests__/unit/api/options.spec.ts @@ -0,0 +1,205 @@ +import { Chart } from '../../../src'; +import { Point } from '../../../src/api/mark/mark'; + +describe('chart api and options', () => { + it('chart.options({...}) should create node instance from spec.', () => { + const chart = new Chart({}); + + chart.options({ + type: 'interval', + data: [ + { genre: 'Sports', sold: 275 }, + { genre: 'Strategy', sold: 115 }, + { genre: 'Action', sold: 120 }, + { genre: 'Shooter', sold: 350 }, + { genre: 'Other', sold: 150 }, + ], + encode: { + x: 'genre', + y: 'sold', + }, + }); + + expect(chart.getNodeByType('view')).toBeDefined(); + expect(chart.getNodeByType('interval')).toBeDefined(); + }); + + it('chart.options({...}) should bubble view options', () => { + const chart = new Chart({}); + + chart.options({ + type: 'interval', + width: 100, + height: 100, + padding: 10, + paddingLeft: 10, + paddingRight: 10, + paddingBottom: 10, + paddingTop: 10, + inset: 10, + insetLeft: 10, + insetRight: 10, + insetTop: 10, + insetBottom: 10, + margin: 10, + marginLeft: 10, + marginRight: 10, + marginTop: 10, + marginBottom: 10, + autoFit: 10, + theme: 10, + }); + + expect(chart.options()).toEqual({ + type: 'view', + width: 100, + height: 100, + padding: 10, + paddingLeft: 10, + paddingRight: 10, + paddingBottom: 10, + paddingTop: 10, + inset: 10, + insetLeft: 10, + insetRight: 10, + insetTop: 10, + insetBottom: 10, + margin: 10, + marginLeft: 10, + marginRight: 10, + marginTop: 10, + marginBottom: 10, + autoFit: 10, + theme: 10, + children: [{ type: 'interval' }], + }); + }); + + it('chart.options({...}) should create nested node tree from spec.', () => { + const chart = new Chart({}); + const options = { + type: 'spaceFlex', + flex: [1, 2], + children: [ + { + type: 'spaceLayer', + children: [ + { + type: 'view', + children: [ + { type: 'interval', data: [2, 3, 4] }, + { type: 'point' }, + ], + }, + ], + }, + { type: 'interval', data: [1, 2, 3] }, + ], + }; + + chart.options(options); + + expect(chart.options()).toEqual(options); + expect(chart.getNodeByType('point')).toBeInstanceOf(Point); + }); + + it('chart.options({...}) should update node with same height and index.', () => { + const chart = new Chart({}); + + chart.options({ + type: 'view', + children: [{ type: 'interval' }], + }); + + chart.options({ + children: [{ data: [1, 2, 3] }], + }); + + expect(chart.getNodeByType('interval').value.data).toEqual([1, 2, 3]); + }); + + it('chart.options({...}) should update nested node tree.', () => { + const chart = new Chart({}); + + chart.options({ + type: 'spaceFlex', + flex: [1, 2], + children: [ + { + type: 'spaceLayer', + children: [ + { + type: 'view', + children: [ + { type: 'interval', data: [2, 3, 4] }, + { type: 'point' }, + ], + }, + ], + }, + { type: 'interval', data: [1, 2, 3] }, + ], + }); + + chart.options({ + flex: [2, 3, 4], + children: [ + { children: [{ children: [{}, { data: [1, 2, 3] }] }] }, + { data: [2, 3, 4], scale: { x: { nice: true } } }, + ], + }); + + expect(chart.options()).toEqual({ + type: 'spaceFlex', + flex: [2, 3, 4], + children: [ + { + type: 'spaceLayer', + children: [ + { + type: 'view', + children: [ + { type: 'interval', data: [2, 3, 4] }, + { type: 'point', data: [1, 2, 3] }, + ], + }, + ], + }, + { type: 'interval', data: [2, 3, 4], scale: { x: { nice: true } } }, + ], + }); + }); + + it('chart.options({...}) should transform node.', () => { + const chart = new Chart({}); + + chart.options({ + type: 'view', + children: [ + { type: 'interval', scale: { x: { nice: true } }, data: [1, 2, 3] }, + ], + }); + + chart.options({ + type: 'view', + children: [{ type: 'line', data: [4, 5, 6] }], + }); + + const line = chart.getNodeByType('line'); + expect(line.type).toBe('line'); + expect(line.value).toEqual({ data: [4, 5, 6] }); + }); + + it('chart.options({...}) should update node tree specified by API.', () => { + const chart = new Chart({}); + + const interval = chart.interval().data([1, 2, 3]); + + chart.options({ + type: 'view', + children: [{ data: [2, 3, 4] }], + }); + + expect(interval.data()).toEqual([2, 3, 4]); + }); +}); diff --git a/src/api/chart.ts b/src/api/chart.ts index 9e43cddfe4..8d90d0c7f9 100644 --- a/src/api/chart.ts +++ b/src/api/chart.ts @@ -1,7 +1,7 @@ import { IRenderer, RendererPlugin, Canvas as GCanvas } from '@antv/g'; import { Renderer as CanvasRenderer } from '@antv/g-canvas'; import { Plugin as DragAndDropPlugin } from '@antv/g-plugin-dragndrop'; -import { debounce, deepMix } from '@antv/util'; +import { debounce } from '@antv/util'; import EventEmitter from '@antv/event-emitter'; import { G2Context, render, destroy } from '../runtime'; import { ViewComposition } from '../spec'; @@ -27,6 +27,8 @@ import { removeContainer, sizeOf, optionsOf, + updateRoot, + VIEW_KEYS, } from './utils'; export const G2_CHART_KEY = 'G2_CHART_KEY'; @@ -84,7 +86,6 @@ export class Chart extends View { private _container: HTMLElement; private _context: G2Context; private _emitter: EventEmitter; - private _options: G2ViewTree; private _width: number; private _height: number; private _renderer: IRenderer; @@ -161,20 +162,11 @@ export class Chart extends View { * @returns {Chart|G2ViewTree} */ options(options?: G2ViewTree): Chart | G2ViewTree { - if (arguments.length === 0) { - return this._options || optionsOf(this); - } - this._options = deepMix(this._options || optionsOf(this), options); + if (arguments.length === 0) return optionsOf(this); + updateRoot(this, options); return this; } - // @todo Remove it when implement updateRoot. - changeData(data: any): Promise { - // Update options data. - this.options({ data }); - return super.changeData(data); - } - getContainer(): HTMLElement { return this._container; } @@ -206,7 +198,7 @@ export class Chart extends View { clear() { const options = this.options(); this.emit(ChartEvent.BEFORE_CLEAR); - this._options = {}; + this._reset(); destroy(options, this._context, false); this.emit(ChartEvent.AFTER_CLEAR); } @@ -215,7 +207,7 @@ export class Chart extends View { const options = this.options(); this.emit(ChartEvent.BEFORE_DESTROY); this._unbindAutoFit(); - this._options = {}; + this._reset(); destroy(options, this._context, true); removeContainer(this._container); this.emit(ChartEvent.AFTER_DESTROY); @@ -252,6 +244,12 @@ export class Chart extends View { return finished; } + private _reset() { + this.type = 'view'; + this.value = {}; + this.children = []; + } + private _onResize = debounce(() => { this.forceFit(); }, 300); diff --git a/src/api/node.ts b/src/api/node.ts index 9b128e6df7..9eb4dde211 100644 --- a/src/api/node.ts +++ b/src/api/node.ts @@ -115,6 +115,14 @@ export class Node< return nodes; } + getNodeByType(type: string): Node { + let node; + bfs(this, (current: Node) => { + if (type === current.type) node = current; + }); + return node; + } + /** * Apply specified callback to the node value. */ diff --git a/src/api/utils.ts b/src/api/utils.ts index ca0676440a..1dd597e90b 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,5 +1,33 @@ +import { deepMix } from '@antv/util'; +import { G2ViewTree } from '../runtime'; import { getContainerSize } from '../utils/size'; import { Node } from './node'; +import { mark } from './mark'; +import { composition } from './composition'; + +// Keys can specified by new Chart({...}). +// Keys can bubble form mark-level options to view-level options. +export const VIEW_KEYS = [ + 'width', + 'height', + 'padding', + 'paddingLeft', + 'paddingRight', + 'paddingBottom', + 'paddingTop', + 'inset', + 'insetLeft', + 'insetRight', + 'insetTop', + 'insetBottom', + 'margin', + 'marginLeft', + 'marginRight', + 'marginTop', + 'marginBottom', + 'autoFit', + 'theme', +]; export function normalizeContainer( container: string | HTMLElement, @@ -22,25 +50,7 @@ export function removeContainer(container: HTMLElement) { export function normalizeRoot(node: Node) { if (node.type !== null) return node; const root = node.children[node.children.length - 1]; - root.attr('width', node.attr('width')); - root.attr('height', node.attr('height')); - root.attr('paddingLeft', node.attr('paddingLeft')); - root.attr('paddingTop', node.attr('paddingTop')); - root.attr('paddingBottom', node.attr('paddingBottom')); - root.attr('paddingRight', node.attr('paddingRight')); - root.attr('insetLeft', node.attr('insetLeft')); - root.attr('insetRight', node.attr('insetRight')); - root.attr('insetBottom', node.attr('insetBottom')); - root.attr('insetTop', node.attr('insetTop')); - root.attr('marginLeft', node.attr('marginLeft')); - root.attr('marginBottom', node.attr('marginBottom')); - root.attr('marginTop', node.attr('marginTop')); - root.attr('marginRight', node.attr('marginRight')); - root.attr('autoFit', node.attr('autoFit')); - root.attr('padding', node.attr('padding')); - root.attr('margin', node.attr('margin')); - root.attr('inset', node.attr('inset')); - root.attr('theme', node.attr('theme')); + for (const key of VIEW_KEYS) root.attr(key, node.attr(key)); return root; } @@ -66,7 +76,8 @@ export function optionsOf(node: Node): Record { while (discovered.length) { const node = discovered.pop(); const value = nodeValue.get(node); - for (const child of node.children) { + const { children = [] } = node; + for (const child of children) { const childValue = valueOf(child); const { children = [] } = value; children.push(childValue); @@ -77,3 +88,97 @@ export function optionsOf(node: Node): Record { } return nodeValue.get(root); } + +function isMark(type: string): boolean { + return new Set(Object.keys(mark)).has(type); +} + +function normalizeRootOptions(node: Node, options: G2ViewTree) { + const { type: oldType } = node; + const { type = oldType } = options; + if (type === 'view') return options; + if (typeof type !== 'string') return options; + if (!isMark(type)) return options; + const view = { type: 'view' }; + const mark = { ...options }; + for (const key of VIEW_KEYS) { + if (mark[key] !== undefined) { + view[key] = mark[key]; + delete mark[key]; + } + } + return { ...view, children: [mark] }; +} + +function typeCtor(type: string): new () => Node { + const node = { ...mark, ...composition }; + const ctor = node[type]; + if (!ctor) throw new Error(`Unknown mark: ${type}.`); + return ctor; +} + +// Create node from options. +function createNode(options: G2ViewTree): Node { + const { type, children, ...value } = options; + if (typeof type !== 'string') return; + const Ctor = typeCtor(type); + const node = new Ctor(); + node.value = value; + return node; +} + +// Update node by options. +function updateNode(node: Node, newOptions: G2ViewTree) { + const { type, children, ...value } = newOptions; + if (node.type === type || type === undefined) { + // Update node. + node.value = deepMix(node.value, value); + } else if (typeof type === 'string') { + // Transform node. + node.type = type; + node.value = value; + } +} + +// Create a nested node tree from newOptions, and append it to the parent. +function appendNode(parent: Node, newOptions: G2ViewTree) { + if (!parent) return; + const discovered = [[parent, newOptions]]; + while (discovered.length) { + const [parent, nodeOptions] = discovered.shift(); + const node = createNode(nodeOptions); + if (Array.isArray(parent.children)) parent.children.push(node); + const { children } = nodeOptions; + if (Array.isArray(children)) { + for (const child of children) { + discovered.push([node, child]); + } + } + } +} + +// Update node tree from options. +export function updateRoot(node: Node, options: G2ViewTree) { + const rootOptions = normalizeRootOptions(node, options); + const discovered: [Node, Node, G2ViewTree][] = [[null, node, rootOptions]]; + while (discovered.length) { + const [parent, oldNode, newNode] = discovered.shift(); + // If there is no oldNode, create a node tree directly. + if (!oldNode) { + appendNode(parent, newNode); + } else { + updateNode(oldNode, newNode); + const { children: newChildren } = newNode; + const { children: oldChildren } = oldNode; + if (Array.isArray(newChildren) && Array.isArray(oldChildren)) { + // Only update node specified in newChildren, + // the extra oldChildren will remain still. + for (let i = 0; i < newChildren.length; i++) { + const newChild = newChildren[i]; + const oldChild = oldChildren[i]; + discovered.push([oldNode, oldChild, newChild]); + } + } + } + } +}