Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion __tests__/demos/combo-combined.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ export function render(gui?: GUI) {
});

layout.forEachNode((node) => {
console.log('node:', node);
renderer.updateNodeAttributes(node.id, {
cx: node.x,
cy: node.y,
Expand Down
1 change: 0 additions & 1 deletion __tests__/demos/combo-combined2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ export function render(gui?: GUI) {
});

layout.forEachNode((node: any) => {
console.log('node:', node);
renderer.updateNodeAttributes(node.id, {
// cx: node.x,
// cy: node.y,
Expand Down
5 changes: 3 additions & 2 deletions __tests__/demos/fruchterman.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ export async function render(gui?: GUI) {
const clusterOptions = {
...options,
clustering: true,
nodeClusterBy: (node: any) => node.cluster,
// nodeClusterBy: (node: any) => node.cluster,
nodeClusterBy: 'node.cluster',
};

console.time('fruchterman layout');
await layout.execute(processedData, options);
await layout.execute(processedData, clusterOptions);
console.timeEnd('fruchterman layout');

if (gui) {
Expand Down
4 changes: 2 additions & 2 deletions __tests__/unit/circular.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { CircularLayout } from '@/src';
import { createCanvas } from '@@/utils/create';
import type { Canvas } from '@antv/g';
import { countries as data } from '../dataset';
import { GraphRenderer } from '../utils';
import { calculatePositions } from '../utils';
import { calculatePositions, GraphRenderer } from '../utils';

describe('layout circular', () => {
let canvas: Canvas;
Expand Down Expand Up @@ -43,6 +42,7 @@ describe('layout circular', () => {
ordering: null,
angleRatio: 1,
nodeSize: 10,
nodeSpacing: 0,
});
});

Expand Down
43 changes: 43 additions & 0 deletions __tests__/unit/force.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,49 @@ describe('layout force', () => {
expect(tickCount).toBeGreaterThanOrEqual(1);
});

it('should support Expr for accessor callbacks', async () => {
const graph = {
nodes: [
{ id: 'a', data: { cluster: 'c1', mass: 2, ns: 123, cs: 50 } },
{ id: 'b', data: { cluster: 'c2', mass: 3, ns: 456, cs: 60 } },
],
edges: [
{
id: 'e1',
source: 'a',
target: 'b',
data: { len: 77, es: 0.5 },
},
],
};

const layout = new ForceLayout({
width,
height,
maxIteration: 1,
minMovement: 0,
clustering: true,
nodeClusterBy: 'node.data.cluster',
getMass: 'node.data.mass',
nodeStrength: 'node.data.ns',
edgeStrength: 'edge.data.es',
linkDistance: 'edge.data.len',
clusterNodeStrength: 'node.data.cs',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个是 @antv/expr 支持的还是 layout 自己实现的

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

antv/expr 支持

getCenter: '[0, 0, 10]',
});

await layout.execute(graph);

layout.forEachNode((node: any) => {
expect(node.mass).toBe(node._original.data.mass);
expect(node.nodeStrength).toBe(node._original.data.ns);
});
layout.forEachEdge((edge: any) => {
expect(edge.edgeStrength).toBe(edge._original.data.es);
expect(edge.linkDistance).toBe(edge._original.data.len);
});
});

it('should handle overlapped nodes', async () => {
const overlapGraph = {
nodes: [
Expand Down
2 changes: 1 addition & 1 deletion __tests__/unit/fruchterman.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('FruchtermanLayout', () => {
clusterGravity: 10,
width: 300,
height: 300,
nodeClusterBy: 'data.cluster',
nodeClusterBy: 'node.cluster',
dimensions: 2,
});
});
Expand Down
22 changes: 22 additions & 0 deletions __tests__/unit/util/expr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { evaluateExpression } from '@/src/util/expr';

describe('expr', () => {
describe('evaluateExpression', () => {
test('evaluateExpression returns result for valid expression', () => {
expect(evaluateExpression('x + y', { x: 10, y: 20 })).toBe(30);
});

test('evaluateExpression supports dot notation and array access', () => {
const data = { values: [1, 2, 3], status: 'active' };
expect(
evaluateExpression('data.values[0] + data.values[1]', { data }),
).toBe(3);
});

test('evaluateExpression returns undefined for non-string/empty/invalid expression', () => {
expect(evaluateExpression(123, { x: 1 })).toBeUndefined();
expect(evaluateExpression(' ', { x: 1 })).toBeUndefined();
expect(evaluateExpression('x +', { x: 1 })).toBeUndefined();
});
});
});
52 changes: 25 additions & 27 deletions __tests__/unit/util/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,6 @@ describe('format', () => {
expect(result()).toEqual([20, 30]);
});

test('should handle object value with width and height', () => {
const result = formatSizeFn<NodeData>({ width: 40, height: 50 }, 10);
expect(result()).toEqual([40, 50]);
});

test('should handle object value when resultIsNumber is false', () => {
const result = formatSizeFn<NodeData>({ width: 40, height: 50 }, 10);
expect(result()).toEqual([40, 50]);
});

test('should return default value when value is undefined and no node provided', () => {
const result = formatSizeFn<NodeData>(undefined, 10);
expect(result()).toBe(10);
Expand All @@ -111,7 +101,7 @@ describe('format', () => {

test('should handle number zero as valid size', () => {
const result = formatSizeFn<NodeData>(0, 10);
expect(result()).toBe(10); // 0 is falsy, falls back to default
expect(result()).toBe(0);
});

test('should return default value when data has no size', () => {
Expand Down Expand Up @@ -157,41 +147,45 @@ describe('format', () => {
describe('formatNodeSizeFn', () => {
test('should return node size plus spacing', () => {
const result = formatNodeSizeFn(20, 5, 10);
expect(result()).toBe(25);
expect(result()).toEqual([25, 25, 25]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这三个值分别是啥意思

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

【长、宽、高】

});

test('should use default node size when nodeSize is undefined', () => {
const result = formatNodeSizeFn(undefined, 5, 15);
expect(result()).toBe(20); // 15 + 5
expect(result()).toEqual([20, 20, 20]); // 15 + 5
});

test('should handle zero spacing', () => {
const result = formatNodeSizeFn(20, 0, 10);
expect(result()).toBe(20);
expect(result()).toEqual([20, 20, 20]);
});

test('should handle undefined spacing', () => {
const result = formatNodeSizeFn(20, undefined, 10);
expect(result()).toBe(20);
expect(result()).toEqual([20, 20, 20]);
});

test('should handle nodeSize as array', () => {
const result = formatNodeSizeFn([30, 40], 5, 10);
expect(result()).toBe(45); // max(30, 40) + 5
expect(result()).toEqual([35, 45, 35]);
});

test('should handle nodeSize as function', () => {
const sizeFn = (node?: NodeData) =>
((node?.data as any)?.customSize as number) || 25;
const result = formatNodeSizeFn(sizeFn, 10, 10);
expect(result({ id: 'test', data: { customSize: 30 } })).toBe(40); // 30 + 10
expect(result({ id: 'test', data: { customSize: 30 } })).toEqual([
40, 40, 40,
]);
});

test('should handle nodeSpacing as function', () => {
const spacingFn = (node?: NodeData) =>
((node?.data as any)?.spacing as number) || 0;
const result = formatNodeSizeFn(20, spacingFn, 10);
expect(result({ id: 'test', data: { spacing: 5 } })).toBe(25); // 20 + 5
expect(result({ id: 'test', data: { spacing: 5 } })).toEqual([
25, 25, 25,
]);
});

test('should handle both nodeSize and nodeSpacing as functions', () => {
Expand All @@ -200,7 +194,9 @@ describe('format', () => {
const spacingFn = (node?: NodeData) =>
((node?.data as any)?.spacing as number) || 0;
const result = formatNodeSizeFn(sizeFn, spacingFn, 10);
expect(result({ id: 'test', data: { size: 30, spacing: 5 } })).toBe(35);
expect(result({ id: 'test', data: { size: 30, spacing: 5 } })).toEqual([
35, 35, 35,
]);
});

test('should return default when nodeSize undefined and node has no size in data', () => {
Expand All @@ -209,32 +205,34 @@ describe('format', () => {
id: 'node1',
data: {},
};
expect(result(nodeData)).toBe(15); // default 10 + 5 spacing
expect(result(nodeData)).toEqual([15, 15, 15]); // default 10 + 5 spacing
});

test('should handle negative spacing', () => {
const result = formatNodeSizeFn(20, -5, 10);
expect(result()).toBe(15); // 20 + (-5)
expect(result()).toEqual([15, 15, 15]); // 20 + (-5)
});

test('should handle fractional sizes and spacing', () => {
const result = formatNodeSizeFn(20.5, 3.2, 10);
expect(result()).toBeCloseTo(23.7);
expect(result()[0]).toBeCloseTo(23.7);
expect(result()[1]).toBeCloseTo(23.7);
expect(result()[2]).toBeCloseTo(23.7);
});

test('should handle number zero node size', () => {
const result = formatNodeSizeFn(0, 5, 10);
expect(result()).toBe(15); // 0 is falsy, falls back to default 10 + 5
expect(result()).toEqual([5, 5, 5]);
});

test('should handle large sizes', () => {
const result = formatNodeSizeFn(1000, 100, 10);
expect(result()).toBe(1100);
expect(result()).toEqual([1100, 1100, 1100]);
});

test('should use default when all values are undefined', () => {
const result = formatNodeSizeFn(undefined, undefined, 12);
expect(result()).toBe(12);
expect(result()).toEqual([12, 12, 12]);
});

test('should handle node data without spacing function', () => {
Expand All @@ -243,12 +241,12 @@ describe('format', () => {
id: 'node1',
data: {},
};
expect(result(nodeData)).toBe(25);
expect(result(nodeData)).toEqual([25, 25, 25]);
});

test('should handle single element array size', () => {
const result = formatNodeSizeFn([35], 5, 10);
expect(result()).toBe(40); // max(35) + 5
expect(result()).toEqual([40, 40, 40]); // max(35) + 5
});
});
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@antv/layout",
"version": "2.0.0-beta.0",
"version": "2.0.0-beta.1",
"description": "graph layout algorithm",
"main": "dist/index.min.js",
"module": "lib/index.js",
Expand Down Expand Up @@ -35,6 +35,7 @@
],
"dependencies": {
"@antv/event-emitter": "^0.1.3",
"@antv/expr": "^1.0.2",
"@antv/graphlib": "^2.0.0",
"@antv/util": "^3.3.2",
"comlink": "^4.4.1",
Expand Down
2 changes: 1 addition & 1 deletion site/docs/en/guide/start/_meta.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["getting-started", "installation", "data-mapping", "layout-configuration"]
["getting-started", "installation", "webworker", "data-mapping", "layout-configuration"]
95 changes: 95 additions & 0 deletions site/docs/en/guide/start/webworker.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Badge } from '@theme';

# Using WebWorker <Badge text="Performance" type="info" />

WebWorker is a browser capability that runs JavaScript in a **separate thread**, helping you avoid main-thread jank caused by heavy computations.

In `@antv/layout`, when `enableWorker: true` is set and `Worker` is available, layout computation runs in a Worker to reduce main-thread blocking. If the Worker is unavailable or fails to load, it automatically falls back to the main thread.

## How to enable

Just set `enableWorker: true` in layout options:

```ts
import { ForceAtlas2Layout } from '@antv/layout';

const layout = new ForceAtlas2Layout({ enableWorker: true });
await layout.execute(data);
```

If you use the UMD build via CDN, make sure `dist/worker.js` is accessible and lives alongside `dist/index.min.js` (the worker URL is derived from `index.js/index.min.js` and points to `worker.js` in the same directory). See “Worker notes” in the Installation page for details.

## Limitations in Worker mode

In Worker mode, the library transfers `data/options` between the main thread and the Worker via structured clone. **Functions are not structured-cloneable**, so function callbacks inside options can break the transfer, typically with:

```txt
DataCloneError: Failed to execute 'postMessage' on 'Worker': function could not be cloned
```

Below are common ways to address it.

### Option 1: Use `Expr` (string expressions) instead of callbacks

`@antv/layout` supports `Expr` (powered by `@antv/expr`) for many numeric/size fields. If a field’s type includes `Expr`, you can pass a string expression instead of a function.

Example (ForceLayout):

```ts
import { ForceLayout } from '@antv/layout';

const layout = new ForceLayout({
enableWorker: true,
// Expr supports expressions only; it does NOT support ?? / ?. / undefined / == / !=
nodeStrength:
'node.data.strength === 0 || node.data.strength ? node.data.strength : -30',
edgeStrength:
'edge.data.weight === 0 || edge.data.weight ? edge.data.weight : 0.1',
nodeSize: 'node.data.size === 0 || node.data.size ? node.data.size : 10',
});

await layout.execute(data);
```

Expression variable names are **taken from the callback parameter names in the type definitions** (i.e. if the field originally supports a function like `(node) => ...`, then your `Expr` should use `node`):

- `(node) => ...`: use `node` (e.g. `nodeStrength` / `nodeSize`)
- `(edge) => ...`: use `edge` (e.g. `edgeStrength`)
- `(edge, source, target) => ...`: use `edge` / `source` / `target` (e.g. `linkDistance`)
- If the combo callback parameter is named `combo`, use `combo`

#### `Expr` syntax tips

`Expr` follows `@antv/expr` syntax (see: https://github.com/antvis/expr). Common patterns:

- Property access: `node.data.size`, `edge.source`, `combo.id`, `items[0]`
- Arithmetic / logic: `a + b * c`, `a > 10 && b < 5`, `!flag`
- Ternary: `node.data.type === "big" ? 20 : 10`
- Built-in functions (prefixed with `@`): `@max(a, b)`, `@min(a, b)`, `@sqrt(x)` (built-ins: `abs/ceil/floor/round/sqrt/pow/max/min`)

Notes: `@antv/expr` does not support `??`, `?.`, `undefined` literals, or `==/!=`. For fallbacks, prefer `?:` / `&&` / `||`, for example:

```ts
// Treat 0 as a valid value
nodeSize: 'node.data.size === 0 || node.data.size ? node.data.size : 10'
```

### Option 2: Pre-compute values into your data

If you used to do:

```ts
nodeSize: (node) => node.data.size
```

You can pre-fill `data.nodes[i].data.size` and then use `nodeSize: 'node.data.size'` (or rely on defaults).

### Option 3: If you need callbacks, don’t enable Worker

Some capabilities inherently require functions and cannot be passed into Workers:

- `onTick`-style callbacks
- `node` / `edge` mapping functions
- Any custom logic without an `Expr` alternative

In these cases, run the layout on the main thread by keeping `enableWorker` off.
2 changes: 1 addition & 1 deletion site/docs/zh/guide/start/_meta.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["getting-started", "installation", "data-mapping", "layout-configuration"]
["getting-started", "installation", "webworker", "data-mapping", "layout-configuration"]
Loading