From e99ea29ca938442d64b89d9c60675c48c592cff7 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Tue, 11 Nov 2025 14:39:52 +0800 Subject: [PATCH 1/6] feat: implement comprehensive tests for circular layout and utility functions --- .editorconfig | 2 +- .vscode/settings.json | 3 +- docs/api/circular.md | 232 +++++++ packages/layout/__tests__/demos/circular.ts | 31 +- .../counterclockwise-radius-range.svg | 198 ++++++ .../snapshots/circular/custom-center.svg | 198 ++++++ .../__tests__/snapshots/circular/default.svg | 256 ++++---- .../snapshots/circular/divisions-5.svg | 198 ++++++ .../snapshots/circular/endRadius-only.svg | 198 ++++++ .../circular/nodeSize-nodeSpacing-fn.svg | 198 ++++++ .../snapshots/circular/ordering-degree.svg | 582 ++++++++++++++++++ .../snapshots/circular/ordering-original.svg | 582 ++++++++++++++++++ .../circular/ordering-topology-directed.svg | 582 ++++++++++++++++++ .../snapshots/circular/ordering-topology.svg | 582 ++++++++++++++++++ .../snapshots/circular/radius-180.svg | 198 ++++++ .../circular/startAngle-PI-endAngle-2PI.svg | 198 ++++++ .../startRadius-100-endRadius-200.svg | 198 ++++++ .../snapshots/circular/startRadius-only.svg | 198 ++++++ .../layout/__tests__/unit/circular.test.ts | 243 +++++++- .../layout/__tests__/unit/util/array.test.ts | 28 + .../layout/__tests__/unit/util/common.test.ts | 111 ++++ .../__tests__/unit/util/function.test.ts | 221 +++++++ .../layout/__tests__/unit/util/object.test.ts | 215 +++++++ .../layout/__tests__/unit/util/size.test.ts | 1 + packages/layout/__tests__/utils/create.ts | 4 +- packages/layout/__tests__/utils/index.ts | 2 +- packages/layout/__tests__/utils/render.ts | 70 ++- packages/layout/jest.config.js | 2 +- packages/layout/package.json | 14 +- packages/layout/src/util/index.ts | 9 +- 30 files changed, 5389 insertions(+), 165 deletions(-) create mode 100644 docs/api/circular.md create mode 100644 packages/layout/__tests__/snapshots/circular/counterclockwise-radius-range.svg create mode 100644 packages/layout/__tests__/snapshots/circular/custom-center.svg create mode 100644 packages/layout/__tests__/snapshots/circular/divisions-5.svg create mode 100644 packages/layout/__tests__/snapshots/circular/endRadius-only.svg create mode 100644 packages/layout/__tests__/snapshots/circular/nodeSize-nodeSpacing-fn.svg create mode 100644 packages/layout/__tests__/snapshots/circular/ordering-degree.svg create mode 100644 packages/layout/__tests__/snapshots/circular/ordering-original.svg create mode 100644 packages/layout/__tests__/snapshots/circular/ordering-topology-directed.svg create mode 100644 packages/layout/__tests__/snapshots/circular/ordering-topology.svg create mode 100644 packages/layout/__tests__/snapshots/circular/radius-180.svg create mode 100644 packages/layout/__tests__/snapshots/circular/startAngle-PI-endAngle-2PI.svg create mode 100644 packages/layout/__tests__/snapshots/circular/startRadius-100-endRadius-200.svg create mode 100644 packages/layout/__tests__/snapshots/circular/startRadius-only.svg create mode 100644 packages/layout/__tests__/unit/util/array.test.ts create mode 100644 packages/layout/__tests__/unit/util/common.test.ts create mode 100644 packages/layout/__tests__/unit/util/function.test.ts create mode 100644 packages/layout/__tests__/unit/util/object.test.ts diff --git a/.editorconfig b/.editorconfig index b7ee6ef3..25fec9f7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,4 +18,4 @@ indent_size = 1 [*.md] trim_trailing_whitespace = false -indent_size = 4 +indent_size = 2 diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d827228..88c9a298 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,7 +6,8 @@ "graphlib", "nodesep", "rankdir", - "ranksep" + "ranksep", + "relayout" ], "rust-analyzer.linkedProjects": ["./packages/layout-rust/Cargo.toml"] } diff --git a/docs/api/circular.md b/docs/api/circular.md new file mode 100644 index 00000000..02d191be --- /dev/null +++ b/docs/api/circular.md @@ -0,0 +1,232 @@ +# Circular + +Circular layout arranges the node on a circle. By tuning the configurations, user can adjust the node ordering method, division number, radial layout, and so on. We implements it according to the paper: [A framework and algorithms for circular drawings of graphs](https://www.sciencedirect.com/science/article/pii/S1570866705000031). + +

+ +

+ +## Usage + +First we need to create a graph model with `@antv/graphlib`. + +```ts +import { Graph } from '@antv/graphlib'; + +const graph = new Graph({ + nodes: [ + { id: 'node1', data: {} }, + { id: 'node2', data: {} }, + { id: 'node3', data: {} }, + // ... + ], + edges: [ + { id: 'edge1', source: 'node1', target: 'node2', data: {} }, + // ... + ], +}); +``` + +Then select a circular layout from `@antv/layout`. + +```ts +import { CircularLayout } from '@antv/layout'; + +const circular = new CircularLayout({ + center: [250, 250], + radius: 200, +}); +``` + +Returns the positions of nodes after calculating the layout. + +```ts +const positions = await circular.execute(graph); +// positions = { nodes: [...], edges: [...] } +``` + +Or we can directly assign the positions to the nodes by calling `assign`: + +```ts +await circular.assign(graph); +``` + +## Options + +| Key | Description | Type | Default | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------- | +| width | The width of the layout. | `number` | `undefined` | +| height | The height of the layout. | `number` | `undefined` | +| center | The center of the layout. | `[number, number]` | `[width / 2, height / 2]` | +| radius | The radius of the circle. If specified, `startRadius` and `endRadius` will be ignored. | `number` | `null` | +| startRadius | The start radius for spiral layout. | `number` | `null` | +| endRadius | The end radius for spiral layout. | `number` | `null` | +| startAngle | The start angle in radians for placing nodes. | `number` | `0` | +| endAngle | The end angle in radians for placing nodes. | `number` | `2 * Math.PI` | +| clockwise | Whether to place nodes in clockwise order. | `boolean` | `true` | +| divisions | The division number of nodes on the circle. Takes effect when `endRadius - startRadius !== 0`. | `number` | `1` | +| ordering | The ordering method for nodes. `null` means nodes are arranged in data order; `'topology'` means in topology order; `'topology-directed'` means in directed topology order; `'degree'` means in degree order. | null | 'topology' | 'topology-directed' | 'degree' | `null` | +| angleRatio | The ratio of the angle between nodes. For example, `angleRatio: 1` means the angle between nodes is `(endAngle - startAngle) / nodeCount`. | `number` | `1` | +| nodeSize | The size of nodes. Used with `nodeSpacing` to calculate radius automatically. | number | [number, number] | ((node: Node) => number | [number, number]) | `undefined` | +| nodeSpacing | The spacing between nodes. Used with `nodeSize` to calculate radius automatically. | number | ((node: Node) => number) | `undefined` | + +## Layout Types + +### Circle Layout + +When `radius` is specified, nodes are arranged on a single circle. + +```ts +const circular = new CircularLayout({ + center: [250, 250], + radius: 200, +}); +``` + +```plain + node2 + ● + ● ● + node1 node3 + + ● ● ● + node8 C node4 + + ● ● + node7 node5 + ● + node6 + +C = center (250, 250) +radius = 200 +``` + +### Spiral Layout + +When `startRadius` and `endRadius` are specified (without `radius`), nodes are arranged in a spiral pattern. + +```ts +const circular = new CircularLayout({ + center: [250, 250], + startRadius: 50, + endRadius: 200, +}); +``` + +```plain + node_n + ● + ● ● + ● + ● C ● + node1 + ● ● + ● + +Nodes spiral outward from startRadius to endRadius +``` + +### Multi-Division Layout + +When `divisions > 1`, nodes are divided into multiple arcs. + +```ts +const circular = new CircularLayout({ + center: [250, 250], + radius: 200, + divisions: 4, +}); +``` + +```plain +Division 1 Division 2 + ● ● ● ● + + ● ● + C + ● ● + + ● ● ● ● +Division 4 Division 3 + +Each division occupies 2π/4 radians +``` + +## Node Ordering + +### Default Ordering (null) + +Nodes are arranged in their original data order. + +### Topology Ordering + +Nodes are arranged based on graph topology, placing neighbors close to each other. + +```ts +const circular = new CircularLayout({ + ordering: 'topology', +}); +``` + +### Topology-Directed Ordering + +Similar to topology ordering but considers edge direction. + +```ts +const circular = new CircularLayout({ + ordering: 'topology-directed', +}); +``` + +### Degree Ordering + +Nodes are sorted by their degree (number of connections) in ascending order. + +```ts +const circular = new CircularLayout({ + ordering: 'degree', +}); +``` + +## Methods + +### execute + +# **execute**(graph: Graph, options?: CircularLayoutOptions): Promise<LayoutMapping> + +Returns the positions of nodes and edges after calculating the layout. + +**Parameters:** + +- `graph`: The graph instance. +- `options`: Optional layout options to override the constructor options. + +**Returns:** + +```ts +{ + nodes: Node[], // nodes with x, y positions in data + edges: Edge[] // edges +} +``` + +### assign + +# **assign**(graph: Graph, options?: CircularLayoutOptions): Promise<void> + +Directly assigns the calculated positions to the nodes in the graph. + +**Parameters:** + +- `graph`: The graph instance. +- `options`: Optional layout options to override the constructor options. + +## Use Cases + +Circular layout is particularly useful for: + +- **Social networks**: Visualizing connections in a circular pattern +- **Cycle detection**: Highlighting circular dependencies +- **Timeline visualization**: Arranging events in a circular timeline +- **Periodic data**: Displaying seasonal or cyclical patterns +- **Network topology**: Showing network structure in a symmetric way diff --git a/packages/layout/__tests__/demos/circular.ts b/packages/layout/__tests__/demos/circular.ts index 3daf02df..34d7236c 100644 --- a/packages/layout/__tests__/demos/circular.ts +++ b/packages/layout/__tests__/demos/circular.ts @@ -34,53 +34,64 @@ export function render(canvas: Canvas, gui?: GUI) { endAngle: 2 * Math.PI, clockwise: true, divisions: 1, + ordering: 'original', }; folder.add(config, 'centerX', 0, 500).onChange((centerX: number) => { - relayout({ center: [centerX, config.centerY] }); + relayout({ ...config, center: [centerX, config.centerY] }); }); folder.add(config, 'centerY', 0, 500).onChange((centerY: number) => { - relayout({ center: [config.centerX, centerY] }); + relayout({ ...config, center: [config.centerX, centerY] }); }); folder.add(config, 'radius', 0, 500).onChange((radius: number) => { - relayout({ radius }); + relayout({ ...config, radius }); }); folder .add(config, 'startRadius', 0, 500) .onChange((startRadius: number) => { - relayout({ radius: 0, startRadius, endRadius: config.endRadius }); + relayout({ ...config, radius: 0, startRadius }); }); folder.add(config, 'endRadius', 0, 500).onChange((endRadius: number) => { - relayout({ radius: 0, endRadius, startRadius: config.startRadius }); + relayout({ ...config, radius: 0, endRadius }); }); folder .add(config, 'startAngle', 0, 2 * Math.PI) .onChange((startAngle: number) => { - relayout({ startAngle }); + relayout({ ...config, startAngle }); }); folder .add(config, 'endAngle', 0, 2 * Math.PI) .onChange((endAngle: number) => { - relayout({ endAngle }); + relayout({ ...config, endAngle }); }); folder.add(config, 'clockwise').onChange((clockwise: boolean) => { relayout({ + ...config, clockwise, radius: 0, - endRadius: config.endRadius, - startRadius: config.startRadius, }); }); folder.add(config, 'divisions', 0, 10).onChange((divisions: number) => { - relayout({ divisions }); + relayout({ ...config, divisions }); }); + + folder + .add(config, 'ordering', [ + 'original', + 'degree', + 'topology', + 'topology-directed', + ]) + .onChange((ordering) => { + relayout({ ...config, ordering }); + }); } return canvas; diff --git a/packages/layout/__tests__/snapshots/circular/counterclockwise-radius-range.svg b/packages/layout/__tests__/snapshots/circular/counterclockwise-radius-range.svg new file mode 100644 index 00000000..d2d036d6 --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/counterclockwise-radius-range.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/custom-center.svg b/packages/layout/__tests__/snapshots/circular/custom-center.svg new file mode 100644 index 00000000..213d277e --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/custom-center.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/default.svg b/packages/layout/__tests__/snapshots/circular/default.svg index e7100e2f..1bb3e90f 100644 --- a/packages/layout/__tests__/snapshots/circular/default.svg +++ b/packages/layout/__tests__/snapshots/circular/default.svg @@ -1,197 +1,197 @@ - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + diff --git a/packages/layout/__tests__/snapshots/circular/divisions-5.svg b/packages/layout/__tests__/snapshots/circular/divisions-5.svg new file mode 100644 index 00000000..28a8497c --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/divisions-5.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/endRadius-only.svg b/packages/layout/__tests__/snapshots/circular/endRadius-only.svg new file mode 100644 index 00000000..1bb3e90f --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/endRadius-only.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/nodeSize-nodeSpacing-fn.svg b/packages/layout/__tests__/snapshots/circular/nodeSize-nodeSpacing-fn.svg new file mode 100644 index 00000000..09daebf1 --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/nodeSize-nodeSpacing-fn.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/ordering-degree.svg b/packages/layout/__tests__/snapshots/circular/ordering-degree.svg new file mode 100644 index 00000000..4ddf60cf --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/ordering-degree.svg @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/ordering-original.svg b/packages/layout/__tests__/snapshots/circular/ordering-original.svg new file mode 100644 index 00000000..9fa1e9fd --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/ordering-original.svg @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/ordering-topology-directed.svg b/packages/layout/__tests__/snapshots/circular/ordering-topology-directed.svg new file mode 100644 index 00000000..016a5542 --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/ordering-topology-directed.svg @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/ordering-topology.svg b/packages/layout/__tests__/snapshots/circular/ordering-topology.svg new file mode 100644 index 00000000..6b9597f3 --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/ordering-topology.svg @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/radius-180.svg b/packages/layout/__tests__/snapshots/circular/radius-180.svg new file mode 100644 index 00000000..a3780977 --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/radius-180.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/startAngle-PI-endAngle-2PI.svg b/packages/layout/__tests__/snapshots/circular/startAngle-PI-endAngle-2PI.svg new file mode 100644 index 00000000..b8229b15 --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/startAngle-PI-endAngle-2PI.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/startRadius-100-endRadius-200.svg b/packages/layout/__tests__/snapshots/circular/startRadius-100-endRadius-200.svg new file mode 100644 index 00000000..2170db05 --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/startRadius-100-endRadius-200.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/snapshots/circular/startRadius-only.svg b/packages/layout/__tests__/snapshots/circular/startRadius-only.svg new file mode 100644 index 00000000..1bb3e90f --- /dev/null +++ b/packages/layout/__tests__/snapshots/circular/startRadius-only.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/layout/__tests__/unit/circular.test.ts b/packages/layout/__tests__/unit/circular.test.ts index 7c97c628..0cd6546d 100644 --- a/packages/layout/__tests__/unit/circular.test.ts +++ b/packages/layout/__tests__/unit/circular.test.ts @@ -1,10 +1,243 @@ -import { createGraphCanvas } from '@@/utils/create'; -import { circular } from '../demos'; +import { CircularLayout } from '@/src'; +import { createCanvas } from '@@/utils/create'; +import type { Canvas } from '@antv/g'; +import { Graph } from '@antv/graphlib'; +import { countries } from '../dataset'; +import { renderNodes } from '../utils'; +import { renderNodesAndEdges } from '../utils/render'; describe('layout circular', () => { - it('basic', async () => { - const canvas = createGraphCanvas(); + let canvas: Canvas; + let graph: Graph; + let circular: CircularLayout; - await expect(circular(canvas)).toMatchSnapshot(__filename); + beforeEach(() => { + canvas = createCanvas(); + const { nodes, edges } = countries; + graph = new Graph({ nodes, edges }); + circular = new CircularLayout({ + center: [250, 250], + radius: 200, + }); + }); + + afterEach(() => { + canvas.destroy(); + }); + + it('should render with default config', async () => { + const positions = await circular.execute(graph); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename); + }); + + it('should render with custom radius', async () => { + const positions = await circular.execute(graph, { radius: 180 }); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename, 'radius-180'); + }); + + it('should render with startRadius and endRadius', async () => { + const positions = await circular.execute(graph, { + radius: 0, + startRadius: 100, + endRadius: 200, + }); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot( + __filename, + 'startRadius-100-endRadius-200', + ); + }); + + it('should render with custom angle range', async () => { + const positions = await circular.execute(graph, { + startAngle: Math.PI, + endAngle: Math.PI * 2, + }); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot( + __filename, + 'startAngle-PI-endAngle-2PI', + ); + }); + + it('should render counterclockwise with radius range', async () => { + const positions = await circular.execute(graph, { + clockwise: false, + radius: 0, + startRadius: 100, + endRadius: 200, + }); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot( + __filename, + 'counterclockwise-radius-range', + ); + }); + + it('should render with divisions', async () => { + const positions = await circular.execute(graph, { + divisions: 5, + radius: 200, + startAngle: Math.PI / 4, + endAngle: Math.PI, + }); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename, 'divisions-5'); + }); + + it('should render with custom center', async () => { + const positions = await circular.execute(graph, { + center: [300, 300], + }); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename, 'custom-center'); + }); + + it('should render with zero radius', async () => { + await circular.assign(graph, { + radius: 0, + }); + const allNodes = graph.getAllNodes(); + allNodes.forEach((node) => { + expect(typeof node.data.x).toBe('number'); + expect(typeof node.data.y).toBe('number'); + expect(Number.isFinite(node.data.x)).toBe(true); + expect(Number.isFinite(node.data.y)).toBe(true); + }); + }); + + it('returns empty positionsult for empty graph', async () => { + const graph = new Graph({ nodes: [], edges: [] }); + const layout = new CircularLayout({ center: [0, 0], radius: 100 }); + const positions = await layout.execute(graph, {} as any); + expect(positions.nodes).toHaveLength(0); + expect(positions.edges).toHaveLength(0); + }); + + it('assign places single node at center', async () => { + const graph = new Graph({ + nodes: [{ id: 'a', data: {} }], + edges: [] as any, + }); + const layout = new CircularLayout(); + await layout.assign(graph, { center: [10, 20] } as any); + const n = graph.getAllNodes()[0]; + expect((n.data as any).x).toBe(10); + expect((n.data as any).y).toBe(20); + }); + + it('degree ordering places highest degree at the end', async () => { + const nodes = [ + { id: 'A', data: {} }, + { id: 'B', data: {} }, + { id: 'C', data: {} }, + ]; + const edges = [ + { id: 'e1', source: 'A', target: 'B', data: {} }, + { id: 'e2', source: 'A', target: 'C', data: {} }, + ]; + const graph = new Graph({ nodes: nodes as any, edges: edges as any }); + const layout = new CircularLayout({ center: [0, 0], radius: 100 }); + const positions = await layout.execute(graph, { + ordering: 'degree', + } as any); + // A has degree 2, B and C have degree 1 -> A should be last (sorted ascending) + expect(positions.nodes[positions.nodes.length - 1].id).toBe('A'); + }); + + it('should layout according to the topology', async () => { + const positions = await circular.execute(graph, { + ordering: 'topology', + }); + await renderNodesAndEdges(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename, 'ordering-topology'); + }); + + it('should layout according to the topology-directed', async () => { + const positions = await circular.execute(graph, { + ordering: 'topology-directed', + }); + await renderNodesAndEdges(canvas, positions); + await expect(canvas).toMatchSnapshot( + __filename, + 'ordering-topology-directed', + ); + }); + + it('should layout according to the original order', async () => { + const positions = await circular.execute(graph); + await renderNodesAndEdges(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename, 'ordering-original'); + }); + + it('should layout according to degree', async () => { + const positions = await circular.execute(graph, { + ordering: 'degree', + }); + await renderNodesAndEdges(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename, 'ordering-degree'); + }); + + it('nodeSpacing / nodeSize branch computes positions', async () => { + const positions = await circular.execute(graph, { + nodeSpacing: () => 5, + nodeSize: () => 20, + } as any); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename, 'nodeSize-nodeSpacing-fn'); + }); + + it('startRadius only (without endRadius) should set endRadius equal to startRadius', async () => { + const positions = await circular.execute(graph, { + startRadius: 150, + endRadius: undefined, + }); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename, 'startRadius-only'); + }); + + it('endRadius only (without startRadius) should set startRadius equal to endRadius', async () => { + const positions = await circular.execute(graph, { + startRadius: undefined, + endRadius: 150, + }); + await renderNodes(canvas, positions); + await expect(canvas).toMatchSnapshot(__filename, 'endRadius-only'); + }); + + it('assign mode should directly modify graph node positions', async () => { + const nodes = [ + { id: 'a', data: {} }, + { id: 'b', data: {} }, + { id: 'c', data: {} }, + ]; + const graph = new Graph({ nodes: nodes as any, edges: [] as any }); + const layout = new CircularLayout({ center: [100, 100], radius: 50 }); + await layout.assign(graph, {}); + const allNodes = graph.getAllNodes(); + allNodes.forEach((node) => { + expect(typeof node.data.x).toBe('number'); + expect(typeof node.data.y).toBe('number'); + expect(Number.isFinite(node.data.x)).toBe(true); + expect(Number.isFinite(node.data.y)).toBe(true); + }); + }); + + it('should calculate center when not provided', async () => { + const nodes = [ + { id: 'a', data: {} }, + { id: 'b', data: {} }, + ]; + const graph = new Graph({ nodes: nodes as any, edges: [] as any }); + const layout = new CircularLayout({ radius: 50 }); + // Don't provide center, it should be calculated + const positions = await layout.execute(graph, {}); + expect(positions.nodes).toHaveLength(2); + positions.nodes.forEach((n) => { + expect(Number.isFinite(n.data.x)).toBe(true); + expect(Number.isFinite(n.data.y)).toBe(true); + }); }); }); diff --git a/packages/layout/__tests__/unit/util/array.test.ts b/packages/layout/__tests__/unit/util/array.test.ts new file mode 100644 index 00000000..ea016d5a --- /dev/null +++ b/packages/layout/__tests__/unit/util/array.test.ts @@ -0,0 +1,28 @@ +import { isArray } from '@/src/util/array'; + +describe('array', () => { + describe('isArray', () => { + test('should return true for arrays', () => { + expect(isArray([])).toBe(true); + expect(isArray([1, 2, 3])).toBe(true); + expect(isArray(['a', 'b', 'c'])).toBe(true); + expect(isArray([{ id: 1 }])).toBe(true); + expect(isArray(new Array(5))).toBe(true); + }); + + test('should return false for non-arrays', () => { + expect(isArray(null)).toBe(false); + expect(isArray(undefined)).toBe(false); + expect(isArray({})).toBe(false); + expect(isArray('string')).toBe(false); + expect(isArray(123)).toBe(false); + expect(isArray(true)).toBe(false); + expect(isArray(() => {})).toBe(false); + }); + + test('should return false for array-like objects', () => { + expect(isArray({ length: 0 })).toBe(false); + expect(isArray({ 0: 'a', 1: 'b', length: 2 })).toBe(false); + }); + }); +}); diff --git a/packages/layout/__tests__/unit/util/common.test.ts b/packages/layout/__tests__/unit/util/common.test.ts new file mode 100644 index 00000000..14045fc5 --- /dev/null +++ b/packages/layout/__tests__/unit/util/common.test.ts @@ -0,0 +1,111 @@ +import { handleSingleNodeGraph } from '@/src/util'; +import { Graph } from '@antv/graphlib'; + +describe('handleSingleNodeGraph', () => { + test('should handle empty graph', () => { + const graph = new Graph({ + nodes: [], + edges: [], + }); + + const result = handleSingleNodeGraph(graph, false, [100, 200]); + + expect(result).toEqual({ + nodes: [], + edges: [], + }); + }); + + test('should handle single node graph without assign', () => { + const graph = new Graph({ + nodes: [{ id: 'node1', data: {} }], + edges: [], + }); + + const result = handleSingleNodeGraph(graph, false, [100, 200]); + + expect(result?.nodes.length).toBe(1); + expect(result?.nodes[0].id).toBe('node1'); + expect(result?.nodes[0].data.x).toBe(100); + expect(result?.nodes[0].data.y).toBe(200); + expect(result?.edges).toEqual([]); + + // Original graph should not be modified + const originalNode = graph.getNode('node1'); + expect(originalNode?.data.x).toBeUndefined(); + expect(originalNode?.data.y).toBeUndefined(); + }); + + test('should handle single node graph with assign', () => { + const graph = new Graph({ + nodes: [{ id: 'node1', data: {} }], + edges: [], + }); + + const result = handleSingleNodeGraph(graph, true, [100, 200]); + + expect(result?.nodes.length).toBe(1); + expect(result?.nodes[0].id).toBe('node1'); + expect(result?.nodes[0].data.x).toBe(100); + expect(result?.nodes[0].data.y).toBe(200); + + // Original graph should be modified + const originalNode = graph.getNode('node1'); + expect(originalNode?.data.x).toBe(100); + expect(originalNode?.data.y).toBe(200); + }); + + test('should handle single node graph with existing data', () => { + const graph = new Graph({ + nodes: [{ id: 'node1', data: { color: 'red', size: 10 } }], + edges: [], + }); + + const result = handleSingleNodeGraph(graph, false, [150, 250]); + + expect(result?.nodes[0].data).toEqual({ + color: 'red', + size: 10, + x: 150, + y: 250, + }); + }); + + test('should return undefined for multi-node graph', () => { + const graph = new Graph({ + nodes: [ + { id: 'node1', data: {} }, + { id: 'node2', data: {} }, + ], + edges: [], + }); + + const result = handleSingleNodeGraph(graph, false, [100, 200]); + + expect(result).toBeUndefined(); + }); + + test('should handle different center coordinates', () => { + const graph = new Graph({ + nodes: [{ id: 'node1', data: {} }], + edges: [], + }); + + const result = handleSingleNodeGraph(graph, false, [0, 0]); + + expect(result?.nodes[0].data.x).toBe(0); + expect(result?.nodes[0].data.y).toBe(0); + }); + + test('should handle negative center coordinates', () => { + const graph = new Graph({ + nodes: [{ id: 'node1', data: {} }], + edges: [], + }); + + const result = handleSingleNodeGraph(graph, false, [-100, -200]); + + expect(result?.nodes[0].data.x).toBe(-100); + expect(result?.nodes[0].data.y).toBe(-200); + }); +}); diff --git a/packages/layout/__tests__/unit/util/function.test.ts b/packages/layout/__tests__/unit/util/function.test.ts new file mode 100644 index 00000000..b182323a --- /dev/null +++ b/packages/layout/__tests__/unit/util/function.test.ts @@ -0,0 +1,221 @@ +import { Node } from '@/src/types'; +import { + formatNodeSizeToNumber, + formatNumberFn, + formatSizeFn, +} from '@/src/util/function'; + +describe('function', () => { + describe('formatNumberFn', () => { + test('should return function that returns default value when value is undefined', () => { + const result = formatNumberFn(10, undefined); + expect(result()).toBe(10); + }); + + test('should return function that returns number when value is number', () => { + const result = formatNumberFn(10, 20); + expect(result()).toBe(20); + expect(result({ id: 'test' })).toBe(20); + }); + + test('should return the function when value is function', () => { + const customFn = (d?: any) => d.value * 2; + const result = formatNumberFn(10, customFn); + expect(result({ value: 5 })).toBe(10); + }); + + test('should return default value for invalid types', () => { + const result = formatNumberFn(10, 'invalid' as any); + expect(result()).toBe(10); + }); + + test('should handle zero as valid number', () => { + const result = formatNumberFn(10, 0); + expect(result()).toBe(0); + }); + + test('should handle negative numbers', () => { + const result = formatNumberFn(10, -5); + expect(result()).toBe(-5); + }); + }); + + describe('formatSizeFn', () => { + const createNode = (data: any = {}): Node => ({ + id: 'node1', + data, + }); + + test('should use default value when value is undefined', () => { + const result = formatSizeFn(10, undefined); + const node = createNode(); + expect(result(node)).toBe(10); + }); + + test('should extract size from node data', () => { + const result = formatSizeFn(10, undefined); + const node = createNode({ size: 20 }); + expect(result(node)).toBe(20); + }); + + test('should extract size from node data as array', () => { + const result = formatSizeFn(10, undefined); + const node = createNode({ size: [30, 40] }); + expect(result(node)).toBe(40); // max of array + }); + + test('should extract size from node data as object', () => { + const result = formatSizeFn(10, undefined); + const node = createNode({ size: { width: 25, height: 35 } }); + expect(result(node)).toBe(35); // max of width and height + }); + + test('should return array when resultIsNumber is false', () => { + const result = formatSizeFn(10, undefined, false); + const node = createNode({ size: [30, 40] }); + expect(result(node)).toEqual([30, 40]); + }); + + test('should return size array from object when resultIsNumber is false', () => { + const result = formatSizeFn(10, undefined, false); + const node = createNode({ size: { width: 25, height: 35 } }); + expect(result(node)).toEqual([25, 35]); + }); + + test('should return number when value is number', () => { + const result = formatSizeFn(10, 15); + const node = createNode(); + expect(result(node)).toBe(15); + }); + + test('should return function result when value is function', () => { + const customFn = (d: Node) => d.data.customSize || 50; + const result = formatSizeFn(10, customFn); + const node = createNode({ customSize: 60 }); + expect(result(node)).toBe(60); + }); + + test('should handle array value', () => { + const result = formatSizeFn(10, [20, 30]); + const node = createNode(); + expect(result(node)).toBe(30); // max of array + }); + + test('should handle array value when resultIsNumber is false', () => { + const result = formatSizeFn(10, [20, 30], false); + const node = createNode(); + expect(result(node)).toEqual([20, 30]); + }); + + test('should handle object value', () => { + const result = formatSizeFn(10, { width: 40, height: 50 }); + const node = createNode(); + expect(result(node)).toBe(50); + }); + + test('should handle object value when resultIsNumber is false', () => { + const result = formatSizeFn(10, { width: 40, height: 50 }, false); + const node = createNode(); + expect(result(node)).toEqual([40, 50]); + }); + + test('should handle zero as valid size', () => { + const result = formatSizeFn(10, 0); + const node = createNode(); + expect(result(node)).toBe(0); + }); + + test('should return default value for invalid types', () => { + const result = formatSizeFn(10, 'invalid' as any); + const node = createNode(); + expect(result(node)).toBe(10); + }); + }); + + describe('formatNodeSizeToNumber', () => { + const createNode = (data: any = {}): Node => ({ + id: 'node1', + data, + }); + + test('should use default node size when nodeSize is undefined', () => { + const result = formatNodeSizeToNumber(undefined, undefined, 10); + const node = createNode(); + expect(result(node)).toBe(10); + }); + + test('should extract size from node data', () => { + const result = formatNodeSizeToNumber(undefined, undefined, 10); + const node = createNode({ size: 20 }); + expect(result(node)).toBe(20); + }); + + test('should extract size from node data as array', () => { + const result = formatNodeSizeToNumber(undefined, undefined, 10); + const node = createNode({ size: [30, 40] }); + expect(result(node)).toBe(40); // max of array + }); + + test('should extract size from node data as object', () => { + const result = formatNodeSizeToNumber(undefined, undefined, 10); + const node = createNode({ size: { width: 25, height: 35 } }); + expect(result(node)).toBe(35); + }); + + test('should use bboxSize when available', () => { + const result = formatNodeSizeToNumber(undefined, undefined, 10); + const node = createNode({ bboxSize: [50, 60], size: [30, 40] }); + expect(result(node)).toBe(60); // max of bboxSize + }); + + test('should use provided nodeSize as number', () => { + const result = formatNodeSizeToNumber(15, undefined, 10); + const node = createNode(); + expect(result(node)).toBe(15); + }); + + test('should use provided nodeSize as array', () => { + const result = formatNodeSizeToNumber([20, 30], undefined, 10); + const node = createNode(); + expect(result(node)).toBe(30); + }); + + test('should use provided nodeSize as function', () => { + const customFn = (d: Node) => d.data.customSize || [25, 35]; + const result = formatNodeSizeToNumber(customFn, undefined, 10); + const node = createNode({ customSize: [40, 50] }); + expect(result(node)).toBe(50); + }); + + test('should add nodeSpacing as number', () => { + const result = formatNodeSizeToNumber(20, 5, 10); + const node = createNode(); + expect(result(node)).toBe(25); // 20 + 5 + }); + + test('should add nodeSpacing as function', () => { + const spacingFn = (d: Node) => d.data.spacing || 0; + const result = formatNodeSizeToNumber(20, spacingFn, 10); + const node = createNode({ spacing: 10 }); + expect(result(node)).toBe(30); // 20 + 10 + }); + + test('should handle zero spacing', () => { + const result = formatNodeSizeToNumber(20, 0, 10); + const node = createNode(); + expect(result(node)).toBe(20); + }); + + test('should handle undefined spacing', () => { + const result = formatNodeSizeToNumber(20, undefined, 10); + const node = createNode(); + expect(result(node)).toBe(20); + }); + + test('should combine node size from data and spacing', () => { + const result = formatNodeSizeToNumber(undefined, 5, 10); + const node = createNode({ size: [30, 40] }); + expect(result(node)).toBe(45); // max(30, 40) + 5 + }); + }); +}); diff --git a/packages/layout/__tests__/unit/util/object.test.ts b/packages/layout/__tests__/unit/util/object.test.ts new file mode 100644 index 00000000..7f927109 --- /dev/null +++ b/packages/layout/__tests__/unit/util/object.test.ts @@ -0,0 +1,215 @@ +import { clone, cloneFormatData } from '@/src/util/object'; +import { Node, Edge } from '@/src/types'; + +describe('object', () => { + describe('clone', () => { + test('should clone primitive values', () => { + expect(clone(null)).toBeNull(); + expect(clone(undefined)).toBeUndefined(); + expect(clone(123)).toBe(123); + expect(clone('string')).toBe('string'); + expect(clone(true)).toBe(true); + }); + + test('should clone Date objects', () => { + const date = new Date('2024-01-01'); + const cloned = clone(date); + expect(cloned).toEqual(date); + expect(cloned).not.toBe(date); + expect(cloned.getTime()).toBe(date.getTime()); + }); + + test('should clone simple arrays', () => { + const arr = [1, 2, 3]; + const cloned = clone(arr); + expect(cloned).toEqual(arr); + expect(cloned).not.toBe(arr); + }); + + test('should deep clone nested arrays', () => { + const arr = [1, [2, 3], [4, [5, 6]]]; + const cloned = clone(arr); + expect(cloned).toEqual(arr); + expect(cloned).not.toBe(arr); + expect(cloned[1]).not.toBe(arr[1]); + expect(cloned[2]).not.toBe(arr[2]); + }); + + test('should clone simple objects', () => { + const obj = { a: 1, b: 'test', c: true }; + const cloned = clone(obj); + expect(cloned).toEqual(obj); + expect(cloned).not.toBe(obj); + }); + + test('should deep clone nested objects', () => { + const obj = { + a: 1, + b: { c: 2, d: { e: 3 } }, + f: [1, 2, 3], + }; + const cloned = clone(obj); + expect(cloned).toEqual(obj); + expect(cloned).not.toBe(obj); + expect(cloned.b).not.toBe(obj.b); + expect(cloned.b.d).not.toBe(obj.b.d); + expect(cloned.f).not.toBe(obj.f); + }); + + test('should clone objects with Date properties', () => { + const obj = { + date: new Date('2024-01-01'), + nested: { date: new Date('2024-12-31') }, + }; + const cloned = clone(obj); + expect(cloned).toEqual(obj); + expect(cloned.date).not.toBe(obj.date); + expect(cloned.nested.date).not.toBe(obj.nested.date); + }); + + test('should clone complex mixed structures', () => { + const complex = { + arr: [1, { a: 2 }, [3, 4]], + obj: { x: 5, y: { z: 6 } }, + date: new Date('2024-01-01'), + primitive: 'test', + }; + const cloned = clone(complex); + expect(cloned).toEqual(complex); + expect(cloned).not.toBe(complex); + expect(cloned.arr).not.toBe(complex.arr); + expect(cloned.obj).not.toBe(complex.obj); + expect(cloned.date).not.toBe(complex.date); + }); + + test('should handle empty objects and arrays', () => { + expect(clone({})).toEqual({}); + expect(clone([])).toEqual([]); + }); + }); + + describe('cloneFormatData', () => { + test('should clone node without initRange', () => { + const node: Node = { + id: 'node1', + data: { x: 10, y: 20, size: 30 }, + }; + const cloned = cloneFormatData(node); + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + expect(cloned.data).not.toBe(node.data); + }); + + test('should clone edge without initRange', () => { + const edge: Edge = { + id: 'edge1', + source: 'a', + target: 'b', + data: { weight: 1 }, + }; + const cloned = cloneFormatData(edge); + expect(cloned).toEqual(edge); + expect(cloned).not.toBe(edge); + }); + + test('should initialize data property if not exists', () => { + const node: Node = { id: 'node1' } as any; + const cloned = cloneFormatData(node); + expect(cloned.data).toBeDefined(); + expect(cloned.data).toEqual({}); + }); + + test('should not modify x and y without initRange', () => { + const node: Node = { + id: 'node1', + data: { x: 10, y: 20 }, + }; + const cloned = cloneFormatData(node); + expect(cloned.data.x).toBe(10); + expect(cloned.data.y).toBe(20); + }); + + test('should initialize x with random value when x is undefined and initRange provided', () => { + const node: Node = { + id: 'node1', + data: { y: 20 }, + }; + const cloned = cloneFormatData(node, [100, 200]); + expect(cloned.data.x).toBeDefined(); + expect(cloned.data.x).toBeGreaterThanOrEqual(0); + expect(cloned.data.x).toBeLessThanOrEqual(100); + expect(cloned.data.y).toBe(20); + }); + + test('should initialize y with random value when y is undefined and initRange provided', () => { + const node: Node = { + id: 'node1', + data: { x: 10 }, + }; + const cloned = cloneFormatData(node, [100, 200]); + expect(cloned.data.x).toBe(10); + expect(cloned.data.y).toBeDefined(); + expect(cloned.data.y).toBeGreaterThanOrEqual(0); + expect(cloned.data.y).toBeLessThanOrEqual(200); + }); + + test('should initialize both x and y when both undefined and initRange provided', () => { + const node: Node = { + id: 'node1', + data: { size: 30 }, + }; + const cloned = cloneFormatData(node, [100, 200]); + expect(cloned.data.x).toBeDefined(); + expect(cloned.data.x).toBeGreaterThanOrEqual(0); + expect(cloned.data.x).toBeLessThanOrEqual(100); + expect(cloned.data.y).toBeDefined(); + expect(cloned.data.y).toBeGreaterThanOrEqual(0); + expect(cloned.data.y).toBeLessThanOrEqual(200); + expect(cloned.data.size).toBe(30); + }); + + test('should not override existing x and y values even with initRange', () => { + const node: Node = { + id: 'node1', + data: { x: 10, y: 20 }, + }; + const cloned = cloneFormatData(node, [100, 200]); + expect(cloned.data.x).toBe(10); + expect(cloned.data.y).toBe(20); + }); + + test('should preserve other data properties', () => { + const node: Node = { + id: 'node1', + data: { size: 30, color: 'red', label: 'Node 1' }, + }; + const cloned = cloneFormatData(node, [100, 200]); + expect(cloned.data.size).toBe(30); + expect(cloned.data.color).toBe('red'); + expect(cloned.data.label).toBe('Node 1'); + }); + + test('should handle edge with initRange', () => { + const edge: Edge = { + id: 'edge1', + source: 'a', + target: 'b', + data: { weight: 1 }, + }; + const cloned = cloneFormatData(edge, [100, 200]); + expect(cloned.data.x).toBeDefined(); + expect(cloned.data.y).toBeDefined(); + expect(cloned.data.weight).toBe(1); + }); + + test('should treat 0 as valid coordinate', () => { + const node: Node = { + id: 'node1', + data: { x: 0, y: 0 }, + }; + const cloned = cloneFormatData(node, [100, 200]); + expect(cloned.data.x).toBe(0); + expect(cloned.data.y).toBe(0); + }); + }); +}); diff --git a/packages/layout/__tests__/unit/util/size.test.ts b/packages/layout/__tests__/unit/util/size.test.ts index 1a69b7de..c49a5159 100644 --- a/packages/layout/__tests__/unit/util/size.test.ts +++ b/packages/layout/__tests__/unit/util/size.test.ts @@ -4,6 +4,7 @@ describe('size', () => { it('parseSize', () => { expect(parseSize()).toEqual([0, 0, 0]); expect(parseSize(1)).toEqual([1, 1, 1]); + expect(parseSize([])).toEqual([0, 0, 0]); expect(parseSize([1])).toEqual([1, 1, 1]); expect(parseSize([1, 2])).toEqual([1, 2, 1]); expect(parseSize([1, 2, 3])).toEqual([1, 2, 3]); diff --git a/packages/layout/__tests__/utils/create.ts b/packages/layout/__tests__/utils/create.ts index 02837189..769ca7e9 100644 --- a/packages/layout/__tests__/utils/create.ts +++ b/packages/layout/__tests__/utils/create.ts @@ -3,14 +3,14 @@ import { Renderer as SVGRenderer } from '@antv/g-svg'; import { OffscreenCanvasContext } from './offscreen-canvas-context'; /** - * Create graph canvas with config. + * Create canvas with config. * @param dom - dom * @param width - width * @param height - height * @param options - options * @returns instance */ -export function createGraphCanvas( +export function createCanvas( dom?: null | HTMLElement, width: number = 500, height: number = 500, diff --git a/packages/layout/__tests__/utils/index.ts b/packages/layout/__tests__/utils/index.ts index e4557bba..7069e022 100644 --- a/packages/layout/__tests__/utils/index.ts +++ b/packages/layout/__tests__/utils/index.ts @@ -1,3 +1,3 @@ -export { createGraphCanvas } from './create'; +export { createCanvas } from './create'; export { renderNodes } from './render'; export { sleep } from './sleep'; diff --git a/packages/layout/__tests__/utils/render.ts b/packages/layout/__tests__/utils/render.ts index 0e38051b..43ebc830 100644 --- a/packages/layout/__tests__/utils/render.ts +++ b/packages/layout/__tests__/utils/render.ts @@ -1,20 +1,78 @@ import type { LayoutMapping } from '@/src/types'; -import { Canvas, Circle } from '@antv/g'; +import { Canvas, Circle, Line, Text } from '@antv/g'; -export async function renderNodes(canvas: Canvas, positions: LayoutMapping) { +export async function renderNodesAndEdges( + canvas: Canvas, + positions: LayoutMapping, + showLabel: boolean = false, +) { await canvas.ready; canvas.removeChildren(); + + displayEdges(canvas, positions); + displayNodes(canvas, positions, showLabel); +} + +export async function renderNodes( + canvas: Canvas, + positions: LayoutMapping, + showLabel: boolean = false, +) { + await canvas.ready; + canvas.removeChildren(); + + displayNodes(canvas, positions, showLabel); +} + +const displayNodes = async ( + canvas: Canvas, + positions: LayoutMapping, + showLabel: boolean = false, +) => { positions.nodes.forEach((node) => { const circle = new Circle({ style: { cx: node.data.x, cy: node.data.y, r: 10, - fill: '#1890FF', - stroke: '#F04864', - lineWidth: 4, + fill: 'rgb(207,226,252)', + stroke: 'rgb(118,145,241)', + lineWidth: 2, }, }); canvas.appendChild(circle); + + if (showLabel) { + const text = new Text({ + style: { + x: node.data.x, + y: node.data.y, + text: node.data.name || node.id, + fontSize: 12, + textAlign: 'center', + textBaseline: 'middle', + }, + }); + canvas.appendChild(text); + } }); -} +}; + +const displayEdges = async (canvas: Canvas, positions: LayoutMapping) => { + positions.edges.forEach(({ source, target, data }) => { + const sourceNode = positions.nodes.find(({ id }) => id === source); + const targetNode = positions.nodes.find(({ id }) => id === target); + + const line = new Line({ + style: { + x1: sourceNode.data.x, + y1: sourceNode.data.y, + x2: targetNode.data.x, + y2: targetNode.data.y, + lineWidth: 1, + stroke: 'grey', + }, + }); + canvas.appendChild(line); + }); +}; diff --git a/packages/layout/jest.config.js b/packages/layout/jest.config.js index b16f8ed5..3760c5e5 100644 --- a/packages/layout/jest.config.js +++ b/packages/layout/jest.config.js @@ -23,7 +23,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts'], coveragePathIgnorePatterns: [], moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], - collectCoverage: false, + collectCoverage: true, testRegex: '(/__tests__/.*\\.(test|spec))\\.(ts|tsx|js)$', transformIgnorePatterns: [`/node_modules/.pnpm/(?!(${esm}))`], testPathIgnorePatterns: ['/(lib|esm)/__tests__/'], diff --git a/packages/layout/package.json b/packages/layout/package.json index 842ba141..c2a45f8f 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -62,15 +62,17 @@ "xmlserializer": "^0.6.1" }, "scripts": { - "clean": "rimraf dist lib tsconfig.build.tsbuildinfo", - "dev": "vite", - "build": "npm run clean && run-p build:*", - "ci": "npm run build && npm run test", "build:esm": "tsc -p tsconfig.build.json", "build:umd": "webpack --config webpack.config.js --mode production", + "build": "npm run clean && run-p build:*", + "ci": "npm run build && npm run test", + "clean": "rimraf dist lib tsconfig.build.tsbuildinfo", + "coverage:open": "open coverage/lcov-report/index.html", + "coverage": "jest --coverage", + "dev": "vite", + "prepublishOnly": "npm run build", "publish:alpha": "npm publish --tag alpha", - "test": "jest", - "prepublishOnly": "npm run build" + "test": "jest" }, "publishConfig": { "access": "public", diff --git a/packages/layout/src/util/index.ts b/packages/layout/src/util/index.ts index f697b75f..e1eb2d48 100644 --- a/packages/layout/src/util/index.ts +++ b/packages/layout/src/util/index.ts @@ -1,4 +1,5 @@ -export * from "./array"; -export * from "./math"; -export * from "./object"; -export * from "./function"; +export * from './array'; +export * from './common'; +export * from './function'; +export * from './math'; +export * from './object'; From 035fb03a45b05b3e0eaa91d3cda997c7fecb7558 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Tue, 11 Nov 2025 14:59:19 +0800 Subject: [PATCH 2/6] feat: add Jest coverage report workflow --- .github/workflows/coverage.yml | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/coverage.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..be19b609 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,45 @@ +name: Layout Package Coverage + +on: + pull_request: + paths: + - 'packages/layout/**' # 仅 layout 包的改动触发 + - '.github/workflows/layout-coverage.yml' + +jobs: + coverage: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - uses: pnpm/action-setup@v2 + name: Install pnpm + with: + version: 9 + run_install: false + + - name: Install Dependencies + run: pnpm install --no-frozen-lockfile + + # 👇 只运行 packages/layout 的测试并生成 coverage + - name: Run layout tests with coverage + working-directory: packages/layout + run: | + npx jest --coverage \ + --coverageDirectory=../../coverage/layout \ + --coverageReporters="json-summary" \ + --coverageReporters="text-summary" + + # 👇 用社区 Action 输出覆盖率评论 + - name: Comment Jest coverage report + uses: ArtiomTr/jest-coverage-report-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + summary-file: coverage/layout/coverage-summary.json + title: 🧪 `packages/layout` Coverage + skip-step: false From 7d646cf5c6b0c6c9a82deb61db8e08313e5bb787 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Tue, 11 Nov 2025 15:01:11 +0800 Subject: [PATCH 3/6] fix: simplify Jest coverage command in workflow --- .github/workflows/coverage.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index be19b609..df852888 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -29,11 +29,7 @@ jobs: # 👇 只运行 packages/layout 的测试并生成 coverage - name: Run layout tests with coverage working-directory: packages/layout - run: | - npx jest --coverage \ - --coverageDirectory=../../coverage/layout \ - --coverageReporters="json-summary" \ - --coverageReporters="text-summary" + run: npx jest --coverage # 👇 用社区 Action 输出覆盖率评论 - name: Comment Jest coverage report From eaea96f9f984dd3c01ee1abcde01c679d8a967a3 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Tue, 11 Nov 2025 15:04:06 +0800 Subject: [PATCH 4/6] chore: remove layout package coverage workflow --- .github/workflows/coverage.yml | 41 ---------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 .github/workflows/coverage.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index df852888..00000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Layout Package Coverage - -on: - pull_request: - paths: - - 'packages/layout/**' # 仅 layout 包的改动触发 - - '.github/workflows/layout-coverage.yml' - -jobs: - coverage: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: 20 - - - uses: pnpm/action-setup@v2 - name: Install pnpm - with: - version: 9 - run_install: false - - - name: Install Dependencies - run: pnpm install --no-frozen-lockfile - - # 👇 只运行 packages/layout 的测试并生成 coverage - - name: Run layout tests with coverage - working-directory: packages/layout - run: npx jest --coverage - - # 👇 用社区 Action 输出覆盖率评论 - - name: Comment Jest coverage report - uses: ArtiomTr/jest-coverage-report-action@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - summary-file: coverage/layout/coverage-summary.json - title: 🧪 `packages/layout` Coverage - skip-step: false From c0085a7552c0e2d0720370cfc6f63f687e50ab89 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Tue, 11 Nov 2025 15:15:50 +0800 Subject: [PATCH 5/6] feat: add Codecov coverage report upload step in build workflow --- .github/workflows/build.yml | 8 +++++++- packages/layout/package.json | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 720aded6..73259f95 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,13 @@ jobs: - name: build run: | pnpm run ci - + - name: test run: | pnpm test + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: antvis/layout diff --git a/packages/layout/package.json b/packages/layout/package.json index c2a45f8f..a0b1274a 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -67,8 +67,8 @@ "build": "npm run clean && run-p build:*", "ci": "npm run build && npm run test", "clean": "rimraf dist lib tsconfig.build.tsbuildinfo", - "coverage:open": "open coverage/lcov-report/index.html", - "coverage": "jest --coverage", + "test:coverage:open": "open coverage/lcov-report/index.html", + "test:coverage": "jest --coverage", "dev": "vite", "prepublishOnly": "npm run build", "publish:alpha": "npm publish --tag alpha", From ddf6e81a0b1883703454f77581aeae53af13f028 Mon Sep 17 00:00:00 2001 From: Yuxin <55794321+yvonneyx@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:16:27 +0800 Subject: [PATCH 6/6] Update docs/api/circular.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/api/circular.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/circular.md b/docs/api/circular.md index 02d191be..ba3bdfad 100644 --- a/docs/api/circular.md +++ b/docs/api/circular.md @@ -1,6 +1,6 @@ # Circular -Circular layout arranges the node on a circle. By tuning the configurations, user can adjust the node ordering method, division number, radial layout, and so on. We implements it according to the paper: [A framework and algorithms for circular drawings of graphs](https://www.sciencedirect.com/science/article/pii/S1570866705000031). +Circular layout arranges nodes on a circle. By tuning the configurations, user can adjust the node ordering method, division number, radial layout, and so on. We implement it according to the paper: [A framework and algorithms for circular drawings of graphs](https://www.sciencedirect.com/science/article/pii/S1570866705000031).