From 63eb81815e037a52d61eb995c96aff15f6883950 Mon Sep 17 00:00:00 2001 From: hustcc Date: Mon, 1 Mar 2021 14:00:05 +0800 Subject: [PATCH] feat(sankey): changeData API for sankey (#2367) * feat(sankey): add change data API * test: add test case for change data * chore: update code * chore: merge master * fix: ci --- .../unit/plots/sankey/change-data-spec.ts | 26 +++++ __tests__/unit/plots/sankey/index-spec.ts | 17 ++- __tests__/unit/plots/word-cloud/index-spec.ts | 7 +- __tests__/unit/utils/transform/sankey-spec.ts | 10 +- src/plots/sankey/adaptor.ts | 106 ++++++------------ src/plots/sankey/constant.ts | 2 + src/plots/sankey/helper.ts | 68 +++++++++++ src/plots/sankey/index.ts | 38 ++++++- src/plots/sankey/types.ts | 5 +- src/utils/data.ts | 8 +- src/utils/transform/sankey.ts | 5 + 11 files changed, 206 insertions(+), 86 deletions(-) create mode 100644 __tests__/unit/plots/sankey/change-data-spec.ts diff --git a/__tests__/unit/plots/sankey/change-data-spec.ts b/__tests__/unit/plots/sankey/change-data-spec.ts new file mode 100644 index 0000000000..55b1099d88 --- /dev/null +++ b/__tests__/unit/plots/sankey/change-data-spec.ts @@ -0,0 +1,26 @@ +import { Sankey } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; +import { delay } from '../../../utils/delay'; +import { ALIPAY_DATA } from '../../../data/sankey-energy'; + +describe('sankey', () => { + it('changeData', async () => { + const data = ALIPAY_DATA.slice(0, ALIPAY_DATA.length - 5); + const sankey = new Sankey(createDiv(), { + height: 500, + data, + sourceField: 'source', + targetField: 'target', + weightField: 'value', + }); + + sankey.render(); + await delay(50); + sankey.changeData(ALIPAY_DATA); + + expect(sankey.options.data).toEqual(ALIPAY_DATA); + expect(sankey.chart.views[0].getOptions().data.length).toBe(ALIPAY_DATA.length); + + sankey.destroy(); + }); +}); diff --git a/__tests__/unit/plots/sankey/index-spec.ts b/__tests__/unit/plots/sankey/index-spec.ts index 743f15cf94..ffc164a424 100644 --- a/__tests__/unit/plots/sankey/index-spec.ts +++ b/__tests__/unit/plots/sankey/index-spec.ts @@ -30,6 +30,15 @@ describe('sankey', () => { expect(sankey.options.appendPadding).toEqual(8); + expect(sankey.options.animation).toEqual({ + appear: { + animation: 'wave-in', + }, + enter: { + animation: 'wave-in', + }, + }); + // node expect(sankey.chart.views[1].geometries[0].type).toBe('polygon'); expect(sankey.chart.views[1].geometries[0].data.length).toBe(48); @@ -60,10 +69,14 @@ describe('sankey', () => { ); expect(sankey.chart.views[0].geometries[0].labelsContainer.getChildren().length).toBe(0); + sankey.update({ + animation: false, + }); + // tooltip sankey.chart.showTooltip({ x: 100, y: 100 }); - expect(document.querySelector('.g2-tooltip-name').textContent).toBe('Nuclear -> Thermal generation'); - expect(document.querySelector('.g2-tooltip-value').textContent).toBe('839.978'); + expect(sankey.chart.ele.querySelector('.g2-tooltip-name').textContent).toBe('Nuclear -> Thermal generation'); + expect(sankey.chart.ele.querySelector('.g2-tooltip-value').textContent).toBe('839.978'); sankey.destroy(); }); diff --git a/__tests__/unit/plots/word-cloud/index-spec.ts b/__tests__/unit/plots/word-cloud/index-spec.ts index fd9f23877e..f7eddc3428 100644 --- a/__tests__/unit/plots/word-cloud/index-spec.ts +++ b/__tests__/unit/plots/word-cloud/index-spec.ts @@ -49,7 +49,12 @@ describe('word-cloud', () => { expect(chart.width).toBe(400); chart.ele.style.width = `410px`; - await delay(); + + // @ts-ignore + cloud.triggerResize(); + + await delay(10); + expect(chart.width).toBe(410); cloud.destroy(); }); diff --git a/__tests__/unit/utils/transform/sankey-spec.ts b/__tests__/unit/utils/transform/sankey-spec.ts index 3e48824239..e9e670612a 100644 --- a/__tests__/unit/utils/transform/sankey-spec.ts +++ b/__tests__/unit/utils/transform/sankey-spec.ts @@ -4,17 +4,19 @@ import { ENERGY } from '../../../data/sankey-energy'; describe('sankeyLayout', () => { it('getNodeAlignFunction', () => { - expect(getNodeAlignFunction(null)).toBe(sankeyJustify); - expect(getNodeAlignFunction(undefined)).toBe(sankeyJustify); + expect(getNodeAlignFunction(null, null)).toBe(sankeyJustify); + expect(getNodeAlignFunction(undefined, null)).toBe(sankeyJustify); // @ts-ignore - expect(getNodeAlignFunction('middle')).toBe(sankeyJustify); + expect(getNodeAlignFunction('middle', null)).toBe(sankeyJustify); - expect(getNodeAlignFunction('left')).toBe(sankeyLeft); + expect(getNodeAlignFunction('left', null)).toBe(sankeyLeft); const fn = jest.fn(); // @ts-ignore expect(getNodeAlignFunction(fn)).toBe(fn); + expect(getNodeAlignFunction(sankeyLeft, () => 1)).not.toBe(sankeyLeft); + // @ts-ignore expect(getNodeAlignFunction(123)).toBe(sankeyJustify); }); diff --git a/src/plots/sankey/adaptor.ts b/src/plots/sankey/adaptor.ts index 42df93f8ba..d52cee3da8 100644 --- a/src/plots/sankey/adaptor.ts +++ b/src/plots/sankey/adaptor.ts @@ -1,13 +1,10 @@ -import { interaction, animation, theme } from '../../adaptor/common'; +import { interaction, theme } from '../../adaptor/common'; import { Params } from '../../core/adaptor'; import { flow } from '../../utils'; -import { sankeyLayout } from '../../utils/transform/sankey'; import { polygon, edge } from '../../adaptor/geometries'; -import { transformDataToNodeLinkData } from '../../utils/data'; import { SankeyOptions } from './types'; import { X_FIELD, Y_FIELD, COLOR_FIELD } from './constant'; -import { cutoffCircle } from './circle'; -import { getNodePaddingRatio, getNodeWidthRatio } from './helper'; +import { transformToViewsData } from './helper'; /** * geometry 处理 @@ -15,24 +12,7 @@ import { getNodePaddingRatio, getNodeWidthRatio } from './helper'; */ function geometry(params: Params): Params { const { chart, options } = params; - const { - data, - sourceField, - targetField, - weightField, - color, - nodeStyle, - edgeStyle, - label, - tooltip, - nodeAlign, - nodePaddingRatio, - nodePadding, - nodeWidthRatio, - nodeWidth, - nodeSort, - nodeDepth, - } = options; + const { color, nodeStyle, edgeStyle, label, tooltip } = options; // 1. 组件,优先设置,因为子 view 会继承配置 chart.legend(false); @@ -41,54 +21,13 @@ function geometry(params: Params): Params { // y 镜像一下,防止图形顺序和数据顺序反了 chart.coordinate().reflect('y'); - // 2. 转换出 layout 前数据 - const sankeyLayoutInputData = transformDataToNodeLinkData( - cutoffCircle(data, sourceField, targetField), - sourceField, - targetField, - weightField - ); - - // 3. layout 之后的数据 - const { nodes, links } = sankeyLayout( - { - nodeAlign, - // @ts-ignore - nodePadding: getNodePaddingRatio(nodePadding, nodePaddingRatio, chart.height), - // @ts-ignore - nodeWidth: getNodeWidthRatio(nodeWidth, nodeWidthRatio, chart.width), - nodeSort, - nodeDepth, - }, - sankeyLayoutInputData - ); - - // 4. 生成绘图数据 - const nodesData = nodes.map((node) => { - return { - x: node.x, - y: node.y, - name: node.name, - isNode: true, - }; - }); - const edgesData = links.map((link) => { - return { - source: link.source.name, - target: link.target.name, - name: link.source.name || link.target.name, - x: link.x, - y: link.y, - value: link.value, - isNode: false, - }; - }); - - // 5. node edge views + // 2. node edge views + // @ts-ignore + const { nodes, edges } = transformToViewsData(options, chart.width, chart.height); // edge view - const edgeView = chart.createView(); - edgeView.data(edgesData); + const edgeView = chart.createView({ id: 'views' }); + edgeView.data(edges); edge({ chart: edgeView, @@ -114,8 +53,8 @@ function geometry(params: Params): Params { }, }); - const nodeView = chart.createView(); - nodeView.data(nodesData); + const nodeView = chart.createView({ id: 'nodes' }); + nodeView.data(nodes); polygon({ chart: nodeView, @@ -144,6 +83,31 @@ function geometry(params: Params): Params { return params; } +/** + * 动画 + * @param params + */ +export function animation(params: Params): Params { + const { chart, options } = params; + const { animation } = options; + + // 同时设置整个 view 动画选项 + if (typeof animation === 'boolean') { + chart.animate(animation); + } else { + chart.animate(true); + } + + const geometries = [...chart.views[0].geometries, ...chart.views[1].geometries]; + + // 所有的 Geometry 都使用同一动画(各个图形如有区别,自行覆盖) + geometries.forEach((g) => { + g.animate(animation); + }); + + return params; +} + /** * 图适配器 * @param chart diff --git a/src/plots/sankey/constant.ts b/src/plots/sankey/constant.ts index e6b0786c2a..0f24501ad3 100644 --- a/src/plots/sankey/constant.ts +++ b/src/plots/sankey/constant.ts @@ -1,3 +1,5 @@ export const X_FIELD = 'x'; export const Y_FIELD = 'y'; export const COLOR_FIELD = 'name'; +export const NODES_VIEW_ID = 'nodes'; +export const EDGES_VIEW_ID = 'views'; diff --git a/src/plots/sankey/helper.ts b/src/plots/sankey/helper.ts index 40d3e02f6e..54519d9130 100644 --- a/src/plots/sankey/helper.ts +++ b/src/plots/sankey/helper.ts @@ -1,4 +1,8 @@ import { isRealNumber } from '../../utils/number'; +import { transformDataToNodeLinkData } from '../../utils/data'; +import { sankeyLayout } from '../../utils/transform/sankey'; +import { cutoffCircle } from './circle'; +import { SankeyOptions } from './types'; export function getNodeWidthRatio(nodeWidth: number, nodeWidthRatio: number, width: number) { return isRealNumber(nodeWidth) ? nodeWidth / width : nodeWidthRatio; @@ -7,3 +11,67 @@ export function getNodeWidthRatio(nodeWidth: number, nodeWidthRatio: number, wid export function getNodePaddingRatio(nodePadding: number, nodePaddingRatio: number, height: number) { return isRealNumber(nodePadding) ? nodePadding / height : nodePaddingRatio; } + +/** + * 将桑基图配置经过 layout,生成最终的 view 数据 + * @param options + * @param width + * @param height + */ +export function transformToViewsData(options: SankeyOptions, width: number, height: number) { + const { + data, + sourceField, + targetField, + weightField, + nodeAlign, + nodeSort, + nodePadding, + nodePaddingRatio, + nodeWidth, + nodeWidthRatio, + nodeDepth, + } = options; + + const sankeyLayoutInputData = transformDataToNodeLinkData( + cutoffCircle(data, sourceField, targetField), + sourceField, + targetField, + weightField + ); + + // 3. layout 之后的数据 + const { nodes, links } = sankeyLayout( + { + nodeAlign, + nodePadding: getNodePaddingRatio(nodePadding, nodePaddingRatio, height), + nodeWidth: getNodeWidthRatio(nodeWidth, nodeWidthRatio, width), + nodeSort, + nodeDepth, + }, + sankeyLayoutInputData + ); + + // 4. 生成绘图数据 + return { + nodes: nodes.map((node) => { + return { + x: node.x, + y: node.y, + name: node.name, + isNode: true, + }; + }), + edges: links.map((link) => { + return { + source: link.source.name, + target: link.target.name, + name: link.source.name || link.target.name, + x: link.x, + y: link.y, + value: link.value, + isNode: false, + }; + }), + }; +} diff --git a/src/plots/sankey/index.ts b/src/plots/sankey/index.ts index cc8d60abbb..deed749ed5 100644 --- a/src/plots/sankey/index.ts +++ b/src/plots/sankey/index.ts @@ -1,9 +1,12 @@ import { get } from '@antv/util'; import { Plot } from '../../core/plot'; import { Adaptor } from '../../core/adaptor'; -import { Datum } from '../../types'; +import { Data, Datum } from '../../types'; +import { findViewById } from '../../utils'; import { SankeyOptions } from './types'; import { adaptor } from './adaptor'; +import { transformToViewsData } from './helper'; +import { EDGES_VIEW_ID, NODES_VIEW_ID } from './constant'; export { SankeyOptions }; @@ -14,7 +17,7 @@ export class Sankey extends Plot { /** 图表类型 */ public type: string = 'sankey'; - protected getDefaultOptions() { + static getDefaultOptions(): Partial { return { appendPadding: 8, syncViewPadding: true, @@ -65,13 +68,44 @@ export class Sankey extends Plot { }, nodeWidthRatio: 0.008, nodePaddingRatio: 0.01, + animation: { + appear: { + animation: 'wave-in', + }, + enter: { + animation: 'wave-in', + }, + }, }; } + /** + * @override + * @param data + */ + public changeData(data: Data) { + this.updateOption({ data }); + + const { nodes, edges } = transformToViewsData(this.options, this.chart.width, this.chart.height); + + const nodesView = findViewById(this.chart, NODES_VIEW_ID); + const edgesView = findViewById(this.chart, EDGES_VIEW_ID); + + nodesView.changeData(nodes); + edgesView.changeData(edges); + } + /** * 获取适配器 */ protected getSchemaAdaptor(): Adaptor { return adaptor; } + + /** + * 获取 条形图 默认配置 + */ + protected getDefaultOptions() { + return Sankey.getDefaultOptions(); + } } diff --git a/src/plots/sankey/types.ts b/src/plots/sankey/types.ts index b4e5f569d6..37386861d1 100644 --- a/src/plots/sankey/types.ts +++ b/src/plots/sankey/types.ts @@ -1,5 +1,6 @@ -import { Data, Datum, Options, StyleAttr } from '../../types'; +import { Data, Options, StyleAttr } from '../../types'; import { NodeDepth } from '../../utils/transform/sankey'; +import { NodeSort } from '../../utils/transform/sankey'; /** 配置类型定义 */ export interface SankeyOptions extends Omit { @@ -42,7 +43,7 @@ export interface SankeyOptions extends Omit number; + readonly nodeSort?: NodeSort; /** * 节点排放分层的顺序,从 0 开始,并且返回值需要保证所有的层级都有节点 */ diff --git a/src/utils/data.ts b/src/utils/data.ts index 1296549796..025eab2eb6 100644 --- a/src/utils/data.ts +++ b/src/utils/data.ts @@ -60,20 +60,20 @@ export function transformDataToNodeLinkData( // source node if (!nodesMap[source]) { nodesMap[source] = { - index: ++nodesIndex, + id: ++nodesIndex, name: source, }; } if (!nodesMap[target]) { nodesMap[target] = { - index: ++nodesIndex, + id: ++nodesIndex, name: target, }; } // links links.push({ - source: nodesMap[source].index, - target: nodesMap[target].index, + source: nodesMap[source].id, + target: nodesMap[target].id, // sourceName: source, // targetName: target, value: weight, diff --git a/src/utils/transform/sankey.ts b/src/utils/transform/sankey.ts index 3e124625af..a640657adb 100644 --- a/src/utils/transform/sankey.ts +++ b/src/utils/transform/sankey.ts @@ -70,6 +70,11 @@ export type NodeAlign = keyof typeof ALIGN_METHOD; */ export type NodeDepth = (datum: Datum, maxDepth: number) => number; +/** + * 节点排序方法的类型定义 + */ +export type NodeSort = (a: Datum, b: Datum) => number; + /** * 布局参数的定义 */