From 59ea3de2dd38132c22893c413b1dbf6d04137cf3 Mon Sep 17 00:00:00 2001 From: Yuxin <55794321+yvonneyx@users.noreply.github.com> Date: Tue, 12 Nov 2024 11:35:04 +0800 Subject: [PATCH] feat: fishbone layout and case demos (#6499) * feat: fishbone layout * test: fishbone unit tests * fix: update layout case demo * test: update snapshots * chore: update deps * docs: add site demo * docs: treegraph scene case * docs: add site demo * docs: update demos * test: update snapshots --- package.json | 6 +- packages/g6-extension-3d/package.json | 8 +- packages/g6-ssr/package.json | 2 +- packages/g6/__tests__/demos/case-fishbone.ts | 201 +++++---- packages/g6/__tests__/demos/index.ts | 1 + .../g6/__tests__/demos/layout-fishbone.ts | 109 +++++ .../layouts/fishbone/direction-LR.svg | 398 ++++++++++++++++++ .../layouts/fishbone/direction-RL.svg | 398 ++++++++++++++++++ .../layouts/fishbone/vGap-32-hGap-32.svg | 398 ++++++++++++++++++ .../__tests__/unit/layouts/fishbone.spec.ts | 31 ++ packages/g6/package.json | 4 +- packages/g6/src/exports.ts | 2 + packages/g6/src/layouts/fishbone.ts | 295 +++++++++++++ packages/g6/src/layouts/index.ts | 2 + packages/g6/src/registry/build-in.ts | 4 +- .../scene-case/default/demo/fishbone.js | 189 --------- .../scene-case/default/demo/meta.json | 40 -- .../demo/anti-procrastination-fishbone.js | 189 +++++++++ .../demo/indented-tree.js | 0 .../scene-case/tree-graph/demo/meta.json | 56 +++ .../{default => tree-graph}/demo/mindmap.js | 98 +++-- .../tree-graph/demo/product-fishbone.js | 203 +++++++++ .../demo/radial-compact-tree.js} | 25 +- .../demo/radial-dendrogram.js} | 41 +- .../scene-case/tree-graph/index.en.md | 3 + .../scene-case/tree-graph/index.zh.md | 3 + packages/site/package.json | 2 +- .../site/src/constants/locales/page-name.json | 1 + 28 files changed, 2278 insertions(+), 431 deletions(-) create mode 100644 packages/g6/__tests__/demos/layout-fishbone.ts create mode 100644 packages/g6/__tests__/snapshots/layouts/fishbone/direction-LR.svg create mode 100644 packages/g6/__tests__/snapshots/layouts/fishbone/direction-RL.svg create mode 100644 packages/g6/__tests__/snapshots/layouts/fishbone/vGap-32-hGap-32.svg create mode 100644 packages/g6/__tests__/unit/layouts/fishbone.spec.ts create mode 100644 packages/g6/src/layouts/fishbone.ts delete mode 100644 packages/site/examples/scene-case/default/demo/fishbone.js create mode 100644 packages/site/examples/scene-case/tree-graph/demo/anti-procrastination-fishbone.js rename packages/site/examples/scene-case/{default => tree-graph}/demo/indented-tree.js (100%) create mode 100644 packages/site/examples/scene-case/tree-graph/demo/meta.json rename packages/site/examples/scene-case/{default => tree-graph}/demo/mindmap.js (86%) create mode 100644 packages/site/examples/scene-case/tree-graph/demo/product-fishbone.js rename packages/site/examples/scene-case/{default/demo/radial-dendrogram.js => tree-graph/demo/radial-compact-tree.js} (74%) rename packages/site/examples/scene-case/{default/demo/radial-compact-tree.js => tree-graph/demo/radial-dendrogram.js} (57%) create mode 100644 packages/site/examples/scene-case/tree-graph/index.en.md create mode 100644 packages/site/examples/scene-case/tree-graph/index.zh.md diff --git a/package.json b/package.json index 53af39e121a..2e47b625817 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,8 @@ ] }, "devDependencies": { - "@antv/g-canvas": "^2.0.23", - "@antv/g-plugin-rough-canvas-renderer": "^2.0.23", + "@antv/g-canvas": "^2.0.24", + "@antv/g-plugin-rough-canvas-renderer": "^2.0.24", "@babel/core": "^7.26.0", "@babel/plugin-transform-typescript": "^7.25.9", "@changesets/cli": "^2.27.9", @@ -69,7 +69,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-packagejson": "^2.5.3", "rimraf": "^5.0.10", - "rollup": "^4.24.4", + "rollup": "^4.25.0", "rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-visualizer": "^5.12.0", "stats.js": "^0.17.0", diff --git a/packages/g6-extension-3d/package.json b/packages/g6-extension-3d/package.json index 4481d65a344..8f495d3549d 100644 --- a/packages/g6-extension-3d/package.json +++ b/packages/g6-extension-3d/package.json @@ -34,16 +34,16 @@ }, "dependencies": { "@antv/g-device-api": "^1.6.13", - "@antv/g-plugin-3d": "^2.0.25", - "@antv/g-plugin-device-renderer": "^2.2.2", + "@antv/g-plugin-3d": "^2.0.26", + "@antv/g-plugin-device-renderer": "^2.2.3", "@antv/g-plugin-dragndrop": "^2.0.18", - "@antv/g-webgl": "^2.0.27", + "@antv/g-webgl": "^2.0.28", "@antv/layout": "1.2.14-beta.8", "@antv/util": "^3.3.10" }, "devDependencies": { "@antv/g": "^6.1.7", - "@antv/g-canvas": "^2.0.23", + "@antv/g-canvas": "^2.0.24", "@antv/g6": "workspace:*" }, "peerDependencies": { diff --git a/packages/g6-ssr/package.json b/packages/g6-ssr/package.json index f2e6ebdcb16..40f15f5af96 100644 --- a/packages/g6-ssr/package.json +++ b/packages/g6-ssr/package.json @@ -28,7 +28,7 @@ }, "dependencies": { "@antv/g": "^6.1.7", - "@antv/g-canvas": "^2.0.23", + "@antv/g-canvas": "^2.0.24", "@antv/g6": "workspace:*", "cac": "^6.7.14", "canvas": "^2.11.2" diff --git a/packages/g6/__tests__/demos/case-fishbone.ts b/packages/g6/__tests__/demos/case-fishbone.ts index 8b03aa1d995..72fa48d253f 100644 --- a/packages/g6/__tests__/demos/case-fishbone.ts +++ b/packages/g6/__tests__/demos/case-fishbone.ts @@ -1,7 +1,16 @@ import type { TextStyleProps } from '@antv/g'; import { Text } from '@antv/g'; -import type { EdgeData, GraphData, NodeData } from '@antv/g6'; -import { BaseLayout, ExtensionCategory, Graph, register, treeToGraphData } from '@antv/g6'; +import { + BaseTransform, + BaseTransformOptions, + CategoricalPalette, + DrawData, + ExtensionCategory, + Graph, + register, + RuntimeContext, + treeToGraphData, +} from '@antv/g6'; const data = { id: '克服拖延', @@ -30,114 +39,94 @@ const data = { ], }; -const palette = [ - '#1783FF', - '#00C9C9', - '#F08F56', - '#D580FF', - '#7863FF', - '#DB9D0D', - '#60C42D', - '#FF80CA', - '#2491B3', - '#17C76F', -]; +interface AssignColorByBranchOptions extends BaseTransformOptions { + colors?: CategoricalPalette; +} export const caseFishbone: TestCase = async (context) => { - const assignElementStyle = (element: any, style: any) => { - return { ...element, style: { ...(element.style || {}), ...style } }; + let textShape: Text | null; + const measureText = (style: TextStyleProps) => { + if (!textShape) textShape = new Text({ style }); + textShape.attr(style); + return textShape.getBBox().width; }; - class FishboneLayout extends BaseLayout { - id = 'fishbone'; + class AssignColorByBranch extends BaseTransform { + static defaultOptions: Partial = { + colors: [ + '#1783FF', + '#F08F56', + '#D580FF', + '#00C9C9', + '#7863FF', + '#DB9D0D', + '#60C42D', + '#FF80CA', + '#2491B3', + '#17C76F', + ], + }; - async execute(data: GraphData, options: any): Promise { - const { rankSep = 30, branchSep = 30, nodeSep = 48, size = 32 } = { ...this.options, ...options }; + constructor(context: RuntimeContext, options: AssignColorByBranchOptions) { + super(context, Object.assign({}, AssignColorByBranch.defaultOptions, options)); + } - const { model } = this.context; - const root = model.getRootsData()[0]; - Object.assign(root.style || {}, { x: 0, y: 0 }); - const rootSize = typeof size === 'function' ? size(root) : size; - - const nodes: NodeData[] = [root]; - const edges: EdgeData[] = []; - - let branchStartX = rootSize[0] / 2 + branchSep; - let leafNodeMaxX = branchStartX; - - const findEdgeByTarget = (target: string) => (data.edges || []).find((edge) => edge.target === target)!; - - (data.nodes || []) - .filter((node: NodeData) => node.depth === 1) - .forEach((node: NodeData, i: number) => { - const nodeSize = typeof size === 'function' ? size(node) : size; - - const leafNodeIds = node.children || []; - const isUpper = i % 2 === 0; - const sign = isUpper ? 1 : -1; - - leafNodeIds.forEach((leafNodeId: string, j: number) => { - const order = j + 1; - const leafNode = model.getNodeLikeDatum(leafNodeId); - const leafNodeSize = typeof size === 'function' ? size(leafNode) : size; - - const x = branchStartX + rankSep * (order + 1) + leafNodeSize[0] / 2; - const y = sign * nodeSep * order; - nodes.push(assignElementStyle(leafNode, { x, y }) as NodeData); - - leafNodeMaxX = Math.max(leafNodeMaxX, x + leafNodeSize[0] / 2); - const edge = findEdgeByTarget(leafNodeId); - edges.push( - assignElementStyle(edge, { - stroke: palette[i % palette.length], - controlPoints: [[branchStartX + rankSep * order, y]], - zIndex: -i, - }) as EdgeData, - ); - }); - nodes.push( - assignElementStyle(node, { - x: branchStartX + rankSep * (leafNodeIds.length + 1), - y: sign * (nodeSep * (leafNodeIds.length + 1) + nodeSize[1] / 2), - fill: palette[i % palette.length], - }) as NodeData, - ); - const edge = findEdgeByTarget(node.id); - edges.push( - assignElementStyle(edge, { - stroke: palette[i % palette.length], - controlPoints: [[branchStartX, 0]], - zIndex: -i, - }) as EdgeData, - ); - branchStartX = (isUpper ? branchStartX : leafNodeMaxX) + branchSep; - }); - - return { nodes, edges }; + beforeDraw(input: DrawData): DrawData { + const nodes = this.context.model.getNodeData(); + + if (nodes.length === 0) return input; + + let colorIndex = 0; + const dfs = (nodeId: string, color?: string) => { + const node = nodes.find((datum) => datum.id == nodeId); + if (!node) return; + + node.style ||= {}; + node.style.color = color || this.options.colors[colorIndex++ % this.options.colors.length]; + node.children?.forEach((childId) => dfs(childId, node.style?.color as string)); + }; + + nodes.filter((node) => node.depth === 1).forEach((rootNode) => dfs(rootNode.id)); + + return input; } } - register(ExtensionCategory.LAYOUT, 'fishbone', FishboneLayout); + class ArrangeEdgeZIndex extends BaseTransform { + public beforeDraw(input: DrawData): DrawData { + const { model } = this.context; + const { nodes, edges } = model.getData(); + + const oneLevelNodes = nodes.filter((node) => node.depth === 1); + const oneLevelNodeIds = oneLevelNodes.map((node) => node.id); - let textShape: Text | null; - const measureText = (style: TextStyleProps) => { - if (!textShape) textShape = new Text({ style }); - textShape.attr(style); - return textShape.getBBox().width; - }; + edges.forEach((edge) => { + if (oneLevelNodeIds.includes(edge.target)) { + edge.style ||= {}; + edge.style.zIndex = oneLevelNodes.length - oneLevelNodes.findIndex((node) => node.id === edge.target); + } + }); + + return input; + } + } + + register(ExtensionCategory.TRANSFORM, 'assign-color-by-branch', AssignColorByBranch); + register(ExtensionCategory.TRANSFORM, 'arrange-edge-z-index', ArrangeEdgeZIndex); const getNodeSize = (id: string, depth: number) => { + const FONT_FAMILY = 'system-ui, sans-serif'; return depth === 0 - ? [measureText({ text: id, fontSize: 18, fontWeight: 'bold', fontFamily: 'system-ui, sans-serif' }) + 60, 42] + ? [measureText({ text: id, fontSize: 24, fontWeight: 'bold', fontFamily: FONT_FAMILY }) + 80, 58] : depth === 1 - ? [measureText({ text: id, fontSize: 16, fontFamily: 'system-ui, sans-serif' }) + 50, 36] - : [measureText({ text: id, fontSize: 12, fontFamily: 'system-ui, sans-serif' }) + 16, 30]; + ? [measureText({ text: id, fontSize: 18, fontFamily: FONT_FAMILY }) + 50, 42] + : [0, 30]; }; const graph = new Graph({ ...context, autoFit: 'view', - padding: 20, + padding: 10, data: treeToGraphData(data), node: { type: 'rect', @@ -146,7 +135,7 @@ export const caseFishbone: TestCase = async (context) => { radius: 8, size: getNodeSize(d.id, d.depth!), labelText: d.id, - labelPlacement: 'center', + labelPlacement: 'right', }; if (d.depth === 0) { @@ -154,21 +143,23 @@ export const caseFishbone: TestCase = async (context) => { fill: '#EFF0F0', labelFill: '#262626', labelFontWeight: 'bold', - labelFontSize: 18, - labelOffsetY: 4, - ports: [{ placement: 'right' }], + labelFontSize: 24, + labelOffsetY: 8, + labelPlacement: 'center', }); } else if (d.depth === 1) { Object.assign(style, { - ports: [{ placement: 'bottom' }, { placement: 'top' }], - labelFontSize: 14, + labelFontSize: 18, labelFill: '#fff', - labelOffsetY: 2, + labelFillOpacity: 0.9, + labelOffsetY: 3, + labelPlacement: 'center', + fill: d.style?.color, }); } else { Object.assign(style, { fill: 'transparent', - ports: [{ placement: 'left' }], + labelFontSize: 16, labeFill: '#262626', }); } @@ -177,13 +168,21 @@ export const caseFishbone: TestCase = async (context) => { }, edge: { type: 'polyline', - style: { lineWidth: 2 }, + style: { + lineWidth: 3, + stroke: function (data) { + return (this.getNodeData(data.target).style!.color as string) || '#99ADD1'; + }, + }, }, layout: { type: 'fishbone', - size: (d: NodeData) => getNodeSize(d.id, d.depth!), + direction: 'LR', + hGap: 40, + vGap: 60, }, behaviors: ['zoom-canvas', 'drag-canvas'], + transforms: ['assign-color-by-branch', 'arrange-edge-z-index'], animation: false, }); diff --git a/packages/g6/__tests__/demos/index.ts b/packages/g6/__tests__/demos/index.ts index b4e54f8f691..11bdd18caf7 100644 --- a/packages/g6/__tests__/demos/index.ts +++ b/packages/g6/__tests__/demos/index.ts @@ -96,6 +96,7 @@ export { layoutDagre } from './layout-dagre'; export { layoutDendrogramBasic } from './layout-dendrogram-basic'; export { layoutDendrogramRadial } from './layout-dendrogram-radial'; export { layoutDendrogramTb } from './layout-dendrogram-tb'; +export { layoutFishbone } from './layout-fishbone'; export { layoutForce } from './layout-force'; export { layoutForceCollision } from './layout-force-collision'; export { layoutForceLattice } from './layout-force-lattice'; diff --git a/packages/g6/__tests__/demos/layout-fishbone.ts b/packages/g6/__tests__/demos/layout-fishbone.ts new file mode 100644 index 00000000000..c59a281413e --- /dev/null +++ b/packages/g6/__tests__/demos/layout-fishbone.ts @@ -0,0 +1,109 @@ +import { Graph, treeToGraphData } from '@antv/g6'; + +const data = { + id: 'Quality', + children: [ + { + id: 'Machine', + children: [{ id: 'Mill' }, { id: 'Mixer' }, { id: 'Metal Lathe', children: [{ id: 'Milling' }] }], + }, + { id: 'Method' }, + { + id: 'Material', + children: [ + { + id: 'Masonite', + children: [ + { id: 'spearMint' }, + { id: 'pepperMint', children: [{ id: 'test3' }] }, + { id: 'test1', children: [{ id: 'test4' }] }, + ], + }, + { + id: 'Marscapone', + children: [{ id: 'Malty' }, { id: 'Minty' }], + }, + { id: 'Meat', children: [{ id: 'Mutton' }] }, + ], + }, + { + id: 'Man Power', + children: [ + { id: 'Manager' }, + { id: "Master's Student" }, + { id: 'Magician' }, + { id: 'Miner' }, + { id: 'Magister', children: [{ id: 'Malpractice' }] }, + { + id: 'Massage Artist', + children: [{ id: 'Masseur' }, { id: 'Masseuse' }], + }, + ], + }, + { + id: 'Measurement', + children: [{ id: 'Malleability' }], + }, + { + id: 'Milieu', + children: [{ id: 'Marine' }], + }, + ], +}; + +export const layoutFishbone: TestCase = async (context) => { + const graph = new Graph({ + ...context, + autoFit: 'view', + data: treeToGraphData(data), + node: { + type: 'rect', + style: { + size: [32, 32], + // fill: () => randomColor(), + label: false, + labelFill: '#262626', + labelFontFamily: 'Gill Sans', + labelMaxLines: 2, + labelMaxWidth: '100%', + labelPlacement: 'center', + labelText: (d) => d.id, + labelWordWrap: true, + }, + }, + edge: { + type: 'polyline', + style: { + lineWidth: 3, + }, + }, + layout: { + type: 'fishbone', + vGap: 48, + hGap: 48, + }, + behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'], + animation: false, + }); + + await graph.render(); + + layoutFishbone.form = (panel) => { + const config = { + type: 'fishbone', + direction: 'LR', + }; + + return [ + panel + .add(config, 'direction', ['LR', 'RL']) + .name('Direction') + .onChange((value: string) => { + graph.setLayout((prev) => ({ ...prev, direction: value })); + graph.layout(); + }), + ]; + }; + + return graph; +}; diff --git a/packages/g6/__tests__/snapshots/layouts/fishbone/direction-LR.svg b/packages/g6/__tests__/snapshots/layouts/fishbone/direction-LR.svg new file mode 100644 index 00000000000..e438fe6ac42 --- /dev/null +++ b/packages/g6/__tests__/snapshots/layouts/fishbone/direction-LR.svg @@ -0,0 +1,398 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/snapshots/layouts/fishbone/direction-RL.svg b/packages/g6/__tests__/snapshots/layouts/fishbone/direction-RL.svg new file mode 100644 index 00000000000..d41a3917a9e --- /dev/null +++ b/packages/g6/__tests__/snapshots/layouts/fishbone/direction-RL.svg @@ -0,0 +1,398 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/snapshots/layouts/fishbone/vGap-32-hGap-32.svg b/packages/g6/__tests__/snapshots/layouts/fishbone/vGap-32-hGap-32.svg new file mode 100644 index 00000000000..bd57189a1c3 --- /dev/null +++ b/packages/g6/__tests__/snapshots/layouts/fishbone/vGap-32-hGap-32.svg @@ -0,0 +1,398 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/unit/layouts/fishbone.spec.ts b/packages/g6/__tests__/unit/layouts/fishbone.spec.ts new file mode 100644 index 00000000000..6f0fa7fa1e5 --- /dev/null +++ b/packages/g6/__tests__/unit/layouts/fishbone.spec.ts @@ -0,0 +1,31 @@ +import type { Graph } from '@/src'; +import { layoutFishbone } from '@@/demos/layout-fishbone'; +import { createDemoGraph } from '@@/utils'; + +describe('fishbone', () => { + let graph: Graph; + + beforeAll(async () => { + graph = await createDemoGraph(layoutFishbone); + }); + + afterAll(() => { + graph.destroy(); + }); + + it('default', async () => { + await expect(graph).toMatchSnapshot(__filename, 'direction-RL'); + }); + + it('direction RL', async () => { + graph.setLayout((prev) => ({ ...prev, type: 'fishbone', direction: 'LR' })); + await graph.render(); + await expect(graph).toMatchSnapshot(__filename, 'direction-LR'); + }); + + it('vGap and hGap', async () => { + graph.setLayout((prev) => ({ ...prev, type: 'fishbone', vGap: 32, hGap: 32 })); + await graph.render(); + await expect(graph).toMatchSnapshot(__filename, 'vGap-32-hGap-32'); + }); +}); diff --git a/packages/g6/package.json b/packages/g6/package.json index 02553a78bb2..42230657049 100644 --- a/packages/g6/package.json +++ b/packages/g6/package.json @@ -61,7 +61,7 @@ "@antv/component": "^2.1.1", "@antv/event-emitter": "^0.1.3", "@antv/g": "^6.1.7", - "@antv/g-canvas": "^2.0.23", + "@antv/g-canvas": "^2.0.24", "@antv/g-plugin-dragndrop": "^2.0.18", "@antv/graphlib": "^2.0.3", "@antv/hierarchy": "^0.6.14", @@ -72,7 +72,7 @@ }, "devDependencies": { "@antv/g-svg": "^2.0.20", - "@antv/g-webgl": "^2.0.27", + "@antv/g-webgl": "^2.0.28", "@antv/layout-gpu": "^1.1.7", "@antv/layout-wasm": "^1.4.2", "@types/hull.js": "^1.0.4", diff --git a/packages/g6/src/exports.ts b/packages/g6/src/exports.ts index d70ffb062a1..ec4262a3cda 100644 --- a/packages/g6/src/exports.ts +++ b/packages/g6/src/exports.ts @@ -63,6 +63,7 @@ export { D3ForceLayout, DagreLayout, dendrogram as DendrogramLayout, + FishboneLayout, ForceAtlas2Layout, ForceLayout, FruchtermanLayout, @@ -180,6 +181,7 @@ export type { } from './elements/shapes'; export type { UpsertHooks } from './elements/shapes/base-shape'; export type { ContourLabelStyleProps, ContourStyleProps } from './elements/shapes/contour'; +export type { FishboneLayoutOptions } from './layouts'; export type { BaseLayoutOptions, WebWorkerLayoutOptions } from './layouts/types'; export type { CategoricalPalette } from './palettes/types'; export type { diff --git a/packages/g6/src/layouts/fishbone.ts b/packages/g6/src/layouts/fishbone.ts new file mode 100644 index 00000000000..d69c63fd753 --- /dev/null +++ b/packages/g6/src/layouts/fishbone.ts @@ -0,0 +1,295 @@ +import { isEmpty, memoize } from '@antv/util'; +import { idOf } from '../exports'; +import type { EdgeData, GraphData, NodeData } from '../spec'; +import type { ElementDatum, ID, Point, Size, STDSize } from '../types'; +import { parseSize } from '../utils/size'; +import { BaseLayout } from './base-layout'; + +export interface FishboneLayoutOptions { + /** + * 节点大小 + * + * Node size + */ + nodeSize?: Size | ((node: NodeData) => Size); + /** + * 排布方向 + * - `'RL'` 从右到左,鱼头在右 + * - `'LR'` 从左到右,鱼头在左 + * + * Layout direction + * - `'RL'` From right to left, the fish head is on the right + * - `'LR'` From left to right, the fish head is on the left + * @defaultValue `'LR'` + */ + direction?: 'RL' | 'LR'; + /** + * 获取水平间距 + * + * Get horizontal spacing + */ + hGap?: number; + /** + * 获取垂直间距 + * + * Get vertical spacing + */ + vGap?: number; + /** + * 获取鱼骨间距 + * + * Get rib separation + * @defaultValue () => 60 + */ + getRibSep?: (node: NodeData) => number; + /** + * 布局宽度 + * + * Layout width + */ + width?: number; + /** + * 布局高度 + * + * Layout height + */ + height?: number; +} + +type NodeResult = { id: ID; x: number; y: number }; +type EdgeResult = { id: ID; controlPoints: Point[]; relatedNodeId: ID }; +type LayoutResult = { nodes: NodeResult[]; edges: EdgeResult[] }; + +/** + * 鱼骨图布局 + * + * Fishbone layout + * @remarks + * [鱼骨图布局](https://en.wikipedia.org/wiki/Ishikawa_diagram)是一种专门用于表示层次结构数据的图形布局方式。它通过模拟鱼骨的形状,将数据节点按照层次结构排列,使得数据的层次关系更加清晰直观。鱼骨图布局特别适用于需要展示因果关系、层次结构或分类信息的数据集。 + * + * [Fishbone layout](https://en.wikipedia.org/wiki/Ishikawa_diagram) is a graphical layout method specifically designed to represent hierarchical data. By simulating the shape of a fishbone, it arranges data nodes according to their hierarchical structure, making the hierarchical relationships of the data clearer and more intuitive. The fishbone diagram layout is particularly suitable for datasets that need to display causal relationships, hierarchical structures, or classification information. + */ +export class FishboneLayout extends BaseLayout { + id = 'fishbone'; + + static defaultOptions: Partial = { + direction: 'RL', + getRibSep: () => 60, + }; + + private getRoot() { + const roots = this.context.model.getRootsData(); + if (isEmpty(roots) || roots.length > 2) return; + + return roots[0]; + } + + private formatSize(nodeSize: Size | ((node: NodeData) => Size)): (node: NodeData) => STDSize { + const nodeSizeFunc = typeof nodeSize === 'function' ? nodeSize : () => nodeSize; + return (node: NodeData) => parseSize(nodeSizeFunc(node)); + } + + private doLayout(root: NodeData, options: Required): LayoutResult { + const { hGap, getRibSep, vGap, nodeSize, height } = options; + + const { model } = this.context; + + const getSize = this.formatSize(nodeSize); + let ribX = getSize(root)[0] + getRibSep(root); + + const getHorizontalOffset = (node: NodeData, result = 0): number => { + result += hGap * ((node.children || []).length + 1); + + node.children?.forEach((childId) => { + const child = model.getNodeLikeDatum(childId) as NodeData; + child.children?.forEach((grandChildId) => { + const grandChild = model.getNodeLikeDatum(grandChildId) as NodeData; + result = getHorizontalOffset(grandChild, result); + }); + }); + + return result; + }; + + const getAuxiliaryPoint = (node: NodeData): number => { + if (node.depth === 1) return ribX; + + const parent = model.getParentData(node.id, 'tree') as NodeData; + + if (isAtEvenDepth(node)) { + const ancestor = model.getParentData(parent.id, 'tree') as NodeData; + const deltaY = calculateY(node) - calculateY(ancestor); + return getAuxiliaryPoint(parent) + (deltaY * hGap) / vGap; + } else { + const nodeIndex = (parent.children || []).indexOf(node.id); + const followingSiblingsIncludeSelf = model.getNodeData((parent.children || []).slice(nodeIndex)); + return ( + calculateX(parent) - + followingSiblingsIncludeSelf.reduce((acc, sibling) => acc + getHorizontalOffset(sibling), 0) - + getSize(parent)[0] / 2 + ); + } + }; + + const calculateX = memoize( + (node: NodeData): number => { + if (isRoot(node)) return getSize(node)[0] / 2; + + const parent = model.getParentData(node.id, 'tree') as NodeData; + + if (isAtEvenDepth(node)) { + return getAuxiliaryPoint(node) + getHorizontalOffset(node) + getSize(node)[0] / 2; + } else { + const deltaY = calculateY(node) - calculateY(parent); + const ratio = hGap / vGap; + return getAuxiliaryPoint(node) + deltaY * ratio; + } + }, + (node) => node.id, + ); + + const getParentY = (nodeId: ID): number => calculateY(model.getParentData(nodeId, 'tree')!); + + const calculateY = memoize( + (node: NodeData): number => { + if (isRoot(node)) return height / 2; + + if (!isAtEvenDepth(node)) { + // If the node has no children, calculate Y based on the parent + if (isEmpty(node.children)) return getParentY(node.id) + vGap; + + // If the last child has no children, calculate Y based on the last child + const lastChild = model.getNodeLikeDatum(node.children!.slice(-1)[0]); + if (isEmpty(lastChild.children)) return calculateY(lastChild) + vGap; + + // If the last child has children, calculate Y based on the last descendant of the last child + const lastDescendant = model.getDescendantsData(node.id).slice(-1)[0]; + return (isAtEvenDepth(lastDescendant) ? getParentY(lastDescendant.id) : calculateY(lastDescendant)) + vGap; + } else { + // depth > 0 && isAtEvenDepth(node) + const parent = model.getParentData(node.id, 'tree') as NodeData; + const nodeIndex = parent.children!.indexOf(node.id); + // If the node is the first sibling, return Y based on parent + if (nodeIndex === 0) return getParentY(parent.id) + vGap; + + // If the previous sibling has no children, calculate Y based on the previous sibling + const prevSibling = model.getNodeLikeDatum(parent.children![nodeIndex - 1]); + if (isEmpty(prevSibling.children)) return calculateY(prevSibling) + vGap; + + // If the previous sibling has children, calculate Y based on the last descendant of the previous sibling + const descendants = model.getDescendantsData(prevSibling.id); + return ( + Math.max( + ...descendants.map((descendant) => + isAtEvenDepth(descendant) ? getParentY(descendant.id) : calculateY(descendant), + ), + ) + vGap + ); + } + }, + (node) => node.id, + ); + + let tmpRibX = 0; + const result: LayoutResult = { nodes: [], edges: [] }; + + const layout = (node: NodeData) => { + node.children?.forEach((childId) => layout(model.getNodeLikeDatum(childId))); + + const y = calculateY(node); + const x = calculateX(node); + result.nodes.push({ id: node.id, x, y }); + + if (isRoot(node)) return; + + const edge = model.getRelatedEdgesData(node.id, 'in')[0]; + const controlPoint = [getAuxiliaryPoint(node), isAtEvenDepth(node) ? y : getParentY(node.id)] as Point; + result.edges.push({ id: idOf(edge), controlPoints: [controlPoint], relatedNodeId: node.id }); + + tmpRibX = Math.max(tmpRibX, x + getRibSep(node)); + if (node.depth === 1) ribX = tmpRibX; + }; + + layout(root); + + return result; + } + + private placeAlterative(result: LayoutResult, root: NodeData) { + const oddIndexedRibs = (root.children || []).filter((_, index) => index % 2 !== 0); + if (oddIndexedRibs.length === 0) return result; + + const { model } = this.context; + const rootY = result.nodes.find((node) => node.id === root.id)!.y; + + const shouldFlip = (nodeId: ID) => { + const ancestors = model.getAncestorsData(nodeId, 'tree'); + if (isEmpty(ancestors)) return false; + const ribId = ancestors.length === 1 ? nodeId : ancestors[ancestors.length - 2].id; + return oddIndexedRibs.includes(ribId); + }; + + result.nodes.forEach((node) => { + if (shouldFlip(node.id)) node.y = 2 * rootY - node.y; + }); + result.edges.forEach((edge) => { + if (shouldFlip(edge.relatedNodeId)) { + edge.controlPoints = edge.controlPoints.map((point) => [point[0], 2 * rootY - point[1]]); + } + }); + } + + private rightToLeft(result: LayoutResult, options: Required) { + result.nodes.forEach((node) => (node.x = options.width! - node.x)); + result.edges.forEach((edge) => { + edge.controlPoints = edge.controlPoints.map((point) => [options.width! - point[0], point[1]]); + }); + return result; + } + + async execute(data: GraphData, propOptions: FishboneLayoutOptions): Promise { + const options = { ...FishboneLayout.defaultOptions, ...this.options, ...propOptions }; + const { direction, nodeSize } = options; + + const root = this.getRoot(); + if (!root) return data; + + const getSize = this.formatSize(nodeSize); + options.vGap ||= Math.max(...(data.nodes || []).map((node) => getSize(node)[1])); + options.hGap ||= Math.max(...(data.nodes || []).map((node) => getSize(node)[0])); + + let result = this.doLayout(root, options); + + this.placeAlterative(result, root); + + if (direction === 'RL') { + result = this.rightToLeft(result, options); + } + + const { model } = this.context; + const nodes: NodeData[] = []; + const edges: EdgeData[] = []; + + result.nodes.forEach((node) => { + const { id, x, y } = node; + const nodeData = model.getNodeLikeDatum(id); + nodes.push(assignElementStyle(nodeData, { x, y }) as NodeData); + }); + + result.edges.forEach((edge) => { + const { id, controlPoints } = edge; + const edgeData = model.getEdgeDatum(id); + edges.push(assignElementStyle(edgeData, { controlPoints }) as EdgeData); + }); + + return { nodes, edges }; + } +} + +const assignElementStyle = (element: ElementDatum, style: Record) => { + return { ...element, style: { ...(element.style || {}), ...style } }; +}; + +const isRoot = (node: NodeData) => node.depth === 0; + +const isAtEvenDepth = (node: NodeData) => (node.depth ||= 0) % 2 === 0; diff --git a/packages/g6/src/layouts/index.ts b/packages/g6/src/layouts/index.ts index d374f587ef9..5b1b66e0dfa 100644 --- a/packages/g6/src/layouts/index.ts +++ b/packages/g6/src/layouts/index.ts @@ -15,3 +15,5 @@ export { RandomLayout, } from '@antv/layout'; export { BaseLayout } from './base-layout'; +export { FishboneLayout } from './fishbone'; +export type { FishboneLayoutOptions } from './fishbone'; diff --git a/packages/g6/src/registry/build-in.ts b/packages/g6/src/registry/build-in.ts index 9d72651a4dd..5fb1bed73bd 100644 --- a/packages/g6/src/registry/build-in.ts +++ b/packages/g6/src/registry/build-in.ts @@ -57,6 +57,7 @@ import { ConcentricLayout, D3ForceLayout, DagreLayout, + FishboneLayout, ForceAtlas2Layout, ForceLayout, FruchtermanLayout, @@ -151,12 +152,13 @@ const BUILT_IN_EXTENSIONS: ExtensionRegistry = { 'antv-dagre': AntVDagreLayout, 'combo-combined': ComboCombinedLayout, 'compact-box': compactBox, + 'd3-force': D3ForceLayout, 'force-atlas2': ForceAtlas2Layout, circular: CircularLayout, concentric: ConcentricLayout, - 'd3-force': D3ForceLayout, dagre: DagreLayout, dendrogram, + fishbone: FishboneLayout, force: ForceLayout, fruchterman: FruchtermanLayout, grid: GridLayout, diff --git a/packages/site/examples/scene-case/default/demo/fishbone.js b/packages/site/examples/scene-case/default/demo/fishbone.js deleted file mode 100644 index 52c0ca71e4a..00000000000 --- a/packages/site/examples/scene-case/default/demo/fishbone.js +++ /dev/null @@ -1,189 +0,0 @@ - -import { Text } from '@antv/g'; -import { BaseLayout, ExtensionCategory, Graph, register, treeToGraphData } from '@antv/g6'; - -const data = { - id: '克服拖延', - children: [ - { id: '完美主义情结', children: [{ id: '正确评估事情难度' }, { id: '先完成,再完善' }, { id: 'Just do it' }] }, - { - id: '提高专注度', - children: [{ id: '番茄工作法' }, { id: '限时、限量,一次只做一件事' }, { id: '提高抗干扰能力,减少打断' }], - }, - { - id: '设定清晰的任务管理流程', - children: [ - { id: '设立完成事项的优先级' }, - { id: '拆解具体可执行的目标' }, - { id: '收集-整理-排序-执行反馈-总结' }, - ], - }, - { - id: '建立积极反馈', - children: [{ id: '做喜欢的事情' }, { id: '精神激励' }, { id: '物质激励' }], - }, - { - id: '放松、享受', - children: [{ id: '注重过程而非结果' }, { id: '靠需求驱动而非焦虑' }, { id: '接受、理解' }], - }, - ], -}; - -const palette = [ - '#1783FF', - '#00C9C9', - '#F08F56', - '#D580FF', - '#7863FF', - '#DB9D0D', - '#60C42D', - '#FF80CA', - '#2491B3', - '#17C76F', -]; - -const assignElementStyle = (element, style) => { - return { ...element, style: { ...(element.style || {}), ...style } }; -}; - -class FishboneLayout extends BaseLayout { - id = 'fishbone'; - - async execute(data, options) { - const { rankSep = 30, branchSep = 30, nodeSep = 48, size = 32 } = { ...this.options, ...options }; - - const { model } = this.context; - const root = model.getRootsData()[0]; - Object.assign(root.style || {}, { x: 0, y: 0 }); - const rootSize = typeof size === 'function' ? size(root) : size; - - const nodes = [root]; - const edges = []; - - let branchStartX = rootSize[0] / 2 + branchSep; - let leafNodeMaxX = branchStartX; - - const findEdgeByTarget = (target) => (data.edges || []).find((edge) => edge.target === target); - - (data.nodes || []) - .filter((node) => node.depth === 1) - .forEach((node, i) => { - const nodeSize = typeof size === 'function' ? size(node) : size; - - const leafNodeIds = node.children || []; - const isUpper = i % 2 === 0; - const sign = isUpper ? 1 : -1; - - leafNodeIds.forEach((leafNodeId, j) => { - const order = j + 1; - const leafNode = model.getNodeLikeDatum(leafNodeId); - const leafNodeSize = typeof size === 'function' ? size(leafNode) : size; - - const x = branchStartX + rankSep * (order + 1) + leafNodeSize[0] / 2; - const y = sign * nodeSep * order; - nodes.push(assignElementStyle(leafNode, { x, y })); - - leafNodeMaxX = Math.max(leafNodeMaxX, x + leafNodeSize[0] / 2); - const edge = findEdgeByTarget(leafNodeId); - edges.push( - assignElementStyle(edge, { - stroke: palette[i % palette.length], - controlPoints: [[branchStartX + rankSep * order, y]], - zIndex: -i, - }), - ); - }); - nodes.push( - assignElementStyle(node, { - x: branchStartX + rankSep * (leafNodeIds.length + 1), - y: sign * (nodeSep * (leafNodeIds.length + 1) + nodeSize[1] / 2), - fill: palette[i % palette.length], - }), - ); - const edge = findEdgeByTarget(node.id); - edges.push( - assignElementStyle(edge, { - stroke: palette[i % palette.length], - controlPoints: [[branchStartX, 0]], - zIndex: -i, - }), - ); - branchStartX = (isUpper ? branchStartX : leafNodeMaxX) + branchSep; - }); - - return { nodes, edges }; - } -} - -register(ExtensionCategory.LAYOUT, 'fishbone', FishboneLayout); - -let textShape; -const measureText = (style) => { - if (!textShape) textShape = new Text({ style }); - textShape.attr(style); - return textShape.getBBox().width; -}; - -const getNodeSize = (id, depth) => { - return depth === 0 - ? [measureText({ text: id, fontSize: 18, fontWeight: 'bold', fontFamily: 'system-ui, sans-serif' }) + 60, 42] - : depth === 1 - ? [measureText({ text: id, fontSize: 16, fontFamily: 'system-ui, sans-serif' }) + 50, 36] - : [measureText({ text: id, fontSize: 12, fontFamily: 'system-ui, sans-serif' }) + 16, 30]; -}; - -const graph = new Graph({ - container: 'container', - autoFit: 'view', - padding: 20, - data: treeToGraphData(data), - node: { - type: 'rect', - style: (d) => { - const style = { - radius: 8, - size: getNodeSize(d.id, d.depth), - labelText: d.id, - labelPlacement: 'center', - labelFillOpacity: 1, - }; - - if (d.depth === 0) { - Object.assign(style, { - fill: '#EFF0F0', - labelFill: '#262626', - labelFontWeight: 'bold', - labelFontSize: 18, - labelOffsetY: 4, - ports: [{ placement: 'right' }], - }); - } else if (d.depth === 1) { - Object.assign(style, { - ports: [{ placement: 'bottom' }, { placement: 'top' }], - labelFontSize: 14, - labelFill: '#fff', - labelOffsetY: 2, - }); - } else { - Object.assign(style, { - fill: 'transparent', - ports: [{ placement: 'left' }], - labeFill: '#262626', - }); - } - return style; - }, - }, - edge: { - type: 'polyline', - style: { lineWidth: 2 }, - }, - layout: { - type: 'fishbone', - size: (d) => getNodeSize(d.id, d.depth), - }, - behaviors: ['zoom-canvas', 'drag-canvas'], - animation: false, -}); - -graph.render(); diff --git a/packages/site/examples/scene-case/default/demo/meta.json b/packages/site/examples/scene-case/default/demo/meta.json index c534280f75c..5171680dbba 100644 --- a/packages/site/examples/scene-case/default/demo/meta.json +++ b/packages/site/examples/scene-case/default/demo/meta.json @@ -20,38 +20,6 @@ }, "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*IytGRZ8WaSMAAAAAAAAAAAAADmJ7AQ/original" }, - { - "filename": "indented-tree.js", - "title": { - "zh": "缩进树", - "en": "Indented Tree" - }, - "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*r0-SS5dRxykAAAAAAAAAAAAADmJ7AQ/original" - }, - { - "filename": "mindmap.js", - "title": { - "zh": "思维导图", - "en": "Mind Map" - }, - "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*P39BR7WoI5oAAAAAAAAAAAAADmJ7AQ/original" - }, - { - "filename": "radial-dendrogram.js", - "title": { - "zh": "径向生态树", - "en": "Radial Dendrogram" - }, - "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*WRXHSrxqBYsAAAAAAAAAAAAADmJ7AQ/original" - }, - { - "filename": "radial-compact-tree.js", - "title": { - "zh": "径向紧凑树", - "en": "Radial Compact Tree" - }, - "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*uKYKSYJ6iMYAAAAAAAAAAAAADmJ7AQ/original" - }, { "filename": "fund-flow.js", "title": { @@ -91,14 +59,6 @@ "en": "Why Do Cats?" }, "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*ug4vTJA7QbMAAAAAAAAAAAAADmJ7AQ/original" - }, - { - "filename": "fishbone.js", - "title": { - "zh": "克服拖延症鱼骨图", - "en": "Anti-Procrastination Fishbone Diagram" - }, - "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*VusPQ50s3uEAAAAAAAAAAAAADmJ7AQ/original" } ] } diff --git a/packages/site/examples/scene-case/tree-graph/demo/anti-procrastination-fishbone.js b/packages/site/examples/scene-case/tree-graph/demo/anti-procrastination-fishbone.js new file mode 100644 index 00000000000..f105b26e76e --- /dev/null +++ b/packages/site/examples/scene-case/tree-graph/demo/anti-procrastination-fishbone.js @@ -0,0 +1,189 @@ +import { Text } from '@antv/g'; +import { BaseTransform, ExtensionCategory, Graph, register, treeToGraphData } from '@antv/g6'; + +const data = { + id: 'Overcome \n procrastination', + children: [ + { + id: 'Perfectionism', + children: [ + { id: 'Correctly assess the difficulty of things' }, + { id: 'Complete first, then improve' }, + { id: 'Just do it' }, + ], + }, + { + id: 'Improve concentration', + children: [ + { id: 'Pomodoro Technique' }, + { id: 'Limited time, limited quantity, only do one thing at a time' }, + { id: 'Improve anti-interference ability, reduce interruptions' }, + ], + }, + { + id: 'Set a clear task management process', + children: [ + { id: 'Set priorities for completed tasks' }, + { id: 'Break down specific executable goals' }, + { id: 'Collect-sort-sort-execute feedback-summary' }, + ], + }, + { + id: 'Establish positive feedback', + children: [{ id: 'Do what you like' }, { id: 'Spiritual motivation' }, { id: 'Material motivation' }], + }, + { + id: 'Relax and enjoy', + children: [ + { id: 'Focus on process rather than results' }, + { id: 'Driven by needs rather than anxiety' }, + { id: 'Accept and understand' }, + ], + }, + ], +}; + +let textShape; +const measureText = (style) => { + if (!textShape) textShape = new Text({ style }); + textShape.attr(style); + return textShape.getBBox().width; +}; + +class AssignColorByBranch extends BaseTransform { + static defaultOptions = { + colors: [ + '#1783FF', + '#F08F56', + '#D580FF', + '#00C9C9', + '#7863FF', + '#DB9D0D', + '#60C42D', + '#FF80CA', + '#2491B3', + '#17C76F', + ], + }; + + constructor(context, options) { + super(context, Object.assign({}, AssignColorByBranch.defaultOptions, options)); + } + + beforeDraw(input) { + const nodes = this.context.model.getNodeData(); + + if (nodes.length === 0) return input; + + let colorIndex = 0; + const dfs = (nodeId, color) => { + const node = nodes.find((datum) => datum.id == nodeId); + if (!node) return; + + node.style ||= {}; + node.style.color = color || this.options.colors[colorIndex++ % this.options.colors.length]; + node.children?.forEach((childId) => dfs(childId, node.style?.color)); + }; + + nodes.filter((node) => node.depth === 1).forEach((rootNode) => dfs(rootNode.id)); + + return input; + } +} + +class ArrangeEdgeZIndex extends BaseTransform { + beforeDraw(input) { + const { model } = this.context; + const { nodes, edges } = model.getData(); + + const oneLevelNodes = nodes.filter((node) => node.depth === 1); + const oneLevelNodeIds = oneLevelNodes.map((node) => node.id); + + edges.forEach((edge) => { + if (oneLevelNodeIds.includes(edge.target)) { + edge.style ||= {}; + edge.style.zIndex = oneLevelNodes.length - oneLevelNodes.findIndex((node) => node.id === edge.target); + } + }); + + return input; + } +} + +register(ExtensionCategory.TRANSFORM, 'assign-color-by-branch', AssignColorByBranch); +register(ExtensionCategory.TRANSFORM, 'arrange-edge-z-index', ArrangeEdgeZIndex); + +const getNodeSize = (id, depth) => { + const FONT_FAMILY = 'system-ui, sans-serif'; + return depth === 0 + ? [measureText({ text: id, fontSize: 24, fontWeight: 'bold', fontFamily: FONT_FAMILY }) + 80, 70] + : depth === 1 + ? [measureText({ text: id, fontSize: 18, fontFamily: FONT_FAMILY }) + 50, 42] + : [2, 30]; +}; + +const graph = new Graph({ + autoFit: 'view', + padding: 10, + data: treeToGraphData(data), + node: { + type: 'rect', + style: (d) => { + const style = { + radius: 8, + size: getNodeSize(d.id, d.depth), + labelText: d.id, + labelPlacement: 'right', + labelFontFamily: 'Gill Sans', + }; + + if (d.depth === 0) { + Object.assign(style, { + fill: '#EFF0F0', + labelFill: '#262626', + labelFontWeight: 'bold', + labelFontSize: 24, + labelOffsetY: 4, + labelPlacement: 'center', + labelLineHeight: 24, + }); + } else if (d.depth === 1) { + Object.assign(style, { + labelFontSize: 18, + labelFill: '#fff', + labelFillOpacity: 0.9, + labelOffsetY: 5, + labelPlacement: 'center', + fill: d.style?.color, + }); + } else { + Object.assign(style, { + fill: 'transparent', + labelFontSize: 16, + labeFill: '#262626', + }); + } + return style; + }, + }, + edge: { + type: 'polyline', + style: { + lineWidth: 3, + stroke: function (data) { + return this.getNodeData(data.target).style.color || '#99ADD1'; + }, + }, + }, + layout: { + type: 'fishbone', + direction: 'LR', + hGap: 40, + vGap: 60, + }, + behaviors: ['zoom-canvas', 'drag-canvas'], + transforms: ['assign-color-by-branch', 'arrange-edge-z-index'], + animation: false, +}); + +graph.render(); diff --git a/packages/site/examples/scene-case/default/demo/indented-tree.js b/packages/site/examples/scene-case/tree-graph/demo/indented-tree.js similarity index 100% rename from packages/site/examples/scene-case/default/demo/indented-tree.js rename to packages/site/examples/scene-case/tree-graph/demo/indented-tree.js diff --git a/packages/site/examples/scene-case/tree-graph/demo/meta.json b/packages/site/examples/scene-case/tree-graph/demo/meta.json new file mode 100644 index 00000000000..4a9bbddce9f --- /dev/null +++ b/packages/site/examples/scene-case/tree-graph/demo/meta.json @@ -0,0 +1,56 @@ +{ + "title": { + "zh": "中文分类", + "en": "Category" + }, + "demos": [ + { + "filename": "indented-tree.js", + "title": { + "zh": "缩进树", + "en": "Indented Tree" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*r0-SS5dRxykAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "mindmap.js", + "title": { + "zh": "思维导图", + "en": "Mind Map" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*xryoSY8EQWoAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "anti-procrastination-fishbone.js", + "title": { + "zh": "克服拖延症(决策型鱼骨图)", + "en": "Anti-Procrastination Fishbone Diagram" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*olIATZ-4qMEAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "product-fishbone.js", + "title": { + "zh": "分析产品收益(原因型鱼骨图)", + "en": "Product Profitability Below Expectations" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*I0VYSYD_3bUAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "radial-dendrogram.js", + "title": { + "zh": "径向生态树", + "en": "Radial Dendrogram" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*tK5USKBOc2kAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "radial-compact-tree.js", + "title": { + "zh": "径向紧凑树", + "en": "Radial Compact Tree" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*9iPNRpKfb3IAAAAAAAAAAAAADmJ7AQ/original" + } + ] +} diff --git a/packages/site/examples/scene-case/default/demo/mindmap.js b/packages/site/examples/scene-case/tree-graph/demo/mindmap.js similarity index 86% rename from packages/site/examples/scene-case/default/demo/mindmap.js rename to packages/site/examples/scene-case/tree-graph/demo/mindmap.js index 7b8c7ea8bf6..427f1482587 100644 --- a/packages/site/examples/scene-case/default/demo/mindmap.js +++ b/packages/site/examples/scene-case/tree-graph/demo/mindmap.js @@ -21,33 +21,21 @@ const style = document.createElement('style'); style.innerHTML = `@import url('${iconfont.css}');`; document.head.appendChild(style); -const COLORS = [ - '#1783FF', - '#00C9C9', - '#F08F56', - '#D580FF', - '#7863FF', - '#DB9D0D', - '#60C42D', - '#FF80CA', - '#2491B3', - '#17C76F', -]; - const RootNodeStyle = { fill: '#EFF0F0', labelFill: '#262626', - labelFontSize: 16, + labelFontSize: 24, labelFontWeight: 600, + labelOffsetY: 8, labelPlacement: 'center', ports: [{ placement: 'right' }, { placement: 'left' }], - radius: 4, + radius: 8, }; const NodeStyle = { fill: 'transparent', labelPlacement: 'center', - labelFontSize: 12, + labelFontSize: 16, ports: [{ placement: 'right-bottom' }, { placement: 'left-bottom' }], }; @@ -64,9 +52,15 @@ const measureText = (text) => { }; const getNodeWidth = (nodeId, isRoot) => { - return isRoot - ? measureText({ text: nodeId, fontSize: RootNodeStyle.labelFontSize }) + 20 - : measureText({ text: nodeId, fontSize: NodeStyle.labelFontSize }) + 30; + const padding = isRoot ? 40 : 30; + const nodeStyle = isRoot ? RootNodeStyle : NodeStyle; + return measureText({ text: nodeId, fontSize: nodeStyle.labelFontSize, fontFamily: 'Gill Sans' }) + padding; +}; + +const getNodeSize = (nodeId, isRoot) => { + const width = getNodeWidth(nodeId, isRoot); + const height = isRoot ? 48 : 32; + return [width, height]; }; class MindmapNode extends BaseNode { @@ -186,7 +180,7 @@ class MindmapNode extends BaseNode { } getAddBarStyle(attributes) { - const { collapsed, showIcon, direction, color = COLORS[0] } = attributes; + const { collapsed, showIcon, direction, color = '#1783FF' } = attributes; if (collapsed || !showIcon) return false; const [width, height] = this.getSize(attributes); @@ -352,42 +346,51 @@ class CollapseExpandTree extends BaseBehavior { }; } -class AssignElementColor extends BaseTransform { - beforeDraw(data) { - const { nodes = [], edges = [] } = this.context.graph.getData(); +class AssignColorByBranch extends BaseTransform { + static defaultOptions = { + colors: [ + '#1783FF', + '#F08F56', + '#D580FF', + '#00C9C9', + '#7863FF', + '#DB9D0D', + '#60C42D', + '#FF80CA', + '#2491B3', + '#17C76F', + ], + }; + + constructor(context, options) { + super(context, Object.assign({}, AssignColorByBranch.defaultOptions, options)); + } - const nodeColorMap = new Map(); + beforeDraw(input) { + const nodes = this.context.model.getNodeData(); + + if (nodes.length === 0) return input; let colorIndex = 0; const dfs = (nodeId, color) => { const node = nodes.find((datum) => datum.id == nodeId); if (!node) return; - if (node.depth !== 0) { - const nodeColor = color || COLORS[colorIndex++ % COLORS.length]; - node.style ||= {}; - node.style.color = nodeColor; - nodeColorMap.set(nodeId, nodeColor); - } - - node.children?.forEach((childId) => dfs(childId, node.style.color)); + node.style ||= {}; + node.style.color = color || this.options.colors[colorIndex++ % this.options.colors.length]; + node.children?.forEach((childId) => dfs(childId, node.style?.color)); }; - nodes.filter((node) => node.depth === 0).forEach((rootNode) => dfs(rootNode.id)); + nodes.filter((node) => node.depth === 1).forEach((rootNode) => dfs(rootNode.id)); - edges.forEach((edge) => { - edge.style ||= {}; - edge.style.stroke = nodeColorMap.get(edge.target); - }); - - return data; + return input; } } register(ExtensionCategory.NODE, 'mindmap', MindmapNode); register(ExtensionCategory.EDGE, 'mindmap', MindmapEdge); register(ExtensionCategory.BEHAVIOR, 'collapse-expand-tree', CollapseExpandTree); -register(ExtensionCategory.TRANSFORM, 'assign-element-color', AssignElementColor); +register(ExtensionCategory.TRANSFORM, 'assign-color-by-branch', AssignColorByBranch); const getNodeSide = (nodeData, parentData) => { if (!parentData) return 'center'; @@ -413,9 +416,9 @@ fetch('https://assets.antv.antgroup.com/g6/algorithm-category.json') return { direction, labelText: idOf(d), - size: [getNodeWidth(idOf(d), isRoot), 30], - // 通过设置节点标签背景来扩大节点的交互区域 - // Enlarge the interactive area of the node by setting label background + size: getNodeSize(idOf(d), isRoot), + labelFontFamily: 'Gill Sans', + // 通过设置节点标签背景来扩大交互区域 | Expand the interaction area by setting the node label background labelBackground: true, labelBackgroundFill: 'transparent', labelPadding: direction === 'left' ? [2, 0, 10, 40] : [2, 40, 10, 0], @@ -425,7 +428,12 @@ fetch('https://assets.antv.antgroup.com/g6/algorithm-category.json') }, edge: { type: 'mindmap', - style: { lineWidth: 2 }, + style: { + lineWidth: 3, + stroke: function (data) { + return this.getNodeData(data.target).style.color || '#99ADD1'; + }, + }, }, layout: { type: 'mindmap', @@ -437,7 +445,7 @@ fetch('https://assets.antv.antgroup.com/g6/algorithm-category.json') animation: false, }, behaviors: ['drag-canvas', 'zoom-canvas', 'collapse-expand-tree'], - transforms: ['assign-element-color'], + transforms: ['assign-color-by-branch'], animation: false, }); diff --git a/packages/site/examples/scene-case/tree-graph/demo/product-fishbone.js b/packages/site/examples/scene-case/tree-graph/demo/product-fishbone.js new file mode 100644 index 00000000000..dc0bfb21212 --- /dev/null +++ b/packages/site/examples/scene-case/tree-graph/demo/product-fishbone.js @@ -0,0 +1,203 @@ +import { Text } from '@antv/g'; +import { BaseTransform, ExtensionCategory, Graph, register, treeToGraphData } from '@antv/g6'; + +const data = { + id: 'Product Profitability\nBelow Expectations', + children: [ + { + id: 'Problem Description', + children: [ + { id: 'Brand Sales Volume' }, + { id: 'Market Capacity' }, + { id: 'Brand Market Share' }, + { id: 'Total Contribution Margin' }, + ], + }, + { + id: 'Brand Positioning', + children: [{ id: 'Packaging' }, { id: 'Brand Name' }, { id: 'Selling Price' }, { id: 'Product Specifications' }], + }, + { + id: 'Distribution Channels', + children: [{ id: 'Region' }, { id: 'Channel' }, { id: 'Customer Type' }, { id: 'Sales Personnel Coverage' }], + }, + { + id: 'Market Awareness', + children: [ + { id: 'Regional Weighting' }, + { id: 'Media Mix' }, + { id: 'Advertising Investment' }, + { id: 'Quality Perception' }, + ], + }, + { + id: 'Trial Purchase', + children: [ + { id: 'In-store Display' }, + { id: 'Promotion Type' }, + { id: 'Timing of Promotion' }, + { id: 'Supply Assurance' }, + ], + }, + { + id: 'Repeat Purchase', + children: [ + { id: 'Consumer Profile' }, + { id: 'Usage Occasion' }, + { id: 'Frequency of Use' }, + { id: 'Returns Due to Product Issues' }, + ], + }, + ], +}; + +let textShape; +const measureText = (style) => { + if (!textShape) textShape = new Text({ style }); + textShape.attr(style); + return textShape.getBBox().width; +}; + +class AssignColorByBranch extends BaseTransform { + static defaultOptions = { + colors: [ + '#1783FF', + '#F08F56', + '#D580FF', + '#00C9C9', + '#7863FF', + '#DB9D0D', + '#60C42D', + '#FF80CA', + '#2491B3', + '#17C76F', + ], + }; + + constructor(context, options) { + super(context, Object.assign({}, AssignColorByBranch.defaultOptions, options)); + } + + beforeDraw(input) { + const nodes = this.context.model.getNodeData(); + + if (nodes.length === 0) return input; + + let colorIndex = 0; + const dfs = (nodeId, color) => { + const node = nodes.find((datum) => datum.id == nodeId); + if (!node) return; + + node.style ||= {}; + node.style.color = color || this.options.colors[colorIndex++ % this.options.colors.length]; + node.children?.forEach((childId) => dfs(childId, node.style?.color)); + }; + + nodes.filter((node) => node.depth === 1).forEach((rootNode) => dfs(rootNode.id)); + + return input; + } +} + +class ArrangeEdgeZIndex extends BaseTransform { + beforeDraw(input) { + const { model } = this.context; + const { nodes, edges } = model.getData(); + + const oneLevelNodes = nodes.filter((node) => node.depth === 1); + const oneLevelNodeIds = oneLevelNodes.map((node) => node.id); + + edges.forEach((edge) => { + if (oneLevelNodeIds.includes(edge.target)) { + edge.style ||= {}; + edge.style.zIndex = oneLevelNodes.length - oneLevelNodes.findIndex((node) => node.id === edge.target); + } + }); + + return input; + } +} + +register(ExtensionCategory.TRANSFORM, 'assign-color-by-branch', AssignColorByBranch); +register(ExtensionCategory.TRANSFORM, 'arrange-edge-z-index', ArrangeEdgeZIndex); + +const getNodeSize = (id, depth) => { + const FONT_FAMILY = 'system-ui, sans-serif'; + return depth === 0 + ? [measureText({ text: id, fontSize: 24, fontWeight: 'bold', fontFamily: FONT_FAMILY }) + 80, 90] + : depth === 1 + ? [measureText({ text: id, fontSize: 18, fontFamily: FONT_FAMILY }) + 50, 42] + : [2, 30]; +}; + +const graph = new Graph({ + autoFit: 'view', + padding: 30, + data: treeToGraphData(data), + node: { + type: 'rect', + style: (d) => { + const style = { + radius: 8, + size: getNodeSize(d.id, d.depth), + labelText: d.id, + labelPlacement: 'left', + labelFontFamily: 'Gill Sans', + }; + + if (d.depth === 0) { + Object.assign(style, { + fill: '#EFF0F0', + labelFill: '#262626', + labelFontWeight: 'bold', + labelFontSize: 24, + labelOffsetY: 3, + labelPlacement: 'center', + labelLineHeight: 32, + }); + } else if (d.depth === 1) { + Object.assign(style, { + labelFontSize: 18, + labelFill: '#252525', + labelFillOpacity: 0.9, + labelOffsetY: 5, + labelPlacement: 'center', + labelFontWeight: 600, + fill: d.style?.color, + fillOpacity: 0.6, + lineWidth: 2, + stroke: '#252525', + }); + } else { + Object.assign(style, { + fill: 'transparent', + labelFontSize: 16, + labeFill: '#262626', + }); + } + return style; + }, + }, + edge: { + type: 'polyline', + style: { + lineWidth: 3, + stroke: '#252525', + }, + }, + layout: { + type: 'fishbone', + direction: 'RL', + hGap: 40, + vGap: 60, + getRibSep: (node) => { + console.log(node); + return node.depth === 0 ? 0 : -50; + }, + }, + behaviors: ['zoom-canvas', 'drag-canvas'], + transforms: ['assign-color-by-branch', 'arrange-edge-z-index'], + animation: false, +}); + +graph.render(); diff --git a/packages/site/examples/scene-case/default/demo/radial-dendrogram.js b/packages/site/examples/scene-case/tree-graph/demo/radial-compact-tree.js similarity index 74% rename from packages/site/examples/scene-case/default/demo/radial-dendrogram.js rename to packages/site/examples/scene-case/tree-graph/demo/radial-compact-tree.js index 783843e4e40..e09e96d5545 100644 --- a/packages/site/examples/scene-case/default/demo/radial-dendrogram.js +++ b/packages/site/examples/scene-case/tree-graph/demo/radial-compact-tree.js @@ -6,34 +6,30 @@ fetch('https://assets.antv.antgroup.com/g6/flare.json') const graph = new Graph({ container: 'container', autoFit: 'view', + padding: 50, data: treeToGraphData(data), node: { style: { - size: 20, + size: 12, labelText: (d) => d.id, labelBackground: true, - }, - state: { - active: { - fill: '#00C9C9', - }, + labelFontSize: 14, + labelFontFamily: 'Gill Sans', }, }, edge: { type: 'cubic-radial', - state: { - active: { - lineWidth: 3, - stroke: '#009999', - }, + style: { + lineWidth: 3, }, }, layout: [ { - type: 'dendrogram', + type: 'compact-box', radial: true, - nodeSep: 30, - rankSep: 200, + direction: 'RL', + getVGap: () => 40, + getHGap: () => 80, }, ], behaviors: [ @@ -49,6 +45,7 @@ fetch('https://assets.antv.antgroup.com/g6/flare.json') }, ], transforms: ['place-radial-labels'], + animation: false, }); graph.render(); diff --git a/packages/site/examples/scene-case/default/demo/radial-compact-tree.js b/packages/site/examples/scene-case/tree-graph/demo/radial-dendrogram.js similarity index 57% rename from packages/site/examples/scene-case/default/demo/radial-compact-tree.js rename to packages/site/examples/scene-case/tree-graph/demo/radial-dendrogram.js index fcf909f6616..33cd320d8f9 100644 --- a/packages/site/examples/scene-case/default/demo/radial-compact-tree.js +++ b/packages/site/examples/scene-case/tree-graph/demo/radial-dendrogram.js @@ -6,47 +6,27 @@ fetch('https://assets.antv.antgroup.com/g6/flare.json') const graph = new Graph({ container: 'container', autoFit: 'view', + padding: 50, data: treeToGraphData(data), node: { style: { - size: 20, + size: 12, labelText: (d) => d.id, labelBackground: true, - }, - state: { - active: { - fill: '#00C9C9', - }, + labelFontSize: 14, + labelFontFamily: 'Gill Sans', }, }, edge: { type: 'cubic-radial', - state: { - active: { - lineWidth: 3, - stroke: '#009999', - }, + style: { + lineWidth: 3, }, }, - layout: [ - { - type: 'compact-box', - radial: true, - direction: 'RL', - getHeight: () => { - return 20; - }, - getWidth: () => { - return 20; - }, - getVGap: () => { - return 20; - }, - getHGap: () => { - return 80; - }, - }, - ], + layout: { + type: 'dendrogram', + radial: true, + }, behaviors: [ 'drag-canvas', 'zoom-canvas', @@ -60,6 +40,7 @@ fetch('https://assets.antv.antgroup.com/g6/flare.json') }, ], transforms: ['place-radial-labels'], + animation: false, }); graph.render(); diff --git a/packages/site/examples/scene-case/tree-graph/index.en.md b/packages/site/examples/scene-case/tree-graph/index.en.md new file mode 100644 index 00000000000..fe26fe44a8f --- /dev/null +++ b/packages/site/examples/scene-case/tree-graph/index.en.md @@ -0,0 +1,3 @@ +--- +title: TreeGraph Scene Case +--- diff --git a/packages/site/examples/scene-case/tree-graph/index.zh.md b/packages/site/examples/scene-case/tree-graph/index.zh.md new file mode 100644 index 00000000000..770a32845ab --- /dev/null +++ b/packages/site/examples/scene-case/tree-graph/index.zh.md @@ -0,0 +1,3 @@ +--- +title: 树图场景案例 +--- diff --git a/packages/site/package.json b/packages/site/package.json index cb7911c4d27..2f3dd55c342 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -43,7 +43,7 @@ "@antv/dumi-theme-antv": "^0.5.3", "@antv/g": "^6.1.7", "@antv/g-svg": "^2.0.20", - "@antv/g-webgl": "^2.0.27", + "@antv/g-webgl": "^2.0.28", "@antv/g2": "^5.2.7", "@antv/g6": "workspace:*", "@antv/g6-extension-3d": "workspace:*", diff --git a/packages/site/src/constants/locales/page-name.json b/packages/site/src/constants/locales/page-name.json index a4ffb61dfbb..c2dec7c706a 100644 --- a/packages/site/src/constants/locales/page-name.json +++ b/packages/site/src/constants/locales/page-name.json @@ -36,6 +36,7 @@ "D3Force3DLayout": ["D3Force3D", "3D 力导向布局"], "D3ForceLayout": ["D3Force", "力导向布局"], "DagreLayout": ["Dagre", "层次布局"], + "Fishbone": ["Fishbone", "鱼骨布局"], "ForceAtlas2Layout": ["ForceAtlas2", "力导向布局"], "ForceLayout": ["Force", "力导向布局"], "FruchtermanLayout": ["Fruchterman", "力导向布局"],