From 42fdab25e29e077145b91addd0bb3ad1f7e2dab8 Mon Sep 17 00:00:00 2001 From: Gavin Chen Date: Mon, 20 Nov 2023 16:42:00 +0800 Subject: [PATCH] feat(mark): add chord mark (#5810) * feat(mark): add chord mark * docs: add docs for chord mark --------- Co-authored-by: GavinChen --- __tests__/data/population-flow.json | 67 ++ .../static/populationFlowChordDefault.svg | 650 ++++++++++++++++++ __tests__/plots/static/index.ts | 1 + .../static/population-flow-chord-default.ts | 17 + __tests__/unit/lib/graph.spec.ts | 10 +- __tests__/unit/lib/std.spec.ts | 2 + site/docs/spec/mark/chord.en.md | 5 + site/docs/spec/mark/chord.zh.md | 45 ++ site/examples/graph/network/demo/chord.ts | 90 +++ site/examples/graph/network/demo/meta.json | 8 + src/data/utils/arc/arc.ts | 8 +- src/lib/graph.ts | 3 +- src/mark/chord.ts | 159 +++++ src/mark/index.ts | 2 + src/spec/mark.ts | 31 + 15 files changed, 1092 insertions(+), 6 deletions(-) create mode 100644 __tests__/data/population-flow.json create mode 100644 __tests__/integration/snapshots/static/populationFlowChordDefault.svg create mode 100644 __tests__/plots/static/population-flow-chord-default.ts create mode 100644 site/docs/spec/mark/chord.en.md create mode 100644 site/docs/spec/mark/chord.zh.md create mode 100644 site/examples/graph/network/demo/chord.ts create mode 100644 src/mark/chord.ts diff --git a/__tests__/data/population-flow.json b/__tests__/data/population-flow.json new file mode 100644 index 0000000000..a5621bcabd --- /dev/null +++ b/__tests__/data/population-flow.json @@ -0,0 +1,67 @@ +[ + { + "source": "北京", + "target": "天津", + "value": 30 + }, + { + "source": "北京", + "target": "上海", + "value": 80 + }, + { + "source": "北京", + "target": "河北", + "value": 46 + }, + { + "source": "北京", + "target": "辽宁", + "value": 49 + }, + { + "source": "北京", + "target": "黑龙江", + "value": 69 + }, + { + "source": "北京", + "target": "吉林", + "value": 19 + }, + { + "source": "天津", + "target": "河北", + "value": 62 + }, + { + "source": "天津", + "target": "辽宁", + "value": 82 + }, + { + "source": "天津", + "target": "上海", + "value": 16 + }, + { + "source": "上海", + "target": "黑龙江", + "value": 16 + }, + { + "source": "河北", + "target": "黑龙江", + "value": 76 + }, + { + "source": "河北", + "target": "内蒙古", + "value": 24 + }, + { + "source": "内蒙古", + "target": "北京", + "value": 32 + } +] \ No newline at end of file diff --git a/__tests__/integration/snapshots/static/populationFlowChordDefault.svg b/__tests__/integration/snapshots/static/populationFlowChordDefault.svg new file mode 100644 index 0000000000..75ad1ff3f2 --- /dev/null +++ b/__tests__/integration/snapshots/static/populationFlowChordDefault.svg @@ -0,0 +1,650 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 北京 + + + + + + + + + + + + + 天津 + + + + + + + + + + + + + 上海 + + + + + + + + + + + + + 河北 + + + + + + + + + + + + + 内蒙古 + + + + + + + + + + + + + 辽宁 + + + + + + + + + + + + + 黑龙江 + + + + + + + + + + + + + 吉林 + + + + + + + + + + + + \ No newline at end of file diff --git a/__tests__/plots/static/index.ts b/__tests__/plots/static/index.ts index 782de7d823..43e8cbff1c 100644 --- a/__tests__/plots/static/index.ts +++ b/__tests__/plots/static/index.ts @@ -306,3 +306,4 @@ export { mockLineZeroY } from './mock-line-zero-y'; export { mockLineCloseX } from './mock-line-close-x'; export { premierLeagueTable } from './premier-league-table'; export { singlePointBasic } from './single-point-basic'; +export { populationFlowChordDefault } from './population-flow-chord-default'; diff --git a/__tests__/plots/static/population-flow-chord-default.ts b/__tests__/plots/static/population-flow-chord-default.ts new file mode 100644 index 0000000000..3bba53d649 --- /dev/null +++ b/__tests__/plots/static/population-flow-chord-default.ts @@ -0,0 +1,17 @@ +import { G2Spec } from '../../../src'; + +export async function populationFlowChordDefault(): Promise { + return { + type: 'chord', + data: { + type: 'fetch', + value: 'data/population-flow.json', + transform: [ + { + type: 'custom', + callback: (d) => ({ links: d }), + }, + ], + }, + }; +} diff --git a/__tests__/unit/lib/graph.spec.ts b/__tests__/unit/lib/graph.spec.ts index 576f9ffaa2..3b1463d332 100644 --- a/__tests__/unit/lib/graph.spec.ts +++ b/__tests__/unit/lib/graph.spec.ts @@ -1,5 +1,12 @@ import { graphlib } from '../../../src/lib'; -import { ForceGraph, Tree, Sankey, Treemap, Pack } from '../../../src/mark'; +import { + ForceGraph, + Tree, + Sankey, + Treemap, + Pack, + Chord, +} from '../../../src/mark'; import { Cluster, Arc } from '../../../src/data'; describe('graphlib', () => { @@ -10,6 +17,7 @@ describe('graphlib', () => { 'mark.forceGraph': ForceGraph, 'mark.tree': Tree, 'mark.sankey': Sankey, + 'mark.chord': Chord, 'mark.treemap': Treemap, 'mark.pack': Pack, }); diff --git a/__tests__/unit/lib/std.spec.ts b/__tests__/unit/lib/std.spec.ts index 4d91192fe2..d816d17cc6 100644 --- a/__tests__/unit/lib/std.spec.ts +++ b/__tests__/unit/lib/std.spec.ts @@ -42,6 +42,7 @@ import { Density as DensityGeometry, Heatmap, Liquid, + Chord, } from '../../../src/mark'; import { Category10, Category20 } from '../../../src/palette'; import { @@ -248,6 +249,7 @@ describe('stdlib', () => { 'mark.rangeX': RangeX, 'mark.rangeY': RangeY, 'mark.sankey': Sankey, + 'mark.chord': Chord, 'mark.path': Path, 'mark.treemap': Treemap, 'mark.pack': PackGeometry, diff --git a/site/docs/spec/mark/chord.en.md b/site/docs/spec/mark/chord.en.md new file mode 100644 index 0000000000..dd64e07b09 --- /dev/null +++ b/site/docs/spec/mark/chord.en.md @@ -0,0 +1,5 @@ +--- +title: chord +order: 1 +--- +`` diff --git a/site/docs/spec/mark/chord.zh.md b/site/docs/spec/mark/chord.zh.md new file mode 100644 index 0000000000..183f1e3332 --- /dev/null +++ b/site/docs/spec/mark/chord.zh.md @@ -0,0 +1,45 @@ +--- +title: chord +order: 1 +--- +弦图(Chord diagram)是一种用于可视化关系和连接的图表形式。它主要用于展示多个实体之间的相互关系、联系的强度或流量的分布。 + +## 开始使用 + +chord + +```js +import { Chart } from '@antv/g2'; + +const chart = new Chart({ + container: 'container', + autoFit: true, +}); + +chart.chord().data({ + type: 'fetch', + value: 'https://assets.antv.antgroup.com/g2/population-flow.json', + transform: [ + { + type: 'custom', + callback: (d) => ({ links: d }), + }, + ], +}); + +chart.render(); +``` + +## 选项 + +| 属性 | 描述 | 类型 | 默认值 | +| ---------------- | --------------------------------------------------------- | ----------------------------- | ----------------------------- | +| y | 布局时y轴的坐标 | `number` | `0` | +| id | 节点的键 | `Function` | `(node) => node.key` | +| source | 设置弦图的来源节点数据字段 | `Function` | `(node) => node.source` | +| target | 设置弦图的目标节点数据字段 | `Function` | `(node) => node.target` | +| sourceWeight | 来源的权重 | `Function` | `(node) => node.value \|\| 1` | +| targetWeight | 目标的权重 | `Function` | `(node) => node.value \|\| 1` | +| sortBy | 排序方法,可选id, weight, frequency排序或者自定义排序方法 | `string \| Function` | - | +| nodeWidthRatio | 弦图节点的宽度配置,0 ~ 1,参考画布的宽度 | `number`  | `0.05` | +| nodePaddingRatio | 弦图节点之间的间距,0 ~ 1,参考画布的高度 | `number` | `0.1` | diff --git a/site/examples/graph/network/demo/chord.ts b/site/examples/graph/network/demo/chord.ts new file mode 100644 index 0000000000..6d7a7d6df7 --- /dev/null +++ b/site/examples/graph/network/demo/chord.ts @@ -0,0 +1,90 @@ +import { Chart } from '@antv/g2'; +import { schemeTableau10 } from 'd3-scale-chromatic'; + +const chart = new Chart({ + container: 'container', + width: 900, + height: 600, +}); + +const data = [ + { + source: '北京', + target: '天津', + value: 30, + }, + { + source: '北京', + target: '上海', + value: 80, + }, + { + source: '北京', + target: '河北', + value: 46, + }, + { + source: '北京', + target: '辽宁', + value: 49, + }, + { + source: '北京', + target: '黑龙江', + value: 69, + }, + { + source: '北京', + target: '吉林', + value: 19, + }, + { + source: '天津', + target: '河北', + value: 62, + }, + { + source: '天津', + target: '辽宁', + value: 82, + }, + { + source: '天津', + target: '上海', + value: 16, + }, + { + source: '上海', + target: '黑龙江', + value: 16, + }, + { + source: '河北', + target: '黑龙江', + value: 76, + }, + { + source: '河北', + target: '内蒙古', + value: 24, + }, + { + source: '内蒙古', + target: '北京', + value: 32, + }, +]; + +chart + .chord() + .data({ + value: { links: data }, + }) + .layout({ + nodeWidthRatio: 0.05, + }) + .scale('color', { range: schemeTableau10 }) + .style('labelFontSize', 15) + .style('linkFillOpacity', 0.6); + +chart.render(); diff --git a/site/examples/graph/network/demo/meta.json b/site/examples/graph/network/demo/meta.json index 55465d08e0..262b20d7a2 100644 --- a/site/examples/graph/network/demo/meta.json +++ b/site/examples/graph/network/demo/meta.json @@ -12,6 +12,14 @@ }, "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*dACBR7ANcfEAAAAAAAAAAAAADmJ7AQ/original" }, + { + "filename": "chord.ts", + "title": { + "zh": "弦图", + "en": "Chord" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*x6h_RZR7r0QAAAAAAAAAAAAADmJ7AQ/original" + }, { "filename": "forceGraph.ts", "title": { diff --git a/src/data/utils/arc/arc.ts b/src/data/utils/arc/arc.ts index 01d74ed51c..566257f81b 100644 --- a/src/data/utils/arc/arc.ts +++ b/src/data/utils/arc/arc.ts @@ -190,16 +190,16 @@ export function Arc(options?: ArcOptions) { */ sourceEdges.map((edge) => { const w = (edge.sourceWeight / value) * width; - edge.x[0] = node.x[0] + offset; - edge.x[1] = node.x[0] + offset + w; + edge.x[0] = x[0] + offset; + edge.x[1] = x[0] + offset + w; offset += w; }); targetEdges.forEach((edge) => { const w = (edge.targetWeight / value) * width; - edge.x[3] = node.x[0] + offset; - edge.x[2] = node.x[0] + offset + w; + edge.x[3] = x[0] + offset; + edge.x[2] = x[0] + offset + w; offset += w; }); diff --git a/src/lib/graph.ts b/src/lib/graph.ts index e388235221..30c9a85b9d 100644 --- a/src/lib/graph.ts +++ b/src/lib/graph.ts @@ -1,4 +1,4 @@ -import { Sankey, Treemap, Pack, ForceGraph, Tree } from '../mark'; +import { Sankey, Treemap, Pack, ForceGraph, Tree, Chord } from '../mark'; import { Arc, Cluster } from '../data'; export function graphlib() { @@ -9,6 +9,7 @@ export function graphlib() { 'mark.tree': Tree, 'mark.pack': Pack, 'mark.sankey': Sankey, + 'mark.chord': Chord, 'mark.treemap': Treemap, } as const; } diff --git a/src/mark/chord.ts b/src/mark/chord.ts new file mode 100644 index 0000000000..26d5431ca3 --- /dev/null +++ b/src/mark/chord.ts @@ -0,0 +1,159 @@ +import { deepMix } from '@antv/util'; +import { CompositeMarkComponent as CC, MarkOptions } from '../runtime'; +import { ChordMark } from '../spec'; +import { subObject } from '../utils/helper'; +import { subTooltip, maybeAnimation } from '../utils/mark'; +import { Arc } from '../data/arc'; +import { ArcOptions } from '../data/utils/arc/types'; +import { field, initializeData } from './utils'; + +const DEFAULT_LAYOUT_OPTIONS: ArcOptions = { + y: 0, + thickness: 0.05, // width of the node, (0, 1) + marginRatio: 0.1, // margin ratio, [0, 1) + id: (node) => node.key, + source: (edge) => edge.source, + target: (edge) => edge.target, + sourceWeight: (edge) => edge.value || 1, + targetWeight: (edge) => edge.value || 1, + sortBy: null, // optional, id | weight | frequency | {function} +}; + +const DEFAULT_NODE_OPTIONS = { + type: 'polygon', + axis: false, + legend: false, + encode: { + shape: 'polygon', + x: 'x', + y: 'y', + }, + scale: { + x: { type: 'identity' }, + y: { type: 'identity' }, + }, + style: { + opacity: 1, + fillOpacity: 1, + lineWidth: 1, + }, +}; + +const DEFAULT_LINK_OPTIONS = { + type: 'polygon', + axis: false, + legend: false, + encode: { + shape: 'ribbon', + x: 'x', + y: 'y', + }, + style: { + opacity: 0.5, + lineWidth: 1, + strokeWidth: 1, + }, +}; + +const DEFAULT_LABEL_OPTIONS = { + position: 'outside', + fontSize: 10, +}; + +export type ChordOptions = Omit; + +export const Chord: CC = (options, context) => { + const { + data, + encode = {}, + scale, + style = {}, + layout = {}, + nodeLabels = [], + linkLabels = [], + animate = {}, + tooltip = {}, + } = options; + + // Initialize data, generating nodes by link if is not specified. + const { nodes, links } = initializeData(data, encode); + + // Extract encode for node and link. + const nodeEncode = subObject(encode, 'node'); + const linkEncode = subObject(encode, 'link'); + const { key: nodeKey = (d) => d.key, color = nodeKey } = nodeEncode; + const { linkEncodeColor = (d) => d.source } = linkEncode; + const { + nodeWidthRatio = DEFAULT_LAYOUT_OPTIONS.thickness, + nodePaddingRatio = DEFAULT_LAYOUT_OPTIONS.marginRatio, + ...restLayout + } = layout; + + const { nodes: nodeData, edges: linkData } = Arc({ + ...DEFAULT_LAYOUT_OPTIONS, + id: field(nodeKey), + thickness: nodeWidthRatio, + marginRatio: nodePaddingRatio, + ...restLayout, + weight: true, + })({ nodes, edges: links }); + + // Extract label style and apply defaults. + const { text = nodeKey, ...labelStyle } = subObject(style, 'label'); + + const nodeTooltip = subTooltip( + tooltip, + 'node', + { + title: '', + items: [(d) => ({ name: d.key, value: d.value })], + }, + true, + ); + const linkTooltip = subTooltip(tooltip, 'link', { + title: '', + items: [(d) => ({ name: `${d.source} -> ${d.target}`, value: d.value })], + }); + + const { height, width } = context; + + const minimumLen = Math.min(height, width); + + return [ + deepMix({}, DEFAULT_LINK_OPTIONS, { + data: linkData, + encode: { ...linkEncode, color: linkEncodeColor }, + labels: linkLabels, + style: { + fill: linkEncodeColor ? undefined : '#aaa', + ...subObject(style, 'link'), + }, + tooltip: linkTooltip, + animate: maybeAnimation(animate, 'link'), + }), + deepMix({}, DEFAULT_NODE_OPTIONS, { + data: nodeData, + encode: { ...nodeEncode, color }, + scale, + style: subObject(style, 'node'), + coordinate: { + type: 'polar', + // Leave enough rendering space for the label. + outerRadius: (minimumLen - 20) / minimumLen, + }, + labels: [ + { + ...DEFAULT_LABEL_OPTIONS, + text, + ...labelStyle, + }, + ...nodeLabels, + ], + tooltip: nodeTooltip, + animate: maybeAnimation(animate, 'node'), + axis: false, + }), + ] as MarkOptions[]; +}; + +Chord.props = {}; diff --git a/src/mark/index.ts b/src/mark/index.ts index 5982eb0c25..766a6b305d 100644 --- a/src/mark/index.ts +++ b/src/mark/index.ts @@ -17,6 +17,7 @@ export { Range } from './range'; export { RangeX } from './rangeX'; export { RangeY } from './rangeY'; export { Sankey } from './sankey'; +export { Chord } from './chord'; export { Path } from './path'; export { Treemap } from './treemap'; export { Pack } from './pack'; @@ -49,6 +50,7 @@ export type { RangeOptions } from './range'; export type { RangeXOptions } from './rangeX'; export type { RangeYOptions } from './rangeY'; export type { SankeyOptions } from './sankey'; +export type { ChordOptions } from './chord'; export type { TreemapOptions } from './treemap'; export type { PackOptions } from './pack'; export type { ShapeOptions } from './shape'; diff --git a/src/spec/mark.ts b/src/spec/mark.ts index aef3554d10..0839aeca13 100644 --- a/src/spec/mark.ts +++ b/src/spec/mark.ts @@ -38,6 +38,7 @@ export type Mark = | RangeYMark | ConnectorMark | SankeyMark + | ChordMark | PathMark | TreemapMark | PackMark @@ -73,6 +74,7 @@ export type MarkTypes = | 'rangeX' | 'rangeY' | 'sankey' + | 'chord' | 'path' | 'treemap' | 'pack' @@ -288,6 +290,35 @@ export type SankeyMark = BaseMark< linkLabels: Record[]; }; +export type ChordMark = BaseMark< + 'chord', + | 'source' + | 'target' + | 'value' + | `node${Capitalize}` + | `link${Capitalize}` + | ChannelTypes +> & { + layout?: { + nodes?: (graph: any) => any; + links?: (graph: any) => any; + y?: number; + id?: (node: any) => any; + sortBy?: + | 'id' + | 'weight' + | 'frequency' + | null + | ((a: any, b: any) => number); + nodeWidthRatio?: number; + nodePaddingRatio?: number; + sourceWeight?(edge: any): number; + targetWeight?(edge: any): number; + }; + nodeLabels: Record[]; + linkLabels: Record[]; +}; + export type PathMark = BaseMark<'path', ChannelTypes | 'd'>; export type TreemapMark = BaseMark<'treemap', 'value' | ChannelTypes> & {