From ba9ddfd9995ddfd63275485e4874cb285e678f80 Mon Sep 17 00:00:00 2001 From: Yuxin <55794321+yvonneyx@users.noreply.github.com> Date: Tue, 3 Dec 2024 16:12:11 +0800 Subject: [PATCH] feat: built-in snake layout (#6587) * feat: snake layout * test: add snake layout unit tests * docs: add snake layout docs * fix: typo * chore: update deps * fix: update remarks --- package.json | 4 +- packages/g6-extension-react/package.json | 2 +- packages/g6/__tests__/demos/index.ts | 1 + .../g6/__tests__/demos/layout-fishbone.ts | 2 +- packages/g6/__tests__/demos/layout-snake.ts | 66 ++++ .../layouts/snake/anti-clockwise.svg | 336 ++++++++++++++++++ .../snapshots/layouts/snake/cols-1.svg | 336 ++++++++++++++++++ .../snapshots/layouts/snake/cols-20.svg | 336 ++++++++++++++++++ .../snapshots/layouts/snake/default.svg | 336 ++++++++++++++++++ .../snapshots/layouts/snake/gap-50.svg | 336 ++++++++++++++++++ .../snapshots/layouts/snake/padding-20.svg | 336 ++++++++++++++++++ .../g6/__tests__/unit/layouts/snake.spec.ts | 44 +++ packages/g6/src/exports.ts | 3 +- packages/g6/src/layouts/fishbone.ts | 5 +- packages/g6/src/layouts/index.ts | 3 + packages/g6/src/layouts/snake.ts | 235 ++++++++++++ packages/g6/src/layouts/types.ts | 6 +- packages/g6/src/registry/build-in.ts | 2 + .../site/examples/layout/snake/demo/basic.js | 25 ++ .../site/examples/layout/snake/demo/gutter.js | 28 ++ .../site/examples/layout/snake/demo/meta.json | 24 ++ .../site/examples/layout/snake/index.en.md | 3 + .../site/examples/layout/snake/index.zh.md | 3 + .../default/demo/snake-flow-diagram.js | 50 +-- packages/site/package.json | 2 +- .../site/src/constants/locales/page-name.json | 1 + 26 files changed, 2471 insertions(+), 54 deletions(-) create mode 100644 packages/g6/__tests__/demos/layout-snake.ts create mode 100644 packages/g6/__tests__/snapshots/layouts/snake/anti-clockwise.svg create mode 100644 packages/g6/__tests__/snapshots/layouts/snake/cols-1.svg create mode 100644 packages/g6/__tests__/snapshots/layouts/snake/cols-20.svg create mode 100644 packages/g6/__tests__/snapshots/layouts/snake/default.svg create mode 100644 packages/g6/__tests__/snapshots/layouts/snake/gap-50.svg create mode 100644 packages/g6/__tests__/snapshots/layouts/snake/padding-20.svg create mode 100644 packages/g6/__tests__/unit/layouts/snake.spec.ts create mode 100644 packages/g6/src/layouts/snake.ts create mode 100644 packages/site/examples/layout/snake/demo/basic.js create mode 100644 packages/site/examples/layout/snake/demo/gutter.js create mode 100644 packages/site/examples/layout/snake/demo/meta.json create mode 100644 packages/site/examples/layout/snake/index.en.md create mode 100644 packages/site/examples/layout/snake/index.zh.md diff --git a/package.json b/package.json index de333bf6296..ee0ecec61c2 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@types/d3-hierarchy": "^3.1.7", "@types/jest": "^29.5.14", "@types/jsdom": "^21.1.7", - "@types/node": "^20.17.8", + "@types/node": "^20.17.9", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "chalk": "^4.1.2", @@ -69,7 +69,7 @@ "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-packagejson": "^2.5.6", "rimraf": "^5.0.10", - "rollup": "^4.27.4", + "rollup": "^4.28.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-react/package.json b/packages/g6-extension-react/package.json index 350e0cda0fc..0a0d5345eee 100644 --- a/packages/g6-extension-react/package.json +++ b/packages/g6-extension-react/package.json @@ -40,7 +40,7 @@ "@antv/react-g": "^2.0.30" }, "devDependencies": { - "@ant-design/icons": "^5.5.1", + "@ant-design/icons": "^5.5.2", "@antv/g6": "workspace:*", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/packages/g6/__tests__/demos/index.ts b/packages/g6/__tests__/demos/index.ts index 6b42fa52516..9d48b690c0b 100644 --- a/packages/g6/__tests__/demos/index.ts +++ b/packages/g6/__tests__/demos/index.ts @@ -120,6 +120,7 @@ export { layoutRadialConfigurationTranslate } from './layout-radial-configuratio export { layoutRadialPreventOverlap } from './layout-radial-prevent-overlap'; export { layoutRadialPreventOverlapUnstrict } from './layout-radial-prevent-overlap-unstrict'; export { layoutRadialSort } from './layout-radial-sort'; +export { layoutSnake } from './layout-snake'; export { perf20000Elements } from './perf-20000-elements'; export { perfFCP } from './perf-fcp'; export { pluginBackground } from './plugin-background'; diff --git a/packages/g6/__tests__/demos/layout-fishbone.ts b/packages/g6/__tests__/demos/layout-fishbone.ts index c59a281413e..d3bbc722674 100644 --- a/packages/g6/__tests__/demos/layout-fishbone.ts +++ b/packages/g6/__tests__/demos/layout-fishbone.ts @@ -98,7 +98,7 @@ export const layoutFishbone: TestCase = async (context) => { panel .add(config, 'direction', ['LR', 'RL']) .name('Direction') - .onChange((value: string) => { + .onChange((value: 'LR' | 'RL') => { graph.setLayout((prev) => ({ ...prev, direction: value })); graph.layout(); }), diff --git a/packages/g6/__tests__/demos/layout-snake.ts b/packages/g6/__tests__/demos/layout-snake.ts new file mode 100644 index 00000000000..9ae6b5e1b77 --- /dev/null +++ b/packages/g6/__tests__/demos/layout-snake.ts @@ -0,0 +1,66 @@ +import { Graph } from '@antv/g6'; + +const data = { + nodes: [ + { id: '0', data: { label: '开始流程', time: '17:00:00' } }, + { id: '1', data: { label: '流程1', time: '17:00:05' } }, + { id: '2', data: { label: '流程2', time: '17:00:12' } }, + { id: '3', data: { label: '流程3', time: '17:00:30' } }, + { id: '4', data: { label: '流程4', time: '17:02:00' } }, + { id: '5', data: { label: '流程5', time: '17:02:40' } }, + { id: '6', data: { label: '流程6', time: '17:05:50' } }, + { id: '7', data: { label: '流程7', time: '17:10:00' } }, + { id: '8', data: { label: '流程8', time: '17:11:20' } }, + { id: '9', data: { label: '流程9', time: '17:15:00' } }, + { id: '10', data: { label: '流程10', time: '17:30:00' } }, + { id: '11', data: { label: '流程11' } }, + { id: '12', data: { label: '流程12' } }, + { id: '13', data: { label: '流程13' } }, + { id: '14', data: { label: '流程14' } }, + { id: '15', data: { label: '流程结束' } }, + ], + edges: [ + { source: '0', target: '1', data: { done: true } }, + { source: '1', target: '2', data: { done: true } }, + { source: '2', target: '3', data: { done: true } }, + { source: '3', target: '4', data: { done: true } }, + { source: '4', target: '5', data: { done: true } }, + { source: '5', target: '6', data: { done: true } }, + { source: '6', target: '7', data: { done: true } }, + { source: '7', target: '8', data: { done: true } }, + { source: '8', target: '9', data: { done: true } }, + { source: '9', target: '10', data: { done: true } }, + { source: '10', target: '11', data: { done: false } }, + { source: '11', target: '12', data: { done: false } }, + { source: '12', target: '13', data: { done: false } }, + { source: '13', target: '14', data: { done: false } }, + { source: '14', target: '15', data: { done: false } }, + ], +}; + +export const layoutSnake: TestCase = async (context) => { + const graph = new Graph({ + ...context, + data, + node: { + style: { + labelText: (d) => d.id, + labelPlacement: 'center', + labelFill: '#fff', + }, + }, + edge: { + style: { + endArrow: true, + }, + }, + layout: { + key: 'snake', + type: 'snake', + }, + }); + + await graph.render(); + + return graph; +}; diff --git a/packages/g6/__tests__/snapshots/layouts/snake/anti-clockwise.svg b/packages/g6/__tests__/snapshots/layouts/snake/anti-clockwise.svg new file mode 100644 index 00000000000..5ab7c99e57a --- /dev/null +++ b/packages/g6/__tests__/snapshots/layouts/snake/anti-clockwise.svg @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + 1 + + + + + + + + + + + + 2 + + + + + + + + + + + + 3 + + + + + + + + + + + + 4 + + + + + + + + + + + + 5 + + + + + + + + + + + + 6 + + + + + + + + + + + + 7 + + + + + + + + + + + + 8 + + + + + + + + + + + + 9 + + + + + + + + + + + + 10 + + + + + + + + + + + + 11 + + + + + + + + + + + + 12 + + + + + + + + + + + + 13 + + + + + + + + + + + + 14 + + + + + + + + + + + + 15 + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/snapshots/layouts/snake/cols-1.svg b/packages/g6/__tests__/snapshots/layouts/snake/cols-1.svg new file mode 100644 index 00000000000..cc138e24b49 --- /dev/null +++ b/packages/g6/__tests__/snapshots/layouts/snake/cols-1.svg @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + 1 + + + + + + + + + + + + 2 + + + + + + + + + + + + 3 + + + + + + + + + + + + 4 + + + + + + + + + + + + 5 + + + + + + + + + + + + 6 + + + + + + + + + + + + 7 + + + + + + + + + + + + 8 + + + + + + + + + + + + 9 + + + + + + + + + + + + 10 + + + + + + + + + + + + 11 + + + + + + + + + + + + 12 + + + + + + + + + + + + 13 + + + + + + + + + + + + 14 + + + + + + + + + + + + 15 + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/snapshots/layouts/snake/cols-20.svg b/packages/g6/__tests__/snapshots/layouts/snake/cols-20.svg new file mode 100644 index 00000000000..ade17756472 --- /dev/null +++ b/packages/g6/__tests__/snapshots/layouts/snake/cols-20.svg @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + 1 + + + + + + + + + + + + 2 + + + + + + + + + + + + 3 + + + + + + + + + + + + 4 + + + + + + + + + + + + 5 + + + + + + + + + + + + 6 + + + + + + + + + + + + 7 + + + + + + + + + + + + 8 + + + + + + + + + + + + 9 + + + + + + + + + + + + 10 + + + + + + + + + + + + 11 + + + + + + + + + + + + 12 + + + + + + + + + + + + 13 + + + + + + + + + + + + 14 + + + + + + + + + + + + 15 + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/snapshots/layouts/snake/default.svg b/packages/g6/__tests__/snapshots/layouts/snake/default.svg new file mode 100644 index 00000000000..08a0dd0f884 --- /dev/null +++ b/packages/g6/__tests__/snapshots/layouts/snake/default.svg @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + 1 + + + + + + + + + + + + 2 + + + + + + + + + + + + 3 + + + + + + + + + + + + 4 + + + + + + + + + + + + 5 + + + + + + + + + + + + 6 + + + + + + + + + + + + 7 + + + + + + + + + + + + 8 + + + + + + + + + + + + 9 + + + + + + + + + + + + 10 + + + + + + + + + + + + 11 + + + + + + + + + + + + 12 + + + + + + + + + + + + 13 + + + + + + + + + + + + 14 + + + + + + + + + + + + 15 + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/snapshots/layouts/snake/gap-50.svg b/packages/g6/__tests__/snapshots/layouts/snake/gap-50.svg new file mode 100644 index 00000000000..1f417233b2b --- /dev/null +++ b/packages/g6/__tests__/snapshots/layouts/snake/gap-50.svg @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + 1 + + + + + + + + + + + + 2 + + + + + + + + + + + + 3 + + + + + + + + + + + + 4 + + + + + + + + + + + + 5 + + + + + + + + + + + + 6 + + + + + + + + + + + + 7 + + + + + + + + + + + + 8 + + + + + + + + + + + + 9 + + + + + + + + + + + + 10 + + + + + + + + + + + + 11 + + + + + + + + + + + + 12 + + + + + + + + + + + + 13 + + + + + + + + + + + + 14 + + + + + + + + + + + + 15 + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/snapshots/layouts/snake/padding-20.svg b/packages/g6/__tests__/snapshots/layouts/snake/padding-20.svg new file mode 100644 index 00000000000..9ed640eacf5 --- /dev/null +++ b/packages/g6/__tests__/snapshots/layouts/snake/padding-20.svg @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + 1 + + + + + + + + + + + + 2 + + + + + + + + + + + + 3 + + + + + + + + + + + + 4 + + + + + + + + + + + + 5 + + + + + + + + + + + + 6 + + + + + + + + + + + + 7 + + + + + + + + + + + + 8 + + + + + + + + + + + + 9 + + + + + + + + + + + + 10 + + + + + + + + + + + + 11 + + + + + + + + + + + + 12 + + + + + + + + + + + + 13 + + + + + + + + + + + + 14 + + + + + + + + + + + + 15 + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/unit/layouts/snake.spec.ts b/packages/g6/__tests__/unit/layouts/snake.spec.ts new file mode 100644 index 00000000000..4abd2ec21ea --- /dev/null +++ b/packages/g6/__tests__/unit/layouts/snake.spec.ts @@ -0,0 +1,44 @@ +import type { Graph } from '@/src'; +import { layoutSnake } from '@@/demos/layout-snake'; +import { createDemoGraph } from '@@/utils'; + +describe('snake', () => { + let graph: Graph; + + beforeAll(async () => { + graph = await createDemoGraph(layoutSnake); + }); + + afterAll(() => { + graph.destroy(); + }); + + it('default', async () => { + await expect(graph).toMatchSnapshot(__filename, 'default'); + }); + + it('padding', async () => { + graph.setLayout((prev) => ({ ...prev, padding: 20 })), await graph.layout(); + await expect(graph).toMatchSnapshot(__filename, 'padding-20'); + }); + + it('set cols as 1', async () => { + graph.setLayout((prev) => ({ ...prev, padding: 0, cols: 1 })), await graph.layout(); + await expect(graph).toMatchSnapshot(__filename, 'cols-1'); + }); + + it('set cols as 20', async () => { + graph.setLayout((prev) => ({ ...prev, padding: 0, cols: 20 })), await graph.layout(); + await expect(graph).toMatchSnapshot(__filename, 'cols-20'); + }); + + it('colSep and rowSep', async () => { + graph.setLayout((prev) => ({ ...prev, cols: 6, colGap: 50, rowGap: 50 })), await graph.layout(); + await expect(graph).toMatchSnapshot(__filename, 'gap-50'); + }); + + it('anti-clockwise', async () => { + graph.setLayout((prev) => ({ ...prev, clockwise: false })), await graph.layout(); + await expect(graph).toMatchSnapshot(__filename, 'anti-clockwise'); + }); +}); diff --git a/packages/g6/src/exports.ts b/packages/g6/src/exports.ts index 50995763dee..cf4d69874c5 100644 --- a/packages/g6/src/exports.ts +++ b/packages/g6/src/exports.ts @@ -73,6 +73,7 @@ export { mindmap as MindmapLayout, RadialLayout, RandomLayout, + SnakeLayout, } from './layouts'; export { Background, @@ -181,7 +182,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 { FishboneLayoutOptions, SnakeLayoutOptions } 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 index d69c63fd753..dffd4d1e41f 100644 --- a/packages/g6/src/layouts/fishbone.ts +++ b/packages/g6/src/layouts/fishbone.ts @@ -1,11 +1,12 @@ import { isEmpty, memoize } from '@antv/util'; -import { idOf } from '../exports'; +import type { BaseLayoutOptions } from '../layouts/types'; import type { EdgeData, GraphData, NodeData } from '../spec'; import type { ElementDatum, ID, Point, Size, STDSize } from '../types'; +import { idOf } from '../utils/id'; import { parseSize } from '../utils/size'; import { BaseLayout } from './base-layout'; -export interface FishboneLayoutOptions { +export interface FishboneLayoutOptions extends BaseLayoutOptions { /** * 节点大小 * diff --git a/packages/g6/src/layouts/index.ts b/packages/g6/src/layouts/index.ts index 5b1b66e0dfa..1fbaf6518fd 100644 --- a/packages/g6/src/layouts/index.ts +++ b/packages/g6/src/layouts/index.ts @@ -16,4 +16,7 @@ export { } from '@antv/layout'; export { BaseLayout } from './base-layout'; export { FishboneLayout } from './fishbone'; +export { SnakeLayout } from './snake'; + export type { FishboneLayoutOptions } from './fishbone'; +export type { SnakeLayoutOptions } from './snake'; diff --git a/packages/g6/src/layouts/snake.ts b/packages/g6/src/layouts/snake.ts new file mode 100644 index 00000000000..c4882ecf4f6 --- /dev/null +++ b/packages/g6/src/layouts/snake.ts @@ -0,0 +1,235 @@ +import type { GraphData, NodeData } from '../spec'; +import type { ID, Padding, Size } from '../types'; +import { parsePadding } from '../utils/padding'; +import { parseSize } from '../utils/size'; +import { BaseLayout } from './base-layout'; +import type { BaseLayoutOptions } from './types'; + +export interface SnakeLayoutOptions extends BaseLayoutOptions { + /** + * 节点尺寸 + * + * Node size + */ + nodeSize?: Size | ((node: NodeData) => Size); + /** + * 内边距,即布局区域与画布边界的距离 + * + * Padding, the distance between the layout area and the canvas boundary + * @defaultValue 0 + */ + padding?: Padding; + /** + * 节点排序方法。默认按照在图中的路径顺序进行展示 + * + * Node sorting method + */ + sortBy?: (nodeA: NodeData, nodeB: NodeData) => -1 | 0 | 1; + /** + * 节点列数 + * + * Number of node columns + * @defaultValue 5 + */ + cols?: number; + /** + * 节点行之间的间隙大小。默认将根据画布高度和节点总行数自动计算 + * + * The size of the gap between a node's rows + */ + rowGap?: number; + /** + * 节点列之间的间隙大小。默认将根据画布宽度和节点总列数自动计算 + * + * The size of the gap between a node's columns + */ + colGap?: number; + /** + * 节点排布方向是否顺时针 + * + * Whether the node arrangement direction is clockwise + * @defaultValue true + * @remarks + * 在顺时针排布时,节点从左上角开始,第一行从左到右排列,第二行从右到左排列,依次类推,形成 S 型路径。在逆时针排布时,节点从右上角开始,第一行从右到左排列,第二行从左到右排列,依次类推,形成反向 S 型路径。 + * + * When arranged clockwise, the nodes start from the upper left corner, the first row is arranged from left to right, the second row is arranged from right to left, and so on, forming an S-shaped path. When arranged counterclockwise, the nodes start from the upper right corner, the first row is arranged from right to left, the second row is arranged from left to right, and so on, forming a reverse S-shaped path. + */ + clockwise?: boolean; +} + +/** + * 蛇形布局 + * + * Snake layout + * @remarks + * 蛇形布局(Snake Layout)是一种特殊的图形布局方式,能够在较小的空间内更有效地展示长链结构。需要注意的是,其图数据需要确保节点按照从源节点到汇节点的顺序进行线性排列,形成一条明确的路径。 + * + * 节点按 S 字型排列,第一个节点位于第一行的起始位置,接下来的节点在第一行向右排列,直到行末尾。到达行末尾后,下一行的节点从右向左反向排列。这个过程重复进行,直到所有节点排列完毕。 + * + * The Snake layout is a special way of graph layout that can more effectively display long chain structures in a smaller space. Note that the graph data needs to ensure that the nodes are linearly arranged in the order from the source node to the sink node to form a clear path. + * + * The nodes are arranged in an S-shaped pattern, with the first node at the beginning of the first row, and the following nodes arranged to the right until the end of the row. After reaching the end of the row, the nodes in the next row are arranged in reverse from right to left. This process is repeated until all nodes are arranged. + */ +export class SnakeLayout extends BaseLayout { + public id = 'snake'; + + static defaultOptions: Partial = { + padding: 0, + cols: 5, + clockwise: true, + }; + + private formatSize(nodes: NodeData[], size?: Size | ((node: NodeData) => Size)): [number, number] { + const sizeFn = typeof size === 'function' ? size : ((() => size) as (node: NodeData) => Size); + return nodes.reduce( + (acc, node) => { + const [w, h] = parseSize(sizeFn(node)) || [0, 0]; + return [Math.max(acc[0], w), Math.max(acc[1], h)]; + }, + [0, 0], + ); + } + + /** + * Validates the graph data to ensure it meets the requirements for linear arrangement. + * @param data - Graph data + * @returns false if the graph is not connected, has more than one source or sink node, or contains cycles. + */ + private validate(data: GraphData): boolean { + const { nodes = [], edges = [] } = data; + const inDegree: { [key: ID]: number } = {}; + const outDegree: { [key: ID]: number } = {}; + const adjList: { [key: ID]: ID[] } = {}; + + nodes.forEach((node) => { + inDegree[node.id] = 0; + outDegree[node.id] = 0; + adjList[node.id] = []; + }); + + edges.forEach((edge) => { + inDegree[edge.target]++; + outDegree[edge.source]++; + adjList[edge.source].push(edge.target); + }); + + // 检查图是否连通 + // Check if the graph is connected + const visited: Set = new Set(); + const dfs = (nodeId: ID) => { + if (visited.has(nodeId)) return; + visited.add(nodeId); + adjList[nodeId].forEach(dfs); + }; + dfs(nodes[0].id); + if (visited.size !== nodes.length) return false; + + // 检查是否有且仅有一个源节点和一个汇节点 + // Check if there is exactly one source node and one sink node + const sourceNodes = nodes.filter((node) => inDegree[node.id] === 0); + const sinkNodes = nodes.filter((node) => outDegree[node.id] === 0); + if (sourceNodes.length !== 1 || sinkNodes.length !== 1) return false; + + // 检查中间节点是否只有一个前驱和一个后继 + // Check if the middle nodes have only one predecessor and one successor + const middleNodes = nodes.filter((node) => inDegree[node.id] === 1 && outDegree[node.id] === 1); + if (middleNodes.length !== nodes.length - 2) return false; + + return true; + } + + async execute(model: GraphData, options?: SnakeLayoutOptions): Promise { + if (!this.validate(model)) return model; + + const { + nodeSize: propNodeSize, + padding: propPadding, + sortBy, + cols, + colGap: propColSep, + rowGap: propRowSep, + clockwise, + width, + height, + } = Object.assign({}, SnakeLayout.defaultOptions, this.options, options) as Required; + + const [top, right, bottom, left] = parsePadding(propPadding); + const nodeSize = this.formatSize(model.nodes || [], propNodeSize); + + const rows = Math.ceil((model.nodes || []).length / cols); + let colSep = propColSep ? propColSep : (width - left - right - cols * nodeSize[0]) / (cols - 1); + let rowSep = propRowSep ? propRowSep : (height - top - bottom - rows * nodeSize[1]) / (rows - 1); + if (rowSep === Infinity || rowSep < 0) rowSep = 0; + if (colSep === Infinity || colSep < 0) colSep = 0; + + const sortedNodes = sortBy ? model.nodes?.sort(sortBy) : topologicalSort(model); + + const nodes = (sortedNodes || []).map((node, index) => { + const rowIndex = Math.floor(index / cols); + const colIndex = index % cols; + + const actualColIndex = clockwise + ? rowIndex % 2 === 0 + ? colIndex + : cols - 1 - colIndex + : rowIndex % 2 === 0 + ? cols - 1 - colIndex + : colIndex; + + const x = left + actualColIndex * (nodeSize[0] + colSep) + nodeSize[0] / 2; + const y = top + rowIndex * (nodeSize[1] + rowSep) + nodeSize[1] / 2; + + return { + id: node.id, + style: { x, y }, + }; + }); + + return { nodes }; + } +} + +/** + * Topological sorting. The nodes are sorted according to the order of the paths(from the start node to the end node). + * @param data - Graph data + * @returns Sorted nodes + */ +function topologicalSort(data: GraphData): NodeData[] { + const { nodes = [], edges = [] } = data; + const inDegree: { [key: ID]: number } = {}; + const adjList: { [key: ID]: ID[] } = {}; + + nodes.forEach((node) => { + inDegree[node.id] = 0; + adjList[node.id] = []; + }); + + edges.forEach((edge) => { + inDegree[edge.target]++; + adjList[edge.source].push(edge.target); + }); + + const queue: ID[] = []; + const sortedNodes: NodeData[] = []; + + nodes.forEach((node) => { + if (inDegree[node.id] === 0) { + queue.push(node.id); + } + }); + + while (queue.length > 0) { + const nodeId = queue.shift()!; + const node = nodes.find((n) => n.id === nodeId)!; + sortedNodes.push(node); + + adjList[nodeId].forEach((neighbor) => { + inDegree[neighbor]--; + if (inDegree[neighbor] === 0) { + queue.push(neighbor); + } + }); + } + + return sortedNodes; +} diff --git a/packages/g6/src/layouts/types.ts b/packages/g6/src/layouts/types.ts index ef4da4a17a6..23ce931b022 100644 --- a/packages/g6/src/layouts/types.ts +++ b/packages/g6/src/layouts/types.ts @@ -17,6 +17,8 @@ import type { } from '@antv/layout'; import type { NodeData } from '../spec/data'; import type { BaseLayout } from './base-layout'; +import type { FishboneLayoutOptions } from './fishbone'; +import type { SnakeLayoutOptions } from './snake'; export type BuiltInLayoutOptions = | AntVDagreLayout @@ -31,7 +33,9 @@ export type BuiltInLayoutOptions = | GridLayout | MDSLayout | RadialLayout - | RandomLayout; + | RandomLayout + | SnakeLayoutOptions + | FishboneLayoutOptions; export interface BaseLayoutOptions extends AnimationOptions, WebWorkerLayoutOptions, Record { /** diff --git a/packages/g6/src/registry/build-in.ts b/packages/g6/src/registry/build-in.ts index 5fb1bed73bd..75ce1a94220 100644 --- a/packages/g6/src/registry/build-in.ts +++ b/packages/g6/src/registry/build-in.ts @@ -65,6 +65,7 @@ import { MDSLayout, RadialLayout, RandomLayout, + SnakeLayout, compactBox, dendrogram, indented, @@ -167,6 +168,7 @@ const BUILT_IN_EXTENSIONS: ExtensionRegistry = { mindmap, radial: RadialLayout, random: RandomLayout, + snake: SnakeLayout, }, node: { circle: Circle, diff --git a/packages/site/examples/layout/snake/demo/basic.js b/packages/site/examples/layout/snake/demo/basic.js new file mode 100644 index 00000000000..b05a66cf050 --- /dev/null +++ b/packages/site/examples/layout/snake/demo/basic.js @@ -0,0 +1,25 @@ +import { Graph } from '@antv/g6'; + +const data = { + nodes: new Array(16).fill(0).map((_, i) => ({ id: `${i}` })), + edges: new Array(15).fill(0).map((_, i) => ({ source: `${i}`, target: `${i + 1}` })), +}; + +const graph = new Graph({ + container: 'container', + data, + node: { + style: { + labelFill: '#fff', + labelPlacement: 'center', + labelText: (d) => d.id, + }, + }, + layout: { + type: 'snake', + padding: 50, + }, + behaviors: ['drag-canvas', 'drag-element'], +}); + +graph.render(); diff --git a/packages/site/examples/layout/snake/demo/gutter.js b/packages/site/examples/layout/snake/demo/gutter.js new file mode 100644 index 00000000000..57c62586f75 --- /dev/null +++ b/packages/site/examples/layout/snake/demo/gutter.js @@ -0,0 +1,28 @@ +import { Graph } from '@antv/g6'; + +const data = { + nodes: new Array(16).fill(0).map((_, i) => ({ id: `${i}` })), + edges: new Array(15).fill(0).map((_, i) => ({ source: `${i}`, target: `${i + 1}` })), +}; + +const graph = new Graph({ + container: 'container', + autoFit: 'center', + data, + node: { + style: { + labelFill: '#fff', + labelPlacement: 'center', + labelText: (d) => d.id, + }, + }, + layout: { + type: 'snake', + cols: 3, + rowGap: 80, + colGap: 120, + }, + behaviors: ['drag-canvas', 'drag-element'], +}); + +graph.render(); diff --git a/packages/site/examples/layout/snake/demo/meta.json b/packages/site/examples/layout/snake/demo/meta.json new file mode 100644 index 00000000000..5b24d181e8c --- /dev/null +++ b/packages/site/examples/layout/snake/demo/meta.json @@ -0,0 +1,24 @@ +{ + "title": { + "zh": "中文分类", + "en": "Category" + }, + "demos": [ + { + "filename": "basic.js", + "title": { + "zh": "基本 Snake 布局", + "en": "Basic Snake Layout" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*pIpcQ6yX7P0AAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "gutter.js", + "title": { + "zh": "自定义间距的 Snake 布局", + "en": "Snake Layout with Gutter" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*g-5JSoayUzAAAAAAAAAAAAAADmJ7AQ/original" + } + ] +} diff --git a/packages/site/examples/layout/snake/index.en.md b/packages/site/examples/layout/snake/index.en.md new file mode 100644 index 00000000000..6a06f79ca5d --- /dev/null +++ b/packages/site/examples/layout/snake/index.en.md @@ -0,0 +1,3 @@ +--- +title: Snake Layout +--- diff --git a/packages/site/examples/layout/snake/index.zh.md b/packages/site/examples/layout/snake/index.zh.md new file mode 100644 index 00000000000..6515388807f --- /dev/null +++ b/packages/site/examples/layout/snake/index.zh.md @@ -0,0 +1,3 @@ +--- +title: Snake 蛇形布局 +--- diff --git a/packages/site/examples/scene-case/default/demo/snake-flow-diagram.js b/packages/site/examples/scene-case/default/demo/snake-flow-diagram.js index 1ab80972ff2..05ddf2a85fb 100644 --- a/packages/site/examples/scene-case/default/demo/snake-flow-diagram.js +++ b/packages/site/examples/scene-case/default/demo/snake-flow-diagram.js @@ -1,4 +1,4 @@ -import { BaseLayout, ExtensionCategory, Graph, Polyline, positionOf, register } from '@antv/g6'; +import { ExtensionCategory, Graph, Polyline, positionOf, register } from '@antv/g6'; const data = { nodes: [ @@ -38,44 +38,6 @@ const data = { ], }; -class SnakeLayout extends BaseLayout { - id = 's-layout'; - - async execute(data, options) { - const { - width, - height, - nodeSize = 32, - cols = 5, - sep: propSep, - nodeSep: propNodeSep, - } = { ...this.options, ...options }; - - const totalRows = Math.ceil(data.nodes.length / cols); - const sep = propSep ? propSep : height / (totalRows - 1) - nodeSize; - const nodeSep = propNodeSep ? propNodeSep : width / cols - nodeSize; - - const nodes = data.nodes.map((node, index) => { - const row = Math.floor(index / cols); - const col = index % cols; - const x = (col + 0.5) * (nodeSize + nodeSep); - - let y = row * (nodeSize + sep); - if (row === 0) y += nodeSize / 2; - if (row === totalRows - 1) y -= nodeSize / 2; - - const adjustedX = row % 2 === 0 ? x : (cols - col - 0.5) * (nodeSize + nodeSep); - - return { - id: node.id, - style: { x: adjustedX, y }, - }; - }); - - return { nodes }; - } -} - class SnakePolyline extends Polyline { getPoints(attributes) { const [sourcePoint, targetPoint] = this.getEndpoints(attributes, false); @@ -88,7 +50,7 @@ class SnakePolyline extends Polyline { if (!prevPointId) return [sourcePoint, targetPoint]; const prevPoint = positionOf(this.context.model.getNodeLikeDatum(prevPointId)); - const offset = -(prevPoint[0] - sourcePoint[0]) / 2; + const offset = -(prevPoint[0] - sourcePoint[0]) / 4; return [ sourcePoint, [sourcePoint[0] + offset, sourcePoint[1]], @@ -98,7 +60,6 @@ class SnakePolyline extends Polyline { } } -register(ExtensionCategory.LAYOUT, 's-layout', SnakeLayout); register(ExtensionCategory.EDGE, 's-polyline', SnakePolyline); const graph = new Graph({ @@ -137,11 +98,10 @@ const graph = new Graph({ }, }, layout: { - type: 's-layout', + type: 'snake', cols: 6, - sep: 120, - nodeSep: 80, - nodeSize: 32, + rowGap: 200, + padding: [20, 140, 80], }, behaviors: ['drag-canvas', 'zoom-canvas'], }); diff --git a/packages/site/package.json b/packages/site/package.json index 925bb5fe44e..4b92e138602 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -38,7 +38,7 @@ "preview": "dumi preview" }, "dependencies": { - "@ant-design/icons": "^5.5.1", + "@ant-design/icons": "^5.5.2", "@antv/algorithm": "^0.1.26", "@antv/dumi-theme-antv": "^0.5.3", "@antv/g": "^6.1.14", diff --git a/packages/site/src/constants/locales/page-name.json b/packages/site/src/constants/locales/page-name.json index c2dec7c706a..fa65908c04a 100644 --- a/packages/site/src/constants/locales/page-name.json +++ b/packages/site/src/constants/locales/page-name.json @@ -44,6 +44,7 @@ "MdsLayout": ["Mds", "高维数据降维布局"], "RadialLayout": ["Radial", "径向布局"], "RandomLayout": ["Random", "随机布局"], + "SnakeLayout": ["Snake", "蛇形布局"], "BaseBehavior": ["BaseBehavior", "基础交互"], "AutoAdaptLabel": ["AutoAdaptLabel", "标签自适应显示"], "BrushSelect": ["BrushSelect", "框选"],