From 8e258c1c9acfbbe12faf9c10b53b32f54ebdc711 Mon Sep 17 00:00:00 2001 From: hustcc Date: Thu, 26 Nov 2020 20:59:37 +0800 Subject: [PATCH 1/8] feat: init sankey --- src/plots/sankey/adaptor.ts | 36 ++++++++++++++++++++++++++++++++++++ src/plots/sankey/index.ts | 21 +++++++++++++++++++++ src/plots/sankey/types.ts | 11 +++++++++++ 3 files changed, 68 insertions(+) create mode 100644 src/plots/sankey/adaptor.ts create mode 100644 src/plots/sankey/index.ts create mode 100644 src/plots/sankey/types.ts diff --git a/src/plots/sankey/adaptor.ts b/src/plots/sankey/adaptor.ts new file mode 100644 index 0000000000..10213fbe65 --- /dev/null +++ b/src/plots/sankey/adaptor.ts @@ -0,0 +1,36 @@ +import { interaction, animation, theme, scale } from '../../adaptor/common'; +import { Params } from '../../core/adaptor'; +import { flow } from '../../utils'; +import { SankeyOptions } from './types'; + +/** + * geometry 处理 + * @param params + */ +function geometry(params: Params): Params { + const { chart, options } = params; + const { data, sourceField, targetField, weightField } = options; + + chart.data(data); + + chart.interval().position(`${sourceField}*${targetField}`).color(weightField); + + return params; +} + +/** + * 图适配器 + * @param chart + * @param options + */ +export function adaptor(params: Params) { + // flow 的方式处理所有的配置到 G2 API + return flow( + geometry, + scale({}), + interaction, + animation, + theme + // ... 其他的 adaptor flow + )(params); +} diff --git a/src/plots/sankey/index.ts b/src/plots/sankey/index.ts new file mode 100644 index 0000000000..954ea7b8f1 --- /dev/null +++ b/src/plots/sankey/index.ts @@ -0,0 +1,21 @@ +import { Plot } from '../../core/plot'; +import { Adaptor } from '../../core/adaptor'; +import { SankeyOptions } from './types'; +import { adaptor } from './adaptor'; + +export { SankeyOptions }; + +/** + * 桑基图 Sankey + */ +export class Sankey extends Plot { + /** 图表类型 */ + public type: string = 'sankey'; + + /** + * 获取适配器 + */ + protected getSchemaAdaptor(): Adaptor { + return adaptor; + } +} diff --git a/src/plots/sankey/types.ts b/src/plots/sankey/types.ts new file mode 100644 index 0000000000..e5fc4f1a3d --- /dev/null +++ b/src/plots/sankey/types.ts @@ -0,0 +1,11 @@ +import { Options } from '../../types'; + +/** 配置类型定义 */ +export interface SankeyOptions extends Omit { + /** 来源字段 */ + readonly sourceField?: string; + /** 去向字段 */ + readonly targetField?: string; + /** 权重字段 */ + readonly weightField?: string; +} From fe7aaa1df1ec525a1695c6fc3eb3fb0b27bfdfdf Mon Sep 17 00:00:00 2001 From: hustcc Date: Fri, 27 Nov 2020 11:02:21 +0800 Subject: [PATCH 2/8] feat(sankey): add sankey layout --- __tests__/data/sankey-energy.ts | 122 +++++++++++++ __tests__/unit/utils/transform/sankey-spec.ts | 49 ++++++ package.json | 1 + src/plots/sankey/adaptor.ts | 8 +- src/plots/sankey/index.ts | 12 ++ src/plots/sankey/types.ts | 48 +++++- src/utils/transform/sankey.ts | 163 ++++++++++++++++++ 7 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 __tests__/data/sankey-energy.ts create mode 100644 __tests__/unit/utils/transform/sankey-spec.ts create mode 100644 src/utils/transform/sankey.ts diff --git a/__tests__/data/sankey-energy.ts b/__tests__/data/sankey-energy.ts new file mode 100644 index 0000000000..d7b0ff74d5 --- /dev/null +++ b/__tests__/data/sankey-energy.ts @@ -0,0 +1,122 @@ +export const ENERGY = { + nodes: [ + { name: "Agricultural 'waste'" }, + { name: 'Bio-conversion' }, + { name: 'Liquid' }, + { name: 'Losses' }, + { name: 'Solid' }, + { name: 'Gas' }, + { name: 'Biofuel imports' }, + { name: 'Biomass imports' }, + { name: 'Coal imports' }, + { name: 'Coal' }, + { name: 'Coal reserves' }, + { name: 'District heating' }, + { name: 'Industry' }, + { name: 'Heating and cooling - commercial' }, + { name: 'Heating and cooling - homes' }, + { name: 'Electricity grid' }, + { name: 'Over generation / exports' }, + { name: 'H2 conversion' }, + { name: 'Road transport' }, + { name: 'Agriculture' }, + { name: 'Rail transport' }, + { name: 'Lighting & appliances - commercial' }, + { name: 'Lighting & appliances - homes' }, + { name: 'Gas imports' }, + { name: 'Ngas' }, + { name: 'Gas reserves' }, + { name: 'Thermal generation' }, + { name: 'Geothermal' }, + { name: 'H2' }, + { name: 'Hydro' }, + { name: 'International shipping' }, + { name: 'Domestic aviation' }, + { name: 'International aviation' }, + { name: 'National navigation' }, + { name: 'Marine algae' }, + { name: 'Nuclear' }, + { name: 'Oil imports' }, + { name: 'Oil' }, + { name: 'Oil reserves' }, + { name: 'Other waste' }, + { name: 'Pumped heat' }, + { name: 'Solar PV' }, + { name: 'Solar Thermal' }, + { name: 'Solar' }, + { name: 'Tidal' }, + { name: 'UK land based bioenergy' }, + { name: 'Wave' }, + { name: 'Wind' }, + ], + links: [ + { source: 0, target: 1, value: 124.729 }, + { source: 1, target: 2, value: 0.597 }, + { source: 1, target: 3, value: 26.862 }, + { source: 1, target: 4, value: 280.322 }, + { source: 1, target: 5, value: 81.144 }, + { source: 6, target: 2, value: 35 }, + { source: 7, target: 4, value: 35 }, + { source: 8, target: 9, value: 11.606 }, + { source: 10, target: 9, value: 63.965 }, + { source: 9, target: 4, value: 75.571 }, + { source: 11, target: 12, value: 10.639 }, + { source: 11, target: 13, value: 22.505 }, + { source: 11, target: 14, value: 46.184 }, + { source: 15, target: 16, value: 104.453 }, + { source: 15, target: 14, value: 113.726 }, + { source: 15, target: 17, value: 27.14 }, + { source: 15, target: 12, value: 342.165 }, + { source: 15, target: 18, value: 37.797 }, + { source: 15, target: 19, value: 4.412 }, + { source: 15, target: 13, value: 40.858 }, + { source: 15, target: 3, value: 56.691 }, + { source: 15, target: 20, value: 7.863 }, + { source: 15, target: 21, value: 90.008 }, + { source: 15, target: 22, value: 93.494 }, + { source: 23, target: 24, value: 40.719 }, + { source: 25, target: 24, value: 82.233 }, + { source: 5, target: 13, value: 0.129 }, + { source: 5, target: 3, value: 1.401 }, + { source: 5, target: 26, value: 151.891 }, + { source: 5, target: 19, value: 2.096 }, + { source: 5, target: 12, value: 48.58 }, + { source: 27, target: 15, value: 7.013 }, + { source: 17, target: 28, value: 20.897 }, + { source: 17, target: 3, value: 6.242 }, + { source: 28, target: 18, value: 20.897 }, + { source: 29, target: 15, value: 6.995 }, + { source: 2, target: 12, value: 121.066 }, + { source: 2, target: 30, value: 128.69 }, + { source: 2, target: 18, value: 135.835 }, + { source: 2, target: 31, value: 14.458 }, + { source: 2, target: 32, value: 206.267 }, + { source: 2, target: 19, value: 3.64 }, + { source: 2, target: 33, value: 33.218 }, + { source: 2, target: 20, value: 4.413 }, + { source: 34, target: 1, value: 4.375 }, + { source: 24, target: 5, value: 122.952 }, + { source: 35, target: 26, value: 839.978 }, + { source: 36, target: 37, value: 504.287 }, + { source: 38, target: 37, value: 107.703 }, + { source: 37, target: 2, value: 611.99 }, + { source: 39, target: 4, value: 56.587 }, + { source: 39, target: 1, value: 77.81 }, + { source: 40, target: 14, value: 193.026 }, + { source: 40, target: 13, value: 70.672 }, + { source: 41, target: 15, value: 59.901 }, + { source: 42, target: 14, value: 19.263 }, + { source: 43, target: 42, value: 19.263 }, + { source: 43, target: 41, value: 59.901 }, + { source: 4, target: 19, value: 0.882 }, + { source: 4, target: 26, value: 400.12 }, + { source: 4, target: 12, value: 46.477 }, + { source: 26, target: 15, value: 525.531 }, + { source: 26, target: 3, value: 787.129 }, + { source: 26, target: 11, value: 79.329 }, + { source: 44, target: 15, value: 9.452 }, + { source: 45, target: 1, value: 182.01 }, + { source: 46, target: 15, value: 19.013 }, + { source: 47, target: 15, value: 289.366 }, + ], +}; diff --git a/__tests__/unit/utils/transform/sankey-spec.ts b/__tests__/unit/utils/transform/sankey-spec.ts new file mode 100644 index 0000000000..44df487ced --- /dev/null +++ b/__tests__/unit/utils/transform/sankey-spec.ts @@ -0,0 +1,49 @@ +import { sankeyLeft, sankeyJustify } from 'd3-sankey'; +import { sankeyLayout, getNodeAlignFunction, getDefaultOptions } from '../../../../src/utils/transform/sankey'; +import { ENERGY } from '../../../data/sankey-energy'; + +describe('sankeyLayout', () => { + it('getNodeAlignFunction', () => { + expect(getNodeAlignFunction(null)).toBe(sankeyJustify); + expect(getNodeAlignFunction(undefined)).toBe(sankeyJustify); + // @ts-ignore + expect(getNodeAlignFunction('middle')).toBe(sankeyJustify); + + expect(getNodeAlignFunction('left')).toBe(sankeyLeft); + + const fn = jest.fn(); + // @ts-ignore + expect(getNodeAlignFunction(fn)).toBe(fn); + }); + + it('getDefaultOptions', () => { + expect(getDefaultOptions({}).nodeAlign).toBe('justify'); + expect(getDefaultOptions({}).nodePadding).toBe(0.03); + expect(getDefaultOptions({}).nodeWidth).toBe(0.008); + }); + + it('sankeyLayout', () => { + const data = sankeyLayout({}, ENERGY); + expect(data.nodes.length).toBe(48); + expect(data.links.length).toBe(68); + + expect(data.nodes[0].name).toBe("Agricultural 'waste'"); + expect(data.nodes[0].x).toEqual([0, 0.008, 0.008, 0]); + expect(data.nodes[0].y).toEqual([ + 0.15714829392583463, + 0.15714829392583463, + 0.17602864502202453, + 0.17602864502202453, + ]); + + expect(data.links[0].source.name).toBe("Agricultural 'waste'"); + expect(data.links[0].target.name).toBe('Bio-conversion'); + expect(data.links[0].x).toEqual([0.008, 0.008, 0.1417142857142857, 0.1417142857142857]); + expect(data.links[0].y).toEqual([ + 0.17602864502202453, + 0.15714829392583463, + 0.23174113600532192, + 0.21286078490913202, + ]); + }); +}); diff --git a/package.json b/package.json index d5c797e1aa..3b58928d9a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@antv/g2": "^4.1.0-beta.21", "d3-hierarchy": "^2.0.0", "d3-regression": "^1.3.5", + "d3-sankey": "^0.12.3", "dayjs": "^1.8.36", "size-sensor": "^1.0.1", "tslib": "^1.13.0" diff --git a/src/plots/sankey/adaptor.ts b/src/plots/sankey/adaptor.ts index 10213fbe65..0f2d6d1302 100644 --- a/src/plots/sankey/adaptor.ts +++ b/src/plots/sankey/adaptor.ts @@ -13,7 +13,13 @@ function geometry(params: Params): Params { chart.data(data); - chart.interval().position(`${sourceField}*${targetField}`).color(weightField); + // node view + const nodeView = chart.createView(); + nodeView.data([]); + + // edge view + const edgeView = chart.createView(); + edgeView.data([]); return params; } diff --git a/src/plots/sankey/index.ts b/src/plots/sankey/index.ts index 954ea7b8f1..c7275b2dd1 100644 --- a/src/plots/sankey/index.ts +++ b/src/plots/sankey/index.ts @@ -12,6 +12,18 @@ export class Sankey extends Plot { /** 图表类型 */ public type: string = 'sankey'; + protected getDefaultOptions() { + return { + syncViewPadding: true, + nodeWidthRatio: 0.008, + nodePaddingRatio: 0.03, + tooltip: { + showTitle: false, + showMarkers: false, + }, + }; + } + /** * 获取适配器 */ diff --git a/src/plots/sankey/types.ts b/src/plots/sankey/types.ts index e5fc4f1a3d..e8d0aa4dcf 100644 --- a/src/plots/sankey/types.ts +++ b/src/plots/sankey/types.ts @@ -1,11 +1,45 @@ -import { Options } from '../../types'; +import { Data, Datum, Options, StyleAttr } from '../../types'; /** 配置类型定义 */ export interface SankeyOptions extends Omit { - /** 来源字段 */ - readonly sourceField?: string; - /** 去向字段 */ - readonly targetField?: string; - /** 权重字段 */ - readonly weightField?: string; + /** + * 来源字段 + */ + readonly sourceField: string; + /** + * 去向字段 + */ + readonly targetField: string; + /** + * 权重字段 + */ + readonly weightField: string; + /** + * 数据 + */ + readonly data: Data; + /** + * 节点宽度的比如,参考画布的宽度,默认值为 0.008 + */ + readonly nodeWidthRatio?: number; + /** + * 节点之间的间距比例,参考画布高度,默认值为 0.03 + */ + readonly nodePaddingRatio?: number; + /** + * 节点对齐的方式,默认为 justify + */ + readonly nodeAlign?: 'left' | 'right' | 'center' | 'justify'; + /** + * 节点排序方式,默认为空 + */ + readonly nodeSort?: (a: Datum, b: Datum) => number; + /** + * 节点样式 + */ + readonly nodeStyle?: StyleAttr; + /** + * 边样式 + */ + readonly edgeStyle?: StyleAttr; } diff --git a/src/utils/transform/sankey.ts b/src/utils/transform/sankey.ts new file mode 100644 index 0000000000..ed666baebb --- /dev/null +++ b/src/utils/transform/sankey.ts @@ -0,0 +1,163 @@ +import { assign, isString, isFunction } from '@antv/util'; +import { sankey, sankeyLeft, sankeyRight, sankeyCenter, sankeyJustify } from 'd3-sankey'; +import { Datum } from '../../types'; + +const ALIGN_METHOD = { + left: sankeyLeft, + right: sankeyRight, + center: sankeyCenter, + justify: sankeyJustify, +}; + +type InputNode = { + readonly name: string; +}; + +type InputLink = { + readonly source: number; + readonly target: number; + readonly value: number; +}; + +type OutPutNode = { + readonly name: string; + readonly x0: number; + readonly x1: number; + readonly y0: number; + readonly y1: number; + readonly depth: number; + readonly value: number; + + // 用于绘制 polygon + x: number[]; + y: number[]; +}; + +type OutPutLink = { + readonly source: OutPutNode; + readonly target: OutPutNode; + readonly value: number; + readonly width: number; + readonly y0: number; + readonly y1: number; + + // 用于绘制 edge + x?: number[]; + y?: number[]; +}; + +/** + * 桑基图布局的数据结构定义 + */ +export type SankeyLayoutInputData = { + readonly nodes: InputNode[]; + readonly links: InputLink[]; +}; + +type SankeyLayoutOutPutData = { + readonly nodes: OutPutNode[]; + readonly links: OutPutLink[]; +}; + +/** + * 对齐方式的类型定义 + */ +export type NodeAlign = keyof typeof ALIGN_METHOD; + +/** + * 布局参数的定义 + */ +export type SankeyLayoutOptions = { + readonly nodeId?: (node: Datum) => any; + readonly value?: (node: Datum) => any; + readonly source?: (edge: Datum) => any; + readonly target?: (edge: Datum) => any; + // sankey.nodeSort(undefined) is the default and resorts by ascending breadth during each iteration. + // sankey.nodeSort(null) specifies the input order of nodes and never sorts. + // sankey.nodeSort(function) specifies the given order as a comparator function and sorts once on initialization. + readonly sort?: (a: Datum, b: Datum) => number; + readonly nodeAlign?: NodeAlign; + readonly nodeWidth?: number; + readonly nodePadding?: number; +}; + +/** + * 默认值 + */ +const DEFAULT_OPTIONS: Partial = { + nodeId: (node: Datum) => node.index, + value: (node: Datum) => node.value, + source: (edge: Datum) => edge.source, + target: (edge: Datum) => edge.target, + nodeAlign: 'justify', + nodeWidth: 0.008, + nodePadding: 0.03, + sort: undefined, +}; + +/** + * 获得 align function + * @param nodeAlign + */ +export function getNodeAlignFunction(nodeAlign: NodeAlign) { + const func = isString(nodeAlign) ? ALIGN_METHOD[nodeAlign] : isFunction(nodeAlign) ? nodeAlign : null; + + return func || sankeyJustify; +} + +export function getDefaultOptions(sankeyLayoutOptions: SankeyLayoutOptions) { + return assign({}, DEFAULT_OPTIONS, sankeyLayoutOptions); +} + +/** + * 桑基图利用数据进行布局的函数,最终返回节点、边的位置(0 - 1 的信息) + * 将会修改 data 数据 + * @param sankeyLayoutOptions + * @param data + */ +export function sankeyLayout( + sankeyLayoutOptions: SankeyLayoutOptions, + data: SankeyLayoutInputData +): SankeyLayoutOutPutData { + const options = getDefaultOptions(sankeyLayoutOptions); + + const { nodeId, sort, nodeAlign, nodeWidth, nodePadding } = options; + + const sankeyProcessor = sankey() + .nodeId(nodeId) + .nodeSort(sort) + .links((d: any) => d.links) + .nodeWidth(nodeWidth) + .nodePadding(nodePadding) + .nodeAlign(getNodeAlignFunction(nodeAlign)) + .extent([ + [0, 0], + [1, 1], + ]); + + // 进行桑基图布局处理 + const layoutData: SankeyLayoutOutPutData = sankeyProcessor(data); + + // post process (x, y), etc. + layoutData.nodes.forEach((node) => { + const { x0, x1, y0, y1 } = node; + /* points + * 3---2 + * | | + * 0---1 + */ + node.x = [x0, x1, x1, x0]; + node.y = [y0, y0, y1, y1]; + }); + + layoutData.links.forEach((edge) => { + const { source, target } = edge; + const sx = source.x1; + const tx = target.x0; + edge.x = [sx, sx, tx, tx]; + const offset = edge.width / 2; + edge.y = [edge.y0 + offset, edge.y0 - offset, edge.y1 + offset, edge.y1 - offset]; + }); + + return layoutData; +} From 25c6e9794675bdb52c6a4107841dbc6cf9381493 Mon Sep 17 00:00:00 2001 From: hustcc Date: Fri, 27 Nov 2020 13:10:21 +0800 Subject: [PATCH 3/8] test(sankey): add test for sankey transform --- __tests__/unit/utils/transform/sankey-spec.ts | 3 +++ src/utils/transform/sankey.ts | 9 +++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/__tests__/unit/utils/transform/sankey-spec.ts b/__tests__/unit/utils/transform/sankey-spec.ts index 44df487ced..3e48824239 100644 --- a/__tests__/unit/utils/transform/sankey-spec.ts +++ b/__tests__/unit/utils/transform/sankey-spec.ts @@ -14,6 +14,9 @@ describe('sankeyLayout', () => { const fn = jest.fn(); // @ts-ignore expect(getNodeAlignFunction(fn)).toBe(fn); + + // @ts-ignore + expect(getNodeAlignFunction(123)).toBe(sankeyJustify); }); it('getDefaultOptions', () => { diff --git a/src/utils/transform/sankey.ts b/src/utils/transform/sankey.ts index ed666baebb..1c528deff1 100644 --- a/src/utils/transform/sankey.ts +++ b/src/utils/transform/sankey.ts @@ -75,7 +75,7 @@ export type SankeyLayoutOptions = { // sankey.nodeSort(undefined) is the default and resorts by ascending breadth during each iteration. // sankey.nodeSort(null) specifies the input order of nodes and never sorts. // sankey.nodeSort(function) specifies the given order as a comparator function and sorts once on initialization. - readonly sort?: (a: Datum, b: Datum) => number; + readonly sort?: (a: any, b: any) => number; readonly nodeAlign?: NodeAlign; readonly nodeWidth?: number; readonly nodePadding?: number; @@ -86,9 +86,6 @@ export type SankeyLayoutOptions = { */ const DEFAULT_OPTIONS: Partial = { nodeId: (node: Datum) => node.index, - value: (node: Datum) => node.value, - source: (edge: Datum) => edge.source, - target: (edge: Datum) => edge.target, nodeAlign: 'justify', nodeWidth: 0.008, nodePadding: 0.03, @@ -124,7 +121,6 @@ export function sankeyLayout( const { nodeId, sort, nodeAlign, nodeWidth, nodePadding } = options; const sankeyProcessor = sankey() - .nodeId(nodeId) .nodeSort(sort) .links((d: any) => d.links) .nodeWidth(nodeWidth) @@ -133,7 +129,8 @@ export function sankeyLayout( .extent([ [0, 0], [1, 1], - ]); + ]) + .nodeId(nodeId); // 进行桑基图布局处理 const layoutData: SankeyLayoutOutPutData = sankeyProcessor(data); From 51316a8ef8f6ef59b9527007bf63090ba72a2a30 Mon Sep 17 00:00:00 2001 From: hustcc Date: Fri, 27 Nov 2020 21:31:07 +0800 Subject: [PATCH 4/8] feat(sankey): draw sankey by transform data & arc shape --- __tests__/data/sankey-energy.ts | 115 ++++++++++++++++++++++ __tests__/unit/plots/sankey/index-spec.ts | 17 ++++ src/index.ts | 3 + src/plots/sankey/adaptor.ts | 108 ++++++++++++++++++-- src/plots/sankey/constant.ts | 3 + src/plots/sankey/index.ts | 2 +- src/plots/sankey/util/data.ts | 61 ++++++++++++ src/plots/sankey/util/options.ts | 0 src/utils/transform/sankey.ts | 32 +++--- 9 files changed, 318 insertions(+), 23 deletions(-) create mode 100644 __tests__/unit/plots/sankey/index-spec.ts create mode 100644 src/plots/sankey/constant.ts create mode 100644 src/plots/sankey/util/data.ts create mode 100644 src/plots/sankey/util/options.ts diff --git a/__tests__/data/sankey-energy.ts b/__tests__/data/sankey-energy.ts index d7b0ff74d5..23923a5af1 100644 --- a/__tests__/data/sankey-energy.ts +++ b/__tests__/data/sankey-energy.ts @@ -120,3 +120,118 @@ export const ENERGY = { { source: 47, target: 15, value: 289.366 }, ], }; + +export const ENERGY_RELATIONS = [ + { source: "Agricultural 'waste'", target: 'Bio-conversion', value: 124.729 }, + { source: 'Bio-conversion', target: 'Liquid', value: 0.597 }, + { source: 'Bio-conversion', target: 'Losses', value: 26.862 }, + { source: 'Bio-conversion', target: 'Solid', value: 280.322 }, + { source: 'Bio-conversion', target: 'Gas', value: 81.144 }, + { source: 'Biofuel imports', target: 'Liquid', value: 35 }, + { source: 'Biomass imports', target: 'Solid', value: 35 }, + { source: 'Coal imports', target: 'Coal', value: 11.606 }, + { source: 'Coal reserves', target: 'Coal', value: 63.965 }, + { source: 'Coal', target: 'Solid', value: 75.571 }, + { source: 'District heating', target: 'Industry', value: 10.639 }, + { + source: 'District heating', + target: 'Heating and cooling - commercial', + value: 22.505, + }, + { + source: 'District heating', + target: 'Heating and cooling - homes', + value: 46.184, + }, + { + source: 'Electricity grid', + target: 'Over generation / exports', + value: 104.453, + }, + { + source: 'Electricity grid', + target: 'Heating and cooling - homes', + value: 113.726, + }, + { source: 'Electricity grid', target: 'H2 conversion', value: 27.14 }, + { source: 'Electricity grid', target: 'Industry', value: 342.165 }, + { source: 'Electricity grid', target: 'Road transport', value: 37.797 }, + { source: 'Electricity grid', target: 'Agriculture', value: 4.412 }, + { + source: 'Electricity grid', + target: 'Heating and cooling - commercial', + value: 40.858, + }, + { source: 'Electricity grid', target: 'Losses', value: 56.691 }, + { source: 'Electricity grid', target: 'Rail transport', value: 7.863 }, + { + source: 'Electricity grid', + target: 'Lighting & appliances - commercial', + value: 90.008, + }, + { + source: 'Electricity grid', + target: 'Lighting & appliances - homes', + value: 93.494, + }, + { source: 'Gas imports', target: 'Ngas', value: 40.719 }, + { source: 'Gas reserves', target: 'Ngas', value: 82.233 }, + { source: 'Gas', target: 'Heating and cooling - commercial', value: 0.129 }, + { source: 'Gas', target: 'Losses', value: 1.401 }, + { source: 'Gas', target: 'Thermal generation', value: 151.891 }, + { source: 'Gas', target: 'Agriculture', value: 2.096 }, + { source: 'Gas', target: 'Industry', value: 48.58 }, + { source: 'Geothermal', target: 'Electricity grid', value: 7.013 }, + { source: 'H2 conversion', target: 'H2', value: 20.897 }, + { source: 'H2 conversion', target: 'Losses', value: 6.242 }, + { source: 'H2', target: 'Road transport', value: 20.897 }, + { source: 'Hydro', target: 'Electricity grid', value: 6.995 }, + { source: 'Liquid', target: 'Industry', value: 121.066 }, + { source: 'Liquid', target: 'International shipping', value: 128.69 }, + { source: 'Liquid', target: 'Road transport', value: 135.835 }, + { source: 'Liquid', target: 'Domestic aviation', value: 14.458 }, + { source: 'Liquid', target: 'International aviation', value: 206.267 }, + { source: 'Liquid', target: 'Agriculture', value: 3.64 }, + { source: 'Liquid', target: 'National navigation', value: 33.218 }, + { source: 'Liquid', target: 'Rail transport', value: 4.413 }, + { source: 'Marine algae', target: 'Bio-conversion', value: 4.375 }, + { source: 'Ngas', target: 'Gas', value: 122.952 }, + { source: 'Nuclear', target: 'Thermal generation', value: 839.978 }, + { source: 'Oil imports', target: 'Oil', value: 504.287 }, + { source: 'Oil reserves', target: 'Oil', value: 107.703 }, + { source: 'Oil', target: 'Liquid', value: 611.99 }, + { source: 'Other waste', target: 'Solid', value: 56.587 }, + { source: 'Other waste', target: 'Bio-conversion', value: 77.81 }, + { + source: 'Pumped heat', + target: 'Heating and cooling - homes', + value: 193.026, + }, + { + source: 'Pumped heat', + target: 'Heating and cooling - commercial', + value: 70.672, + }, + { source: 'Solar PV', target: 'Electricity grid', value: 59.901 }, + { + source: 'Solar Thermal', + target: 'Heating and cooling - homes', + value: 19.263, + }, + { source: 'Solar', target: 'Solar Thermal', value: 19.263 }, + { source: 'Solar', target: 'Solar PV', value: 59.901 }, + { source: 'Solid', target: 'Agriculture', value: 0.882 }, + { source: 'Solid', target: 'Thermal generation', value: 400.12 }, + { source: 'Solid', target: 'Industry', value: 46.477 }, + { source: 'Thermal generation', target: 'Electricity grid', value: 525.531 }, + { source: 'Thermal generation', target: 'Losses', value: 787.129 }, + { source: 'Thermal generation', target: 'District heating', value: 79.329 }, + { source: 'Tidal', target: 'Electricity grid', value: 9.452 }, + { + source: 'UK land based bioenergy', + target: 'Bio-conversion', + value: 182.01, + }, + { source: 'Wave', target: 'Electricity grid', value: 19.013 }, + { source: 'Wind', target: 'Electricity grid', value: 289.366 }, +]; diff --git a/__tests__/unit/plots/sankey/index-spec.ts b/__tests__/unit/plots/sankey/index-spec.ts new file mode 100644 index 0000000000..690c318437 --- /dev/null +++ b/__tests__/unit/plots/sankey/index-spec.ts @@ -0,0 +1,17 @@ +import { Sankey } from '../../../../src'; +import { createDiv } from '../../../utils/dom'; +import { ENERGY_RELATIONS } from '../../../data/sankey-energy'; + +describe('sankey', () => { + it('Sankey', () => { + const sankey = new Sankey(createDiv(), { + height: 500, + data: ENERGY_RELATIONS, + sourceField: 'source', + targetField: 'target', + weightField: 'value', + }); + + sankey.render(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 0dc6066b06..8c7853e9e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,6 +94,9 @@ export { RadialBar, RadialBarOptions } from './plots/radial-bar'; // 对称条形图及类型定义 | author by [arcsin1](https://github.com/arcsin1) export { BidirectionalBar, BidirectionalBarOptions } from './plots/bidirectional-bar'; +// 桑基图及类型定义 | author by [hustcc](https://github.com/hustcc) +export { Sankey, SankeyOptions } from './plots/sankey'; + // 以下开放自定义图表开发的能力(目前仅仅是孵化中) /** 所有开放图表都使用 G2Plot.P 作为入口开发,理论上官方的所有图表都可以走 G2Plot.P 的入口(暂时不处理) */ export { P } from './plugin'; diff --git a/src/plots/sankey/adaptor.ts b/src/plots/sankey/adaptor.ts index 0f2d6d1302..4e0604679c 100644 --- a/src/plots/sankey/adaptor.ts +++ b/src/plots/sankey/adaptor.ts @@ -1,7 +1,10 @@ import { interaction, animation, theme, scale } from '../../adaptor/common'; import { Params } from '../../core/adaptor'; import { flow } from '../../utils'; +import { sankeyLayout } from '../../utils/transform/sankey'; import { SankeyOptions } from './types'; +import { transformDataToSankey } from './util/data'; +import { X_FIELD, Y_FIELD, COLOR_FIELD } from './constant'; /** * geometry 处理 @@ -9,17 +12,111 @@ import { SankeyOptions } from './types'; */ function geometry(params: Params): Params { const { chart, options } = params; - const { data, sourceField, targetField, weightField } = options; + const { + data, + sourceField, + targetField, + weightField, + nodeAlign, + nodePaddingRatio, + nodeWidthRatio, + nodeSort, + } = options; - chart.data(data); + // 1. 组件,优先设置,因为子 view 会继承配置 + chart.legend(false); + chart.tooltip({ + showTitle: false, + showMarkers: false, + }); + chart.axis(false); - // node view + // 2. 转换出 layout 前数据 + const sankeyLayoutInputData = transformDataToSankey(data, sourceField, targetField, weightField); + + console.log(111, sankeyLayoutInputData); + + // 3. layout 之后的数据 + const { nodes, links } = sankeyLayout( + { + nodeAlign, + nodePadding: nodePaddingRatio, + nodeWidth: nodeWidthRatio, + nodeSort, + }, + sankeyLayoutInputData + ); + + // 4. 生成绘图数据 + const nodesData = nodes.map((node) => { + return { + x: node.x, + y: node.y, + name: node.name, + }; + }); + 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, + }; + }); + + // 5. node edge views const nodeView = chart.createView(); - nodeView.data([]); + nodeView.data(nodesData); + + nodeView + .polygon() + .position(`${X_FIELD}*${Y_FIELD}`) + .color(COLOR_FIELD) + .style({ + opacity: 1, + fillOpacity: 1, + lineWidth: 1, + }) + .tooltip(false); // edge view const edgeView = chart.createView(); - edgeView.data([]); + edgeView.data(edgesData); + + edgeView + .edge() + .position(`${X_FIELD}*${Y_FIELD}`) + .shape('arc') + .color(COLOR_FIELD) + .style({ + opacity: 0.3, + lineWidth: 0, + }) + .tooltip('target*source*value', (target, source, value) => { + return { + name: source + ' to ' + target, + value, + }; + }) + .state({ + active: { + style: { + opacity: 0.8, + lineWidth: 0, + }, + }, + }); + + chart.interaction('element-active'); + + // scale + chart.scale({ + x: { sync: true, nice: true }, + y: { sync: true, nice: true }, + name: { sync: 'color' }, + }); return params; } @@ -33,7 +130,6 @@ export function adaptor(params: Params) { // flow 的方式处理所有的配置到 G2 API return flow( geometry, - scale({}), interaction, animation, theme diff --git a/src/plots/sankey/constant.ts b/src/plots/sankey/constant.ts new file mode 100644 index 0000000000..e6b0786c2a --- /dev/null +++ b/src/plots/sankey/constant.ts @@ -0,0 +1,3 @@ +export const X_FIELD = 'x'; +export const Y_FIELD = 'y'; +export const COLOR_FIELD = 'name'; diff --git a/src/plots/sankey/index.ts b/src/plots/sankey/index.ts index c7275b2dd1..3419b9fdc8 100644 --- a/src/plots/sankey/index.ts +++ b/src/plots/sankey/index.ts @@ -16,7 +16,7 @@ export class Sankey extends Plot { return { syncViewPadding: true, nodeWidthRatio: 0.008, - nodePaddingRatio: 0.03, + nodePaddingRatio: 0.01, tooltip: { showTitle: false, showMarkers: false, diff --git a/src/plots/sankey/util/data.ts b/src/plots/sankey/util/data.ts new file mode 100644 index 0000000000..83492aa2a3 --- /dev/null +++ b/src/plots/sankey/util/data.ts @@ -0,0 +1,61 @@ +import { Data, Datum } from '../../../types'; +import { SankeyLayoutInputData } from '../../../utils/transform/sankey'; + +/** + * 将数据序列转换成 sankey layout 需要的数据结构 + * 1. 过滤掉处理来源去向相同的节点 + * 2. 避免形成环 + * @param data + * @param sourceField + * @param targetField + * @param weightField + */ +export function transformDataToSankey( + data: Data, + sourceField: string, + targetField: string, + weightField: string +): SankeyLayoutInputData { + if (!Array.isArray(data)) { + return { + nodes: [], + links: [], + }; + } + + const nodes = []; + const links = []; + + // TODO 逍为 + // 1. 使用 Map 进行一些性能优化(目前 includes,indexOf 会大量进行数组遍历) + // 2. link 去重 + + // 数组变换成 sankey layout 的数据结构 + data.forEach((datum: Datum) => { + const source = datum[sourceField]; + const target = datum[targetField]; + const weight = datum[weightField]; + + // source node + if (!nodes.includes(source)) { + nodes.push(source); + } + + // target node + if (!nodes.includes(target)) { + nodes.push(target); + } + + // links + links.push({ + source: nodes.indexOf(source), + target: nodes.indexOf(target), + value: weight, + }); + }); + + return { + nodes: nodes.map((name) => ({ name })), + links, + }; +} diff --git a/src/plots/sankey/util/options.ts b/src/plots/sankey/util/options.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/utils/transform/sankey.ts b/src/utils/transform/sankey.ts index 1c528deff1..2472ed59e6 100644 --- a/src/utils/transform/sankey.ts +++ b/src/utils/transform/sankey.ts @@ -19,7 +19,7 @@ type InputLink = { readonly value: number; }; -type OutPutNode = { +type OutputNode = { readonly name: string; readonly x0: number; readonly x1: number; @@ -33,9 +33,9 @@ type OutPutNode = { y: number[]; }; -type OutPutLink = { - readonly source: OutPutNode; - readonly target: OutPutNode; +type OutputLink = { + readonly source: OutputNode; + readonly target: OutputNode; readonly value: number; readonly width: number; readonly y0: number; @@ -54,9 +54,9 @@ export type SankeyLayoutInputData = { readonly links: InputLink[]; }; -type SankeyLayoutOutPutData = { - readonly nodes: OutPutNode[]; - readonly links: OutPutLink[]; +type SankeyLayoutOutputData = { + readonly nodes: OutputNode[]; + readonly links: OutputLink[]; }; /** @@ -69,13 +69,13 @@ export type NodeAlign = keyof typeof ALIGN_METHOD; */ export type SankeyLayoutOptions = { readonly nodeId?: (node: Datum) => any; - readonly value?: (node: Datum) => any; - readonly source?: (edge: Datum) => any; - readonly target?: (edge: Datum) => any; + // readonly value?: (node: Datum) => any; + // readonly source?: (edge: Datum) => any; + // readonly target?: (edge: Datum) => any; // sankey.nodeSort(undefined) is the default and resorts by ascending breadth during each iteration. // sankey.nodeSort(null) specifies the input order of nodes and never sorts. // sankey.nodeSort(function) specifies the given order as a comparator function and sorts once on initialization. - readonly sort?: (a: any, b: any) => number; + readonly nodeSort?: (a: any, b: any) => number; readonly nodeAlign?: NodeAlign; readonly nodeWidth?: number; readonly nodePadding?: number; @@ -89,7 +89,7 @@ const DEFAULT_OPTIONS: Partial = { nodeAlign: 'justify', nodeWidth: 0.008, nodePadding: 0.03, - sort: undefined, + nodeSort: undefined, }; /** @@ -115,13 +115,13 @@ export function getDefaultOptions(sankeyLayoutOptions: SankeyLayoutOptions) { export function sankeyLayout( sankeyLayoutOptions: SankeyLayoutOptions, data: SankeyLayoutInputData -): SankeyLayoutOutPutData { +): SankeyLayoutOutputData { const options = getDefaultOptions(sankeyLayoutOptions); - const { nodeId, sort, nodeAlign, nodeWidth, nodePadding } = options; + const { nodeId, nodeSort, nodeAlign, nodeWidth, nodePadding } = options; const sankeyProcessor = sankey() - .nodeSort(sort) + .nodeSort(nodeSort) .links((d: any) => d.links) .nodeWidth(nodeWidth) .nodePadding(nodePadding) @@ -133,7 +133,7 @@ export function sankeyLayout( .nodeId(nodeId); // 进行桑基图布局处理 - const layoutData: SankeyLayoutOutPutData = sankeyProcessor(data); + const layoutData: SankeyLayoutOutputData = sankeyProcessor(data); // post process (x, y), etc. layoutData.nodes.forEach((node) => { From 82e6b22246867f5d09061e10e510b886c2cd5a88 Mon Sep 17 00:00:00 2001 From: hustcc Date: Wed, 2 Dec 2020 11:48:29 +0800 Subject: [PATCH 5/8] feat(sankey): adaptor & test case --- __tests__/unit/plots/sankey/index-spec.ts | 49 ++++++++++++ package.json | 2 +- src/adaptor/geometries/base.ts | 32 ++++++-- src/adaptor/geometries/edge.ts | 42 ++++++++++ src/adaptor/geometries/index.ts | 1 + src/plots/sankey/adaptor.ts | 97 ++++++++++++++--------- src/plots/sankey/index.ts | 37 ++++++++- 7 files changed, 215 insertions(+), 45 deletions(-) create mode 100644 src/adaptor/geometries/edge.ts diff --git a/__tests__/unit/plots/sankey/index-spec.ts b/__tests__/unit/plots/sankey/index-spec.ts index 690c318437..72d2cd5374 100644 --- a/__tests__/unit/plots/sankey/index-spec.ts +++ b/__tests__/unit/plots/sankey/index-spec.ts @@ -13,5 +13,54 @@ describe('sankey', () => { }); sankey.render(); + + // 默认值 + expect(sankey.options.nodeStyle).toEqual({ + opacity: 1, + fillOpacity: 1, + lineWidth: 1, + }); + expect(sankey.options.edgeStyle).toEqual({ + opacity: 0.3, + lineWidth: 0, + }); + + expect(sankey.options.nodeWidthRatio).toBe(0.008); + expect(sankey.options.nodePaddingRatio).toBe(0.01); + + expect(sankey.options.appendPadding).toEqual(8); + + // node + expect(sankey.chart.views[0].geometries[0].type).toBe('polygon'); + expect(sankey.chart.views[0].geometries[0].data.length).toBe(48); + expect(sankey.chart.views[0].geometries[0].data[0]).toEqual({ + name: "Agricultural 'waste'", + x: [0, 0.008, 0.008, 0], + y: [0.26075939300940637, 0.26075939300940637, 0.2963247055394385, 0.2963247055394385], + }); + + // edge + expect(sankey.chart.views[1].geometries[0].type).toBe('edge'); + expect(sankey.chart.views[1].geometries[0].data.length).toBe(68); + expect(sankey.chart.views[1].geometries[0].data[0]).toEqual({ + name: "Agricultural 'waste'", + source: "Agricultural 'waste'", + target: 'Bio-conversion', + value: 124.729, + x: [0.008, 0.008, 0.1417142857142857, 0.1417142857142857], + y: [0.2963247055394385, 0.26075939300940637, 0.3018199231660282, 0.26625461063599604], + }); + + // label + expect(sankey.chart.views[0].geometries[0].labelsContainer.getChildren().length).toBe(48); + expect(sankey.chart.views[0].geometries[0].labelsContainer.getChildByIndex(0).cfg.children[0].attr('text')).toBe( + "Agricultural 'waste'" + ); + expect(sankey.chart.views[1].geometries[0].labelsContainer.getChildren().length).toBe(0); + + // tooltip + sankey.chart.showTooltip({ x: 100, y: 100 }); + expect(document.querySelector('.g2-tooltip-name').textContent).toBe('Oil imports -> Oil'); + expect(document.querySelector('.g2-tooltip-value').textContent).toBe('504.287'); }); }); diff --git a/package.json b/package.json index 3b58928d9a..1c81e09f78 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "limit-size": [ { "path": "dist/g2plot.min.js", - "limit": "870 Kb" + "limit": "900 Kb" }, { "path": "dist/g2plot.min.js", diff --git a/src/adaptor/geometries/base.ts b/src/adaptor/geometries/base.ts index eec2d41b8f..21c244ccb6 100644 --- a/src/adaptor/geometries/base.ts +++ b/src/adaptor/geometries/base.ts @@ -2,6 +2,7 @@ import { uniq, isFunction, isObject, isString, isNumber, isEmpty } from '@antv/u import { Params } from '../../core/adaptor'; import { ColorAttr, ShapeAttr, SizeAttr, StyleAttr, TooltipAttr, Options, Datum } from '../../types'; import { Label } from '../../types/label'; +import { State } from '../../types/state'; import { transformLabel } from '../../utils'; /** @@ -26,7 +27,7 @@ export type MappingOptions = { */ export type Geometry = { /** geometry 类型, 'line' | 'interval' | 'point' | 'area' | 'polygon' */ - readonly type: string; + readonly type?: string; /** x 轴字段 */ readonly xField?: string; /** y 轴字段 */ @@ -44,10 +45,12 @@ export type Geometry = { /** 其他原始字段, 用于 mapping 回调参数 */ readonly rawFields?: string[]; /** 图形映射规则 */ - readonly mapping: MappingOptions; - /** geometry params */ - /** label 通道 */ + readonly mapping?: MappingOptions; + /** label 映射通道,因为历史原因导致实现略有区别 */ readonly label?: Label; + /** 不同状态的样式 */ + readonly state?: State; + /** geometry params */ readonly args?: any; }; @@ -119,7 +122,19 @@ export function getMappingFunction(mappingFields: string[], func: (datum: Datum) */ export function geometry(params: Params): Params { const { chart, options } = params; - const { type, args, mapping, xField, yField, colorField, shapeField, sizeField, tooltipFields, label } = options; + const { + type, + args, + mapping, + xField, + yField, + colorField, + shapeField, + sizeField, + tooltipFields, + label, + state, + } = options; // 如果没有 mapping 信息,那么直接返回 if (!mapping) { @@ -216,6 +231,13 @@ export function geometry(params: Params): Params f !== colorField) diff --git a/src/adaptor/geometries/edge.ts b/src/adaptor/geometries/edge.ts new file mode 100644 index 0000000000..7d06950f2a --- /dev/null +++ b/src/adaptor/geometries/edge.ts @@ -0,0 +1,42 @@ +import { Params } from '../../core/adaptor'; +import { getTooltipMapping } from '../../utils/tooltip'; +import { deepAssign } from '../../utils'; +import { geometry, MappingOptions, GeometryOptions } from './base'; + +export interface EdgeGeometryOptions extends GeometryOptions { + /** x 轴字段 */ + readonly xField?: string; + /** y 轴字段 */ + readonly yField?: string; + /** 分组颜色字段 */ + readonly seriesField?: string; + /** edge 图形映射规则 */ + readonly edge?: MappingOptions; +} + +/** + * edge 的配置处理 + * @param params + */ +export function edge(params: Params): Params { + const { options } = params; + const { edge, xField, yField, seriesField, tooltip } = options; + + const { fields, formatter } = getTooltipMapping(tooltip, [xField, yField, seriesField]); + + return edge + ? geometry( + deepAssign({}, params, { + options: { + type: 'edge', + colorField: seriesField, + tooltipFields: fields, + mapping: { + tooltip: formatter, + ...edge, + }, + }, + }) + ) + : params; +} diff --git a/src/adaptor/geometries/index.ts b/src/adaptor/geometries/index.ts index ac2ff32423..d4a580617c 100644 --- a/src/adaptor/geometries/index.ts +++ b/src/adaptor/geometries/index.ts @@ -3,3 +3,4 @@ export { line, LineGeometryOptions } from './line'; export { point, PointGeometryOptions } from './point'; export { interval, IntervalGeometryOptions } from './interval'; export { polygon, PolygonGeometryOptions } from './polygon'; +export { edge, EdgeGeometryOptions } from './edge'; diff --git a/src/plots/sankey/adaptor.ts b/src/plots/sankey/adaptor.ts index 4e0604679c..daebe8e4b2 100644 --- a/src/plots/sankey/adaptor.ts +++ b/src/plots/sankey/adaptor.ts @@ -2,6 +2,7 @@ import { interaction, animation, theme, scale } 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 { SankeyOptions } from './types'; import { transformDataToSankey } from './util/data'; import { X_FIELD, Y_FIELD, COLOR_FIELD } from './constant'; @@ -17,6 +18,11 @@ function geometry(params: Params): Params { sourceField, targetField, weightField, + color, + nodeStyle, + edgeStyle, + label, + tooltip, nodeAlign, nodePaddingRatio, nodeWidthRatio, @@ -25,17 +31,12 @@ function geometry(params: Params): Params { // 1. 组件,优先设置,因为子 view 会继承配置 chart.legend(false); - chart.tooltip({ - showTitle: false, - showMarkers: false, - }); + chart.tooltip(tooltip); chart.axis(false); // 2. 转换出 layout 前数据 const sankeyLayoutInputData = transformDataToSankey(data, sourceField, targetField, weightField); - console.log(111, sankeyLayoutInputData); - // 3. layout 之后的数据 const { nodes, links } = sankeyLayout( { @@ -70,44 +71,66 @@ function geometry(params: Params): Params { const nodeView = chart.createView(); nodeView.data(nodesData); - nodeView - .polygon() - .position(`${X_FIELD}*${Y_FIELD}`) - .color(COLOR_FIELD) - .style({ - opacity: 1, - fillOpacity: 1, - lineWidth: 1, - }) - .tooltip(false); + polygon({ + chart: nodeView, + options: { + xField: X_FIELD, + yField: Y_FIELD, + seriesField: COLOR_FIELD, + polygon: { + color, + style: nodeStyle, + }, + label, + tooltip: false, + }, + }); // edge view const edgeView = chart.createView(); edgeView.data(edgesData); - edgeView - .edge() - .position(`${X_FIELD}*${Y_FIELD}`) - .shape('arc') - .color(COLOR_FIELD) - .style({ - opacity: 0.3, - lineWidth: 0, - }) - .tooltip('target*source*value', (target, source, value) => { - return { - name: source + ' to ' + target, - value, - }; - }) - .state({ - active: { - style: { - opacity: 0.8, - lineWidth: 0, + edge({ + chart: edgeView, + // @ts-ignore + options: { + xField: X_FIELD, + yField: Y_FIELD, + seriesField: COLOR_FIELD, + edge: { + color, + style: edgeStyle, + shape: 'arc', + }, + tooltip, + state: { + active: { + style: { + opacity: 0.8, + lineWidth: 0, + }, }, }, - }); + }, + }); + + // edgeView + // .edge() + // .position(`${X_FIELD}*${Y_FIELD}`) + // .shape('arc') + // .color(COLOR_FIELD) + // .style(edgeStyle) + // .tooltip(tooltipFields.join('*'), (target, source, value) => { + // return formatter({ target, source, value }) + // }) + // .state({ + // active: { + // style: { + // opacity: 0.8, + // lineWidth: 0, + // }, + // }, + // }); chart.interaction('element-active'); diff --git a/src/plots/sankey/index.ts b/src/plots/sankey/index.ts index 3419b9fdc8..835202367a 100644 --- a/src/plots/sankey/index.ts +++ b/src/plots/sankey/index.ts @@ -1,5 +1,6 @@ import { Plot } from '../../core/plot'; import { Adaptor } from '../../core/adaptor'; +import { Datum } from '../../types'; import { SankeyOptions } from './types'; import { adaptor } from './adaptor'; @@ -14,13 +15,45 @@ export class Sankey extends Plot { protected getDefaultOptions() { return { + appendPadding: 8, syncViewPadding: true, - nodeWidthRatio: 0.008, - nodePaddingRatio: 0.01, + nodeStyle: { + opacity: 1, + fillOpacity: 1, + lineWidth: 1, + }, + edgeStyle: { + opacity: 0.3, + lineWidth: 0, + }, + label: { + fields: ['x', 'name'], + callback: (x: number[], name: string) => { + const isLast = x[1] === 1; // 最后一列靠边的节点 + return { + style: { + fill: '#545454', + textAlign: isLast ? 'end' : 'start', + }, + offsetX: isLast ? -8 : 8, + content: name, + }; + }, + }, tooltip: { showTitle: false, showMarkers: false, + fields: ['source', 'target', 'value'], + formatter: (datum: Datum) => { + const { source, target, value } = datum; + return { + name: source + ' -> ' + target, + value, + }; + }, }, + nodeWidthRatio: 0.008, + nodePaddingRatio: 0.01, }; } From 68fc46133554b9938fc304b2d4c36644e19d5b75 Mon Sep 17 00:00:00 2001 From: hustcc Date: Wed, 2 Dec 2020 15:26:12 +0800 Subject: [PATCH 6/8] feat(sankey): demo & docs --- __tests__/unit/plots/sankey/index-spec.ts | 64 +++++++++++++++++- docs/manual/plots/sankey.en.md | 6 ++ docs/manual/plots/sankey.zh.md | 80 +++++++++++++++++++++++ examples/sankey/basic/API.en.md | 2 + examples/sankey/basic/API.zh.md | 1 + examples/sankey/basic/demo/alipay.ts | 29 ++++++++ examples/sankey/basic/demo/energy.ts | 19 ++++++ examples/sankey/basic/demo/meta.json | 24 +++++++ examples/sankey/basic/index.en.md | 4 ++ examples/sankey/basic/index.zh.md | 4 ++ gatsby-config.js | 8 +++ src/plots/sankey/adaptor.ts | 18 ----- 12 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 docs/manual/plots/sankey.en.md create mode 100644 docs/manual/plots/sankey.zh.md create mode 100644 examples/sankey/basic/API.en.md create mode 100644 examples/sankey/basic/API.zh.md create mode 100644 examples/sankey/basic/demo/alipay.ts create mode 100644 examples/sankey/basic/demo/energy.ts create mode 100644 examples/sankey/basic/demo/meta.json create mode 100644 examples/sankey/basic/index.en.md create mode 100644 examples/sankey/basic/index.zh.md diff --git a/__tests__/unit/plots/sankey/index-spec.ts b/__tests__/unit/plots/sankey/index-spec.ts index 72d2cd5374..0cef08a0bf 100644 --- a/__tests__/unit/plots/sankey/index-spec.ts +++ b/__tests__/unit/plots/sankey/index-spec.ts @@ -1,9 +1,9 @@ -import { Sankey } from '../../../../src'; +import { Datum, Sankey } from '../../../../src'; import { createDiv } from '../../../utils/dom'; import { ENERGY_RELATIONS } from '../../../data/sankey-energy'; describe('sankey', () => { - it('Sankey', () => { + it('sankey', () => { const sankey = new Sankey(createDiv(), { height: 500, data: ENERGY_RELATIONS, @@ -62,5 +62,65 @@ describe('sankey', () => { sankey.chart.showTooltip({ x: 100, y: 100 }); expect(document.querySelector('.g2-tooltip-name').textContent).toBe('Oil imports -> Oil'); expect(document.querySelector('.g2-tooltip-value').textContent).toBe('504.287'); + + sankey.destroy(); + }); + + it('sankey style', () => { + const DATA = [ + { source: '首次打开', target: '首页 UV', value: 160 }, + { source: '结果页', target: '首页 UV', value: 40 }, + { source: '验证页', target: '首页 UV', value: 10 }, + { source: '我的', target: '首页 UV', value: 10 }, + { source: '朋友', target: '首页 UV', value: 8 }, + { source: '其他来源', target: '首页 UV', value: 27 }, + { source: '首页 UV', target: '理财', value: 30 }, + { source: '首页 UV', target: '扫一扫', value: 40 }, + { source: '首页 UV', target: '服务', value: 35 }, + { source: '首页 UV', target: '蚂蚁森林', value: 25 }, + { source: '首页 UV', target: '跳失', value: 10 }, + { source: '首页 UV', target: '借呗', value: 30 }, + { source: '首页 UV', target: '花呗', value: 40 }, + { source: '首页 UV', target: '其他流向', value: 45 }, + ]; + + let d = null; + const sankey = new Sankey(createDiv(), { + data: DATA, + sourceField: 'source', + targetField: 'target', + weightField: 'value', + nodeStyle: (datum: Datum) => { + d = datum; + return { + fill: 'red', + }; + }, + edgeStyle: { + fill: '#ccc', + fillOpacity: 0.5, + }, + }); + + sankey.render(); + + // @ts-ignore + expect(sankey.chart.views[1].geometries[0].styleOption.cfg).toEqual({ + fill: '#ccc', + fillOpacity: 0.5, + lineWidth: 0, + opacity: 0.3, + }); + + // @ts-ignore + expect(sankey.chart.views[0].geometries[0].styleOption.fields).toEqual(['x', 'y', 'name']); + + expect(d).toEqual({ + name: '其他流向', + x: [0.992, 1, 1, 0.992], + y: [0.8358823529411765, 0.8358823529411765, 1, 1], + }); + + // sankey.destroy(); }); }); diff --git a/docs/manual/plots/sankey.en.md b/docs/manual/plots/sankey.en.md new file mode 100644 index 0000000000..b0bb6d0d83 --- /dev/null +++ b/docs/manual/plots/sankey.en.md @@ -0,0 +1,6 @@ +--- +title: Sankey +order: 27 +--- + +`markdown:docs/manual/plots/sankey.zh.md` \ No newline at end of file diff --git a/docs/manual/plots/sankey.zh.md b/docs/manual/plots/sankey.zh.md new file mode 100644 index 0000000000..68649b49a6 --- /dev/null +++ b/docs/manual/plots/sankey.zh.md @@ -0,0 +1,80 @@ +--- +title: 桑基图 +order: 27 +--- + +### Plot Container + +`markdown:docs/common/chart-options.en.md` + +### Data Mapping + +#### data + +**required** _array object_ + +设置图表数据源。数据源为对象集合,例如:`[{ source: '支付宝首页', target: '花呗', value: 20 }, ...]`。 + +#### sourceField + +**required** _string_ + +设置桑基图的来源节点数据字段。比如针对上述数据,就是: `source`。 + +#### targetField + +**required** _string_ + +设置桑基图的目标节点数据字段。比如针对上述数据,就是: `target`。 + +#### weightField + +**required** _string_ + +设置节点之间关系的权重字段信息,数据越大,边越大。比如针对上述数据,就是: `value`。 + +### Geometry Style + +#### nodeStyle + +**optional** _StyleAttr | Function_ + +桑基图节点样式的配置。 + +#### edgeStyle + +**optional** _StyleAttr | Function_ + +桑基图变样式的配置。 + +`markdown:docs/common/color.en.md` + +#### nodeWidthRatio + +**optional** _number_ + +桑基图节点的宽度配置,0 ~ 1,参考画布的宽度,默认为 `0.008`。 + +#### nodeWidthPadding + +**optional** _number_ + +桑基图节点的之间垂直方向的间距,0 ~ 1,参考画布的高度,默认为 `0.01`。 + +#### nodeAlign + +**optional** _string_ + +桑基图节点的布局方向,默认为 `justify`,可以选择 'left' | 'right' | 'center' | 'justify' 四种方式。 + +### Event + +`markdown:docs/common/events.en.md` + +### Plot Method + +`markdown:docs/common/chart-methods.en.md` + +### Plot Theme + +`markdown:docs/common/theme.en.md` diff --git a/examples/sankey/basic/API.en.md b/examples/sankey/basic/API.en.md new file mode 100644 index 0000000000..f4fa85c225 --- /dev/null +++ b/examples/sankey/basic/API.en.md @@ -0,0 +1,2 @@ +`markdown:docs/manual/plots/sankey.en.md` + diff --git a/examples/sankey/basic/API.zh.md b/examples/sankey/basic/API.zh.md new file mode 100644 index 0000000000..f51ffaa1df --- /dev/null +++ b/examples/sankey/basic/API.zh.md @@ -0,0 +1 @@ +`markdown:docs/manual/plots/sankey.zh.md` diff --git a/examples/sankey/basic/demo/alipay.ts b/examples/sankey/basic/demo/alipay.ts new file mode 100644 index 0000000000..22aaf84bc8 --- /dev/null +++ b/examples/sankey/basic/demo/alipay.ts @@ -0,0 +1,29 @@ +import { Sankey } from '@antv/g2plot'; + +const DATA = [ + { source: '首次打开', target: '首页 UV', value: 160 }, + { source: '结果页', target: '首页 UV', value: 40 }, + { source: '验证页', target: '首页 UV', value: 10 }, + { source: '我的', target: '首页 UV', value: 10 }, + { source: '朋友', target: '首页 UV', value: 8 }, + { source: '其他来源', target: '首页 UV', value: 27 }, + { source: '首页 UV', target: '理财', value: 30 }, + { source: '首页 UV', target: '扫一扫', value: 40 }, + { source: '首页 UV', target: '服务', value: 35 }, + { source: '首页 UV', target: '蚂蚁森林', value: 25 }, + { source: '首页 UV', target: '跳失', value: 10 }, + { source: '首页 UV', target: '借呗', value: 30 }, + { source: '首页 UV', target: '花呗', value: 40 }, + { source: '首页 UV', target: '其他流向', value: 45 }, +]; + +const sankey = new Sankey('container', { + data: DATA, + sourceField: 'source', + targetField: 'target', + weightField: 'value', + nodeWidthRatio: 0.008, + nodePaddingRatio: 0.03, +}); + +sankey.render(); diff --git a/examples/sankey/basic/demo/energy.ts b/examples/sankey/basic/demo/energy.ts new file mode 100644 index 0000000000..08db5977fd --- /dev/null +++ b/examples/sankey/basic/demo/energy.ts @@ -0,0 +1,19 @@ +import { Sankey } from '@antv/g2plot'; + +fetch('https://gw.alipayobjects.com/os/bmw-prod/fa3414cc-75ed-47b4-8306-f2ffe8c40127.json') + .then((res) => res.json()) + .then((data) => { + const sankey = new Sankey('container', { + data, + sourceField: 'source', + targetField: 'target', + weightField: 'value', + color: ['red', 'green', 'yellow'], + edgeStyle: { + fill: '#ccc', + fillOpacity: 0.4, + }, + }); + + sankey.render(); + }); diff --git a/examples/sankey/basic/demo/meta.json b/examples/sankey/basic/demo/meta.json new file mode 100644 index 0000000000..695fa358b3 --- /dev/null +++ b/examples/sankey/basic/demo/meta.json @@ -0,0 +1,24 @@ +{ + "title": { + "zh": "中文分类", + "en": "Category" + }, + "demos": [ + { + "filename": "alipay.ts", + "title": { + "zh": "支付宝流量桑基图", + "en": "Alipay sankey" + }, + "screenshot": "https://gw.alipayobjects.com/mdn/rms_d314dd/afts/img/A*bLulSLk-VskAAAAAAAAAAAAAARQnAQ" + }, + { + "filename": "energy.ts", + "title": { + "zh": "能量关系桑基图", + "en": "Energy sankey" + }, + "screenshot": "https://gw.alipayobjects.com/mdn/rms_d314dd/afts/img/A*W-V0QYiLLbgAAAAAAAAAAAAAARQnAQ" + } + ] +} diff --git a/examples/sankey/basic/index.en.md b/examples/sankey/basic/index.en.md new file mode 100644 index 0000000000..dbd4cb69b0 --- /dev/null +++ b/examples/sankey/basic/index.en.md @@ -0,0 +1,4 @@ +--- +title: Sankey +order: 0 +--- diff --git a/examples/sankey/basic/index.zh.md b/examples/sankey/basic/index.zh.md new file mode 100644 index 0000000000..8440bc7de7 --- /dev/null +++ b/examples/sankey/basic/index.zh.md @@ -0,0 +1,4 @@ +--- +title: 桑基图 +order: 0 +--- diff --git a/gatsby-config.js b/gatsby-config.js index aa3e7bb7f8..2738ee0374 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -252,6 +252,14 @@ module.exports = { en: 'Radial Bar', }, }, + { + slug: 'sankey', + icon: 'sankey', + title: { + zh: '桑基图', + en: 'Sankey', + }, + }, { slug: 'bidirectional-bar', icon: 'bi-directional-bar', diff --git a/src/plots/sankey/adaptor.ts b/src/plots/sankey/adaptor.ts index daebe8e4b2..b93142369b 100644 --- a/src/plots/sankey/adaptor.ts +++ b/src/plots/sankey/adaptor.ts @@ -114,24 +114,6 @@ function geometry(params: Params): Params { }, }); - // edgeView - // .edge() - // .position(`${X_FIELD}*${Y_FIELD}`) - // .shape('arc') - // .color(COLOR_FIELD) - // .style(edgeStyle) - // .tooltip(tooltipFields.join('*'), (target, source, value) => { - // return formatter({ target, source, value }) - // }) - // .state({ - // active: { - // style: { - // opacity: 0.8, - // lineWidth: 0, - // }, - // }, - // }); - chart.interaction('element-active'); // scale From 9324328ba545b28824f824dbf76f9732233f6d19 Mon Sep 17 00:00:00 2001 From: hustcc Date: Wed, 2 Dec 2020 15:36:27 +0800 Subject: [PATCH 7/8] test(sankey): add test for data process --- __tests__/unit/plots/sankey/index-spec.ts | 2 +- __tests__/unit/plots/sankey/util/data-spec.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 __tests__/unit/plots/sankey/util/data-spec.ts diff --git a/__tests__/unit/plots/sankey/index-spec.ts b/__tests__/unit/plots/sankey/index-spec.ts index 0cef08a0bf..e605ee5f0d 100644 --- a/__tests__/unit/plots/sankey/index-spec.ts +++ b/__tests__/unit/plots/sankey/index-spec.ts @@ -121,6 +121,6 @@ describe('sankey', () => { y: [0.8358823529411765, 0.8358823529411765, 1, 1], }); - // sankey.destroy(); + sankey.destroy(); }); }); diff --git a/__tests__/unit/plots/sankey/util/data-spec.ts b/__tests__/unit/plots/sankey/util/data-spec.ts new file mode 100644 index 0000000000..aef6644710 --- /dev/null +++ b/__tests__/unit/plots/sankey/util/data-spec.ts @@ -0,0 +1,30 @@ +import { transformDataToSankey } from '../../../../../src/plots/sankey/util/data'; + +describe('sankey util', () => { + it('transformDataToSankey', () => { + // @ts-ignore + expect(transformDataToSankey({})).toEqual({ nodes: [], links: [] }); + // @ts-ignore + expect(transformDataToSankey(1)).toEqual({ nodes: [], links: [] }); + + expect( + transformDataToSankey( + [ + { source: '杭州', target: '上海', value: 1 }, + { source: '上海', target: '北京', value: 2 }, + { source: '杭州', target: '北京', value: 3 }, + ], + 'source', + 'target', + 'value' + ) + ).toEqual({ + nodes: [{ name: '杭州' }, { name: '上海' }, { name: '北京' }], + links: [ + { source: 0, target: 1, value: 1 }, + { source: 1, target: 2, value: 2 }, + { source: 0, target: 2, value: 3 }, + ], + }); + }); +}); From 8423f1ae7d63c9a25a20377cf9669b0b7e7cf8d4 Mon Sep 17 00:00:00 2001 From: hustcc Date: Wed, 2 Dec 2020 16:21:17 +0800 Subject: [PATCH 8/8] chore: remove unused file & import --- src/plots/sankey/adaptor.ts | 2 +- src/plots/sankey/util/options.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 src/plots/sankey/util/options.ts diff --git a/src/plots/sankey/adaptor.ts b/src/plots/sankey/adaptor.ts index b93142369b..e95b20304d 100644 --- a/src/plots/sankey/adaptor.ts +++ b/src/plots/sankey/adaptor.ts @@ -1,4 +1,4 @@ -import { interaction, animation, theme, scale } from '../../adaptor/common'; +import { interaction, animation, theme } from '../../adaptor/common'; import { Params } from '../../core/adaptor'; import { flow } from '../../utils'; import { sankeyLayout } from '../../utils/transform/sankey'; diff --git a/src/plots/sankey/util/options.ts b/src/plots/sankey/util/options.ts deleted file mode 100644 index e69de29bb2..0000000000