From 7bb73c9bfe36512ea4613fc23f8a79d4a6409eda Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Mon, 12 Jan 2026 15:59:53 +0800 Subject: [PATCH 1/8] feat: expr --- __tests__/demos/combo-combined.ts | 1 - __tests__/demos/combo-combined2.ts | 1 - __tests__/demos/fruchterman.ts | 5 +- .../combo-combined/demo-combo2-actual.svg | 822 ++++++++++++++++++ .../snapshots/dagre/edge-labels-actual.svg | 198 +++++ __tests__/unit/circular.test.ts | 4 +- __tests__/unit/expr-utils.test.ts | 42 + __tests__/unit/force.test.ts | 43 + package.json | 1 + src/algorithm/antv-dagre/index.ts | 27 +- src/algorithm/antv-dagre/types.ts | 17 +- src/algorithm/circular/index.ts | 9 +- src/algorithm/circular/types.ts | 15 - src/algorithm/combo-combined/index.ts | 38 +- src/algorithm/combo-combined/types.ts | 27 +- src/algorithm/concentric/index.ts | 34 +- src/algorithm/concentric/types.ts | 18 +- src/algorithm/d3-force-3d/index.ts | 2 +- src/algorithm/d3-force-3d/types.ts | 5 + src/algorithm/d3-force/index.ts | 17 +- src/algorithm/d3-force/types.ts | 43 +- src/algorithm/dagre/index.ts | 15 +- src/algorithm/dagre/types.ts | 22 +- src/algorithm/force-atlas2/index.ts | 28 +- src/algorithm/force-atlas2/types.ts | 12 - src/algorithm/force/index.ts | 104 ++- src/algorithm/force/types.ts | 121 ++- src/algorithm/fruchterman/index.ts | 22 +- src/algorithm/fruchterman/simulation.ts | 4 +- src/algorithm/fruchterman/types.ts | 7 +- src/algorithm/grid/index.ts | 91 +- src/algorithm/grid/types.ts | 41 +- src/algorithm/radial/index.ts | 20 +- .../radial/radial-nonoverlap-force.ts | 17 +- src/algorithm/radial/types.ts | 17 +- src/algorithm/types.ts | 18 + src/types/common.ts | 11 + src/types/data.ts | 2 +- src/util/expr.ts | 24 + src/util/format.ts | 98 ++- src/util/index.ts | 2 + src/util/order.ts | 12 +- src/util/size.ts | 8 + 43 files changed, 1612 insertions(+), 453 deletions(-) create mode 100644 __tests__/snapshots/combo-combined/demo-combo2-actual.svg create mode 100644 __tests__/snapshots/dagre/edge-labels-actual.svg create mode 100644 __tests__/unit/expr-utils.test.ts create mode 100644 src/util/expr.ts diff --git a/__tests__/demos/combo-combined.ts b/__tests__/demos/combo-combined.ts index 92d33e8e..e838d162 100644 --- a/__tests__/demos/combo-combined.ts +++ b/__tests__/demos/combo-combined.ts @@ -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, diff --git a/__tests__/demos/combo-combined2.ts b/__tests__/demos/combo-combined2.ts index fc3c45c3..c71be899 100644 --- a/__tests__/demos/combo-combined2.ts +++ b/__tests__/demos/combo-combined2.ts @@ -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, diff --git a/__tests__/demos/fruchterman.ts b/__tests__/demos/fruchterman.ts index 2deda21a..b13f9662 100644 --- a/__tests__/demos/fruchterman.ts +++ b/__tests__/demos/fruchterman.ts @@ -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) { diff --git a/__tests__/snapshots/combo-combined/demo-combo2-actual.svg b/__tests__/snapshots/combo-combined/demo-combo2-actual.svg new file mode 100644 index 00000000..f8fff01c --- /dev/null +++ b/__tests__/snapshots/combo-combined/demo-combo2-actual.svg @@ -0,0 +1,822 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + a + + + + + + b + + + + + + c + + + + + + d + + + + + + 0 + + + + + + 1 + + + + + + 2 + + + + + + 3 + + + + + + 4 + + + + + + 5 + + + + + + 6 + + + + + + 7 + + + + + + 8 + + + + + + 9 + + + + + + 10 + + + + + + 11 + + + + + + 12 + + + + + + 13 + + + + + + 14 + + + + + + 15 + + + + + + 16 + + + + + + 17 + + + + + + 18 + + + + + + 19 + + + + + + 20 + + + + + + 21 + + + + + + 22 + + + + + + 23 + + + + + + 24 + + + + + + 25 + + + + + + 26 + + + + + + 27 + + + + + + 28 + + + + + + 29 + + + + + + 30 + + + + + + 31 + + + + + + 32 + + + + + + 33 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + a + + + + + + b + + + + + + c + + + + + + d + + + + + + 0 + + + + + + 1 + + + + + + 2 + + + + + + 3 + + + + + + 4 + + + + + + 5 + + + + + + 6 + + + + + + 7 + + + + + + 8 + + + + + + 9 + + + + + + 10 + + + + + + 11 + + + + + + 12 + + + + + + 13 + + + + + + 14 + + + + + + 15 + + + + + + 16 + + + + + + 17 + + + + + + 18 + + + + + + 19 + + + + + + 20 + + + + + + 21 + + + + + + 22 + + + + + + 23 + + + + + + 24 + + + + + + 25 + + + + + + 26 + + + + + + 27 + + + + + + 28 + + + + + + 29 + + + + + + 30 + + + + + + 31 + + + + + + 32 + + + + + + 33 + + + + + diff --git a/__tests__/snapshots/dagre/edge-labels-actual.svg b/__tests__/snapshots/dagre/edge-labels-actual.svg new file mode 100644 index 00000000..bf548abe --- /dev/null +++ b/__tests__/snapshots/dagre/edge-labels-actual.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + 1 + + + + + + 2 + + + + + + 3 + + + + + + 4 + + + + + + 5 + + + + + + 6 + + + + + + 7 + + + + + + 8 + + + + + + 9 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + 1 + + + + + + 2 + + + + + + 3 + + + + + + 4 + + + + + + 5 + + + + + + 6 + + + + + + 7 + + + + + + 8 + + + + + + 9 + + + + + diff --git a/__tests__/unit/circular.test.ts b/__tests__/unit/circular.test.ts index 39b51ab7..2c32207d 100644 --- a/__tests__/unit/circular.test.ts +++ b/__tests__/unit/circular.test.ts @@ -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; @@ -43,6 +42,7 @@ describe('layout circular', () => { ordering: null, angleRatio: 1, nodeSize: 10, + nodeSpacing: 0, }); }); diff --git a/__tests__/unit/expr-utils.test.ts b/__tests__/unit/expr-utils.test.ts new file mode 100644 index 00000000..130e09e9 --- /dev/null +++ b/__tests__/unit/expr-utils.test.ts @@ -0,0 +1,42 @@ +import { evaluateExpression, format } from '@/src'; + +describe('util/expr', () => { + 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(); + }); +}); + +describe('util/format', () => { + test('format converts string expression to function by default', () => { + const fn = format('x + y') as (ctx: { x: number; y: number }) => unknown; + expect(typeof fn).toBe('function'); + expect(fn({ x: 1, y: 2 })).toBe(3); + }); + + test('format returns function as-is', () => { + const original = (ctx: { x: number }) => ctx.x; + expect(format(original)).toBe(original); + }); + + test('format returns other types as-is', () => { + expect(format(123)).toBe(123); + expect(format({ a: 1 })).toEqual({ a: 1 }); + }); + + test('format returns string as-is when mode is string', () => { + expect(format('x + y', 'string')).toBe('x + y'); + }); +}); diff --git a/__tests__/unit/force.test.ts b/__tests__/unit/force.test.ts index c286b3b2..67dd2b8c 100644 --- a/__tests__/unit/force.test.ts +++ b/__tests__/unit/force.test.ts @@ -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', + 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: [ diff --git a/package.json b/package.json index 2dc025ef..9d3d8ca9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/algorithm/antv-dagre/index.ts b/src/algorithm/antv-dagre/index.ts index f19a6a40..e8643864 100644 --- a/src/algorithm/antv-dagre/index.ts +++ b/src/algorithm/antv-dagre/index.ts @@ -1,8 +1,7 @@ import { isNumber } from '@antv/util'; import type { NodeData, PointObject } from '../../types'; import { parsePoint } from '../../util'; -import { formatNumberFn, formatSizeFn } from '../../util/format'; -import { parseSize } from '../../util/size'; +import { formatNodeSizeFn, formatNumberFn } from '../../util/format'; import { BaseLayout } from '../base-layout'; import { DagreGraph, GraphNode } from './graph'; import { layout } from './layout'; @@ -12,6 +11,7 @@ export type { AntVDagreLayoutOptions }; const DEFAULTS_LAYOUT_OPTIONS: Partial = { nodeSize: 10, + nodeSpacing: 0, rankdir: 'TB', nodesep: 50, // 节点水平间距(px) ranksep: 50, // 每一层节点之间间距 @@ -37,6 +37,7 @@ export class AntVDagreLayout extends BaseLayout { protected async layout(options: AntVDagreLayoutOptions): Promise { const { nodeSize, + nodeSpacing, align, rankdir = 'TB', ranksep, @@ -54,21 +55,16 @@ export class AntVDagreLayout extends BaseLayout { nodesepFunc, } = options; - const ranksepfunc = formatNumberFn(ranksepFunc, ranksep ?? 50); - const nodesepfunc = formatNumberFn(nodesepFunc, nodesep ?? 50); - let horisep: (d?: NodeData | undefined) => number = nodesepfunc; - let vertisep: (d?: NodeData | undefined) => number = ranksepfunc; + const ranksepfunc = formatNumberFn(ranksepFunc, ranksep ?? 50, 'node'); + const nodesepfunc = formatNumberFn(nodesepFunc, nodesep ?? 50, 'node'); + let horisep: (node: NodeData) => number = nodesepfunc; + let vertisep: (node: NodeData) => number = ranksepfunc; if (rankdir === 'LR' || rankdir === 'RL') { horisep = ranksepfunc; vertisep = nodesepfunc; } - const nodeSizeFunc = formatSizeFn( - nodeSize, - DEFAULTS_LAYOUT_OPTIONS.nodeSize as number, - ); - // Create internal graph const g = new DagreGraph({ tree: [] }); @@ -76,9 +72,16 @@ export class AntVDagreLayout extends BaseLayout { const nodes = this.model.nodes(); const edges = this.model.edges(); + const sizeFn = formatNodeSizeFn( + nodeSize, + nodeSpacing, + DEFAULTS_LAYOUT_OPTIONS.nodeSize as number, + DEFAULTS_LAYOUT_OPTIONS.nodeSpacing as number, + ); + nodes.forEach((node) => { const raw = node._original; - const size = parseSize(nodeSizeFunc(raw)); + const size = sizeFn(raw); const verti = vertisep(raw); const hori = horisep(raw); const width = size[0] + 2 * hori; diff --git a/src/algorithm/antv-dagre/types.ts b/src/algorithm/antv-dagre/types.ts index ce4551d7..568828c7 100644 --- a/src/algorithm/antv-dagre/types.ts +++ b/src/algorithm/antv-dagre/types.ts @@ -1,4 +1,4 @@ -import { ID, NodeData, Point, Size } from '../../types'; +import { Expr, ID, NodeData, Point } from '../../types'; import { BaseLayoutOptions } from '../base-layout'; export type DagreRankdir = @@ -62,17 +62,6 @@ export interface AntVDagreLayoutOptions extends BaseLayoutOptions { * @defaultValue undefined */ begin?: Point; - /** - * 节点大小(直径)。 - * - * The diameter of the node - * @remarks - * 用于防止节点重叠时的碰撞检测 - * - * Used for collision detection when nodes overlap - * @defaultValue undefined - */ - nodeSize?: Size | ((d?: NodeData) => Size); /** * 节点间距(px) * @@ -105,7 +94,7 @@ export interface AntVDagreLayoutOptions extends BaseLayoutOptions { * The horizontal spacing of the node in the case of rankdir is 'TB' or 'BT', and the vertical spacing of the node in the case of rankdir is 'LR' or 'RL'. The priority is higher than nodesep, that is, if nodesepFunc is set, nodesep does not take effect * @param d - 节点实例 | Node instance */ - nodesepFunc?: (d?: NodeData) => number; + nodesepFunc?: Expr | ((node: NodeData) => number); /** * 层间距(px)的回调函数 * @@ -116,7 +105,7 @@ export interface AntVDagreLayoutOptions extends BaseLayoutOptions { * The vertical spacing of adjacent layers in the case of rankdir is 'TB' or 'BT', and the horizontal spacing of adjacent layers in the case of rankdir is 'LR' or 'RL'. The priority is higher than nodesep, that is, if nodesepFunc is set, nodesep does not take effect * @param d - 节点实例 | Node instance */ - ranksepFunc?: (d?: NodeData) => number; + ranksepFunc?: Expr | ((node: NodeData) => number); /** * 是否同时计算边上的的控制点位置 * diff --git a/src/algorithm/circular/index.ts b/src/algorithm/circular/index.ts index ae825aae..e934cecf 100644 --- a/src/algorithm/circular/index.ts +++ b/src/algorithm/circular/index.ts @@ -1,4 +1,3 @@ -import { isNil } from '@antv/util'; import { normalizeViewport, orderByDegree, orderByTopology } from '../../util'; import { applySingleNodeLayout } from '../../util/common'; import { formatNodeSizeFn } from '../../util/format'; @@ -18,6 +17,7 @@ const DEFAULT_LAYOUT_OPTIONS: CircularLayoutOptions = { ordering: null, angleRatio: 1, nodeSize: 10, + nodeSpacing: 0, }; /** @@ -67,16 +67,17 @@ export class CircularLayout extends BaseLayout { let { radius, startRadius, endRadius } = this.options; const nodes = this.model.nodes(); - const format = formatNodeSizeFn( + const sizeFn = formatNodeSizeFn( nodeSize, nodeSpacing, DEFAULT_LAYOUT_OPTIONS.nodeSize as number, + DEFAULT_LAYOUT_OPTIONS.nodeSpacing as number, ); - if (!isNil(nodeSpacing)) { + if (nodeSpacing) { let perimeter = 0; for (const node of nodes) { - perimeter += format(node._original); + perimeter += Math.max(...sizeFn(node._original)); } radius = perimeter / (2 * Math.PI); } else if (!radius && !startRadius && !endRadius) { diff --git a/src/algorithm/circular/types.ts b/src/algorithm/circular/types.ts index a4d29ade..aef425e3 100644 --- a/src/algorithm/circular/types.ts +++ b/src/algorithm/circular/types.ts @@ -1,5 +1,4 @@ import type { BaseLayoutOptions } from '../types'; -import type { NodeData, Size } from '../../types'; /** * 环形 Circular 布局配置 @@ -85,20 +84,6 @@ export interface CircularLayoutOptions extends BaseLayoutOptions { * @defaultValue 2 * Math.PI */ endAngle?: number; - /** - * 环与环之间最小间距,用于调整半径 - * - * Minimum spacing between rings, used to adjust the radius - * @defaultValue 0 - */ - nodeSpacing?: number | ((d?: NodeData) => number); - /** - * 节点大小(直径)。用于防止节点重叠时的碰撞检测 - * - * Node size (diameter). Used for collision detection when nodes overlap - * @defaultValue 10 - */ - nodeSize?: Size | ((d?: NodeData) => Size); } export interface ParsedCircularLayoutOptions diff --git a/src/algorithm/combo-combined/index.ts b/src/algorithm/combo-combined/index.ts index 40b76e95..e35b73f5 100644 --- a/src/algorithm/combo-combined/index.ts +++ b/src/algorithm/combo-combined/index.ts @@ -1,14 +1,7 @@ import { registry } from '../../registry'; -import type { - GraphData, - ID, - LayoutNode, - NodeData, - Point, - STDSize, -} from '../../types'; -import { normalizeViewport, parseSize } from '../../util'; -import { formatNodeSizeFn, formatNumberFn } from '../../util/format'; +import type { GraphData, ID, LayoutNode, Point, STDSize } from '../../types'; +import { normalizeViewport } from '../../util'; +import { formatFn, formatNodeSizeFn, formatNumberFn } from '../../util/format'; import { BaseLayout, isLayoutWithIterations } from '../base-layout'; import type { Layout } from '../types'; import type { @@ -205,7 +198,10 @@ export class ComboCombinedLayout extends BaseLayout } private getLayoutConfig(combo: HierarchyNode) { - const { layout } = this.options; + const layout = + typeof this.options.layout === 'object' + ? this.options.layout + : formatFn(this.options.layout, ['comboId']); if (typeof layout === 'function') { const comboId = combo.id === ROOT_ID ? null : combo.id!; @@ -219,7 +215,7 @@ export class ComboCombinedLayout extends BaseLayout const base = { type: 'concentric', ...normalizeViewport(this.options), - nodeSize: (d: NodeData) => d.size, + nodeSize: 'node.size', nodeSpacing: 0, }; @@ -334,8 +330,12 @@ export class ComboCombinedLayout extends BaseLayout return { center: [0, 0], width: 0, height: 0 }; } - const comboPaddingFn = formatNumberFn(this.options.comboPadding, 20); - const padding = comboPaddingFn(combo._original); + const comboPaddingFn = formatNumberFn( + this.options.comboPadding, + 20, + 'combo', + ); + const padding = comboPaddingFn(combo._original!); return { center: [(minX + maxX) / 2, (minY + maxY) / 2], @@ -359,15 +359,19 @@ export class ComboCombinedLayout extends BaseLayout ): STDSize { const { nodeSize, nodeSpacing } = this.options; const sizeFn = formatNodeSizeFn(nodeSize, includeSpacing ? nodeSpacing : 0); - return parseSize(sizeFn(node._original)); + return sizeFn(node._original!); } private getComboSize( combo: HierarchyNode, includeSpacing: boolean = true, ): STDSize { - const comboSpacingFn = formatNumberFn(this.options.comboSpacing, 0); - const spacing = includeSpacing ? comboSpacingFn(combo._original) : 0; + const comboSpacingFn = formatNumberFn( + this.options.comboSpacing, + 0, + 'combo', + ); + const spacing = includeSpacing ? comboSpacingFn(combo._original!) : 0; const [width, height] = combo.size as STDSize; return [width + spacing / 2, height + spacing / 2, 0]; } diff --git a/src/algorithm/combo-combined/types.ts b/src/algorithm/combo-combined/types.ts index 65a85f0a..515f6c42 100644 --- a/src/algorithm/combo-combined/types.ts +++ b/src/algorithm/combo-combined/types.ts @@ -1,9 +1,7 @@ +import type { Expr, ID, NodeData } from '../../types'; import type { BaseLayoutOptions } from '../types'; -import type { ID, NodeData, Size } from '../../types'; -export type ComboCombinedLayoutConfig = - | string - | { type: string; [key: string]: any }; +export type ComboCombinedLayoutConfig = { type: string; [key: string]: any }; export interface ComboCombinedLayoutOptions extends BaseLayoutOptions { /** @@ -11,29 +9,16 @@ export interface ComboCombinedLayoutOptions extends BaseLayoutOptions { */ layout?: | ComboCombinedLayoutConfig - | ((comboId: ID | null) => ComboCombinedLayoutConfig); - - /** - * 节点尺寸 - * - * Node size - */ - nodeSize?: Size | ((node?: NodeData) => Size); - - /** - * 节点间距 - * - * Node spacing - */ - nodeSpacing?: number | ((node?: NodeData) => number); + | ((comboId: ID | null) => ComboCombinedLayoutConfig) + | Expr; /** * Combo 之间的间距 */ - comboSpacing?: number | ((combo?: NodeData) => number); + comboSpacing?: number | ((combo: NodeData) => number) | Expr; /** * Combo 内部的边距 */ - comboPadding?: number | ((combo?: NodeData) => number); + comboPadding?: number | ((combo: NodeData) => number) | Expr; } diff --git a/src/algorithm/concentric/index.ts b/src/algorithm/concentric/index.ts index 52d7026d..e1f75659 100644 --- a/src/algorithm/concentric/index.ts +++ b/src/algorithm/concentric/index.ts @@ -1,4 +1,3 @@ -import { BaseLayout } from '../base-layout'; import type { LayoutNode, NodeData } from '../../types'; import { applySingleNodeLayout, @@ -6,7 +5,8 @@ import { orderByDegree, orderBySorter, } from '../../util'; -import { formatNodeSizeFn } from '../../util/format'; +import { formatFn, formatNodeSizeFn } from '../../util/format'; +import { BaseLayout } from '../base-layout'; import type { ConcentricLayoutOptions } from './types'; export type { ConcentricLayoutOptions }; @@ -52,25 +52,21 @@ export class ConcentricLayout extends BaseLayout { equidistant, preventOverlap, startAngle = DEFAULTS_LAYOUT_OPTIONS.startAngle, - nodeSize = DEFAULTS_LAYOUT_OPTIONS.nodeSize, + nodeSize, nodeSpacing, } = this.options; - let sortBy: ConcentricLayoutOptions['sortBy'] = propsSortBy; - if (propsSortBy && typeof propsSortBy === 'function') { - const testNode = this.model.firstNode()!; - const testValue = propsSortBy(testNode._original); - if (typeof testValue !== 'number') sortBy = 'degree'; - } else { - sortBy = 'degree'; - } + const sortBy = + !propsSortBy || propsSortBy === 'degree' + ? ('degree' as const) + : (formatFn(propsSortBy, ['node']) as (node: NodeData) => number); if (sortBy === 'degree') { orderByDegree(this.model); } else { const sorter = (nodeA: NodeData, nodeB: NodeData) => { - const a = (sortBy as (node: NodeData) => number)(nodeA); - const b = (sortBy as (node: NodeData) => number)(nodeB); + const a = sortBy(nodeA); + const b = sortBy(nodeB); return a === b ? 0 : a > b ? -1 : 1; }; orderBySorter(this.model, sorter); @@ -83,17 +79,23 @@ export class ConcentricLayout extends BaseLayout { const v = sortBy === 'degree' ? this.model.degree(node.id) - : sortBy?.(node._original); + : sortBy(node._original); sortKeys.set(node.id, v); } const maxValueNode = this.model.firstNode()!; const maxLevelDiff = propsMaxLevelDiff || sortKeys.get(maxValueNode.id) / 4; - const nodeSizeFn = formatNodeSizeFn(nodeSize, nodeSpacing); + const sizeFn = formatNodeSizeFn( + nodeSize, + nodeSpacing, + DEFAULTS_LAYOUT_OPTIONS.nodeSize as number, + DEFAULTS_LAYOUT_OPTIONS.nodeSpacing as number, + ); + const nodeDistances = new Map(); for (const node of nodes) { - nodeDistances.set(node.id, nodeSizeFn(node._original)); + nodeDistances.set(node.id, Math.max(...sizeFn(node._original))); } // put the values into levels diff --git a/src/algorithm/concentric/types.ts b/src/algorithm/concentric/types.ts index 3cd269eb..3555d6e0 100644 --- a/src/algorithm/concentric/types.ts +++ b/src/algorithm/concentric/types.ts @@ -1,5 +1,5 @@ +import type { Expr, NodeData } from '../../types'; import type { BaseLayoutOptions } from '../types'; -import type { NodeData, Size } from '../../types'; /** * Concentric 同心圆布局配置 @@ -18,20 +18,6 @@ export interface ConcentricLayoutOptions extends BaseLayoutOptions { * @defaultValue false */ preventOverlap?: boolean; - /** - * 节点大小(直径)。用于防止节点重叠时的碰撞检测 - * - * Node size (diameter). Used for collision detection when preventing node overlap - * @defaultValue 30 - */ - nodeSize?: Size | ((d?: NodeData) => Size); - /** - * 环与环之间最小间距,用于调整半径 - * - * Minimum spacing between rings, used to adjust the radius - * @defaultValue 10 - */ - nodeSpacing?: number | ((d?: NodeData) => number); /** * 第一个节点与最后一个节点之间的弧度差 * @@ -85,5 +71,5 @@ export interface ConcentricLayoutOptions extends BaseLayoutOptions { * - ((node) => ...): Custom sorting function, returns a number, the higher the value, the more the node will be placed in the center * @defaultValue degree */ - sortBy?: 'degree' | ((d?: NodeData) => number); + sortBy?: 'degree' | Expr | ((node: NodeData) => number); } diff --git a/src/algorithm/d3-force-3d/index.ts b/src/algorithm/d3-force-3d/index.ts index 1a29855a..a95a91e5 100644 --- a/src/algorithm/d3-force-3d/index.ts +++ b/src/algorithm/d3-force-3d/index.ts @@ -49,7 +49,7 @@ export class D3Force3DLayout extends D3ForceLayout< return { numDimensions: 3, link: { - id: (edge) => String(edge.id), + id: 'edge.id', }, manyBody: {}, center: { diff --git a/src/algorithm/d3-force-3d/types.ts b/src/algorithm/d3-force-3d/types.ts index d2d8f515..bf13794d 100644 --- a/src/algorithm/d3-force-3d/types.ts +++ b/src/algorithm/d3-force-3d/types.ts @@ -1,3 +1,4 @@ +import { Expr } from '../../types'; import type { D3ForceCommonOptions, EdgeDatum as _EdgeDatum, @@ -32,9 +33,11 @@ export interface D3Force3DLayoutOptions extends D3ForceCommonOptions { | { strength?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); radius?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); x?: number; y?: number; @@ -50,9 +53,11 @@ export interface D3Force3DLayoutOptions extends D3ForceCommonOptions { | { strength?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); z?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); }; } diff --git a/src/algorithm/d3-force/index.ts b/src/algorithm/d3-force/index.ts index 8ef698b2..2e30135c 100644 --- a/src/algorithm/d3-force/index.ts +++ b/src/algorithm/d3-force/index.ts @@ -26,7 +26,7 @@ export type { D3ForceLayoutOptions }; const DEFAULTS_LAYOUT_OPTIONS: Partial = { link: { - id: (d) => String(d.id), + id: 'edge.id', }, manyBody: { @@ -401,14 +401,13 @@ export class D3ForceLayout< ) return undefined; - const radius = - options.nodeSize || options.nodeSpacing - ? (d: NodeDatum) => - formatNodeSizeFn( - options.nodeSize, - options.nodeSpacing, - )(d._original) / 2 - : undefined; + const sizeFn = formatNodeSizeFn( + options.nodeSize, + options.nodeSpacing, + DEFAULTS_LAYOUT_OPTIONS.nodeSize as number, + DEFAULTS_LAYOUT_OPTIONS.nodeSpacing as number, + ); + const radius = (d: NodeDatum) => Math.max(...sizeFn(d._original)) / 2; return assignDefined({}, options.collide || {}, { radius: (options.collide && options.collide.radius) || radius, diff --git a/src/algorithm/d3-force/types.ts b/src/algorithm/d3-force/types.ts index 83a0e4f2..c9e423ca 100644 --- a/src/algorithm/d3-force/types.ts +++ b/src/algorithm/d3-force/types.ts @@ -3,8 +3,8 @@ import type { SimulationLinkDatum, SimulationNodeDatum, } from 'd3-force'; +import type { Expr, LayoutEdge, LayoutNode } from '../../types'; import type { BaseLayoutOptions, Layout } from '../types'; -import type { LayoutEdge, LayoutNode } from '../../types'; export interface D3ForceCommonOptions extends Omit { @@ -42,21 +42,21 @@ export interface D3ForceCommonOptions * Unique identifier field or function for edges * @defaultValue (edge) => String(edge.id) */ - edgeId?: (edge: EdgeDatum) => string; + edgeId?: Expr | ((edge: EdgeDatum) => string); /** * 边的理想长度,可以是数值或根据边数据返回长度的函数 * * Ideal length of edges, can be a number or a function that returns length based on edge data * @defaultValue 50 */ - linkDistance?: number | ((edge: EdgeDatum) => number); + linkDistance?: number | Expr | ((edge: EdgeDatum) => number); /** * 边的强度,可以是数值或根据边数据返回强度的函数。值范围为 [0, 1] * * Strength of edges, can be a number or a function that returns strength based on edge data. Value range is [0, 1] * @defaultValue null */ - edgeStrength?: number | ((edge: EdgeDatum) => number) | null; + edgeStrength?: number | Expr | ((edge: EdgeDatum) => number) | null; /** * 链接力的迭代次数 * @@ -70,7 +70,7 @@ export interface D3ForceCommonOptions * Strength of node force, negative for repulsion, positive for attraction * @defaultValue -30 */ - nodeStrength?: number | ((node: NodeDatum) => number); + nodeStrength?: number | Expr | ((node: NodeDatum) => number); /** * 多体力的近似参数,值范围为 (0, 1] * @@ -113,21 +113,6 @@ export interface D3ForceCommonOptions * @defaultValue 1 */ collideIterations?: number; - /** - * 节点大小(直径)。用于防止节点重叠时的碰撞检测 - * - * Node size (diameter). Used for collision detection when nodes overlap - * - * @defaultValue 10 - */ - nodeSize?: number | ((d?: NodeDatum) => number); - /** - * 节点之间的最小间距 - * - * Minimum spacing between nodes - * @defaultValue 0 - */ - nodeSpacing?: number | ((d?: NodeDatum) => number); /** * 径向力的理想半径,可以是数值或根据节点数据返回半径的函数 * @@ -169,7 +154,7 @@ export interface D3ForceCommonOptions * Field or function used for clustering * @defaultValue (d) => d.cluster */ - clusterBy?: (d: NodeDatum) => string | number; + clusterBy?: Expr | ((node: NodeDatum) => string | number); /** * 聚类内节点之间的作用力强度 * @@ -257,7 +242,7 @@ export interface D3ForceCommonOptions * Set the function for generating random numbers * @returns 随机数 | Random number */ - randomSource?: () => number; + randomSource?: Expr | (() => number); /** * 碰撞力 * @@ -268,6 +253,7 @@ export interface D3ForceCommonOptions | { radius?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); strength?: number; iterations?: number; @@ -282,6 +268,7 @@ export interface D3ForceCommonOptions | { strength?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); theta?: number; distanceMin?: number; @@ -295,12 +282,16 @@ export interface D3ForceCommonOptions link?: | false | { - id?: (edge: EdgeDatum, index: number, edges: EdgeDatum[]) => string; + id?: + | Expr + | ((edge: EdgeDatum, index: number, edges: EdgeDatum[]) => string); distance?: | number + | Expr | ((edge: EdgeDatum, index: number, edges: EdgeDatum[]) => number); strength?: | number + | Expr | ((edge: EdgeDatum, index: number, edges: EdgeDatum[]) => number); iterations?: number; }; @@ -314,9 +305,11 @@ export interface D3ForceCommonOptions | { strength?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); x?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); }; /** @@ -329,9 +322,11 @@ export interface D3ForceCommonOptions | { strength?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); y?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); }; } @@ -360,9 +355,11 @@ export interface D3ForceLayoutOptions extends D3ForceCommonOptions { | { strength?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); radius?: | number + | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); x?: number; y?: number; diff --git a/src/algorithm/dagre/index.ts b/src/algorithm/dagre/index.ts index b7a3d977..e3aa49f2 100644 --- a/src/algorithm/dagre/index.ts +++ b/src/algorithm/dagre/index.ts @@ -1,9 +1,9 @@ import { isBoolean, isNil, pick } from '@antv/util'; import dagre, { graphlib } from 'dagre'; -import { BaseLayout } from '../base-layout'; import type { LayoutNode } from '../../types'; import { parsePoint, parseSize } from '../../util'; -import { formatNumberFn, formatSizeFn } from '../../util/format'; +import { formatFn, formatNumberFn, formatSizeFn } from '../../util/format'; +import { BaseLayout } from '../base-layout'; import type { DagreLayoutOptions } from './types'; export type { DagreLayoutOptions }; @@ -98,12 +98,11 @@ export class DagreLayout extends BaseLayout { edgeWeight, } = this.options; - const edgeLabelSizeFn = formatSizeFn(edgeLabelSize, 0); - const edgeLabelOffsetFn = formatNumberFn(edgeLabelOffset, 10); - const edgeLabelPosFn = - typeof edgeLabelPos === 'function' ? edgeLabelPos : () => edgeLabelPos; - const edgeMinLenFn = formatNumberFn(edgeMinLen, 1); - const edgeWeightFn = formatNumberFn(edgeWeight, 1); + const edgeLabelSizeFn = formatSizeFn(edgeLabelSize, 0, 'edge'); + const edgeLabelOffsetFn = formatNumberFn(edgeLabelOffset, 10, 'edge'); + const edgeLabelPosFn = formatFn(edgeLabelPos, ['edge']); + const edgeMinLenFn = formatNumberFn(edgeMinLen, 1, 'edge'); + const edgeWeightFn = formatNumberFn(edgeWeight, 1, 'edge'); this.model.forEachEdge((edge) => { const raw = edge._original; diff --git a/src/algorithm/dagre/types.ts b/src/algorithm/dagre/types.ts index 6fdc40c6..36f07de6 100644 --- a/src/algorithm/dagre/types.ts +++ b/src/algorithm/dagre/types.ts @@ -1,7 +1,7 @@ import type { GraphLabel } from 'dagre'; -import type { BaseLayoutOptions } from '../types'; +import type { EdgeData, Size } from '../../types'; import type { EdgeLabelPos } from '../../types/edge-label'; -import type { NodeData, Size } from '../../types'; +import type { BaseLayoutOptions } from '../types'; /** * 边标签位置:'l' 左侧,'c' 中心,'r' 右侧 @@ -38,47 +38,39 @@ export interface DagreLayoutOptions extends BaseLayoutOptions, GraphLabel { */ multigraph?: boolean; - /** - * 定义节点占用的空间大小,影响节点间距和整体布局疏密 - * - * Defines space occupied by nodes, affecting inter-node spacing and overall layout density - * @defaultValue [0, 0] - */ - nodeSize?: Size | ((d?: NodeData) => Size); - /** * 设置边跨越的最小层数,值越大节点间距越远,用于控制布局紧凑度 * * Sets minimum number of layers an edge spans; larger values create more distance between nodes, controlling layout compactness * @defaultValue 1 */ - edgeMinLen?: number | ((d?: NodeData) => number); + edgeMinLen?: number | ((edge: EdgeData) => number); /** * 边的权重,影响边的长度优化优先级,权重大的边倾向于更短 * * Edge weight affecting length optimization priority; higher weight edges tend to be shorter */ - edgeWeight?: number | ((d?: NodeData) => number); + edgeWeight?: number | ((edge: EdgeData) => number); /** * 边标签的尺寸,用于为标签预留空间,避免与节点重叠 * * Size of edge labels for reserving space to prevent overlap with nodes */ - edgeLabelSize?: Size | ((d?: NodeData) => Size); + edgeLabelSize?: Size | ((edge: EdgeData) => Size); /** * 标签在边上的位置,控制标签相对于边的对齐方式 * * Label position on edge, controlling label alignment relative to the edge */ - edgeLabelPos?: EdgeLabelPos | ((d?: NodeData) => EdgeLabelPos); + edgeLabelPos?: EdgeLabelPos | ((edge: EdgeData) => EdgeLabelPos); /** * 标签与边的偏移距离,用于微调标签位置避免视觉重叠 * * Offset distance between label and edge for fine-tuning label position to avoid visual overlap */ - edgeLabelOffset?: number | ((d?: NodeData) => number); + edgeLabelOffset?: number | ((edge: EdgeData) => number); } diff --git a/src/algorithm/force-atlas2/index.ts b/src/algorithm/force-atlas2/index.ts index 41a812a6..30fcc198 100644 --- a/src/algorithm/force-atlas2/index.ts +++ b/src/algorithm/force-atlas2/index.ts @@ -1,9 +1,9 @@ -import { BaseLayoutWithIterations } from '../base-layout'; +import { initNodePosition } from '../../model/data'; import type { ID, NullablePosition } from '../../types'; import { normalizeViewport } from '../../util'; -import { initNodePosition } from '../../model/data'; import { applySingleNodeLayout } from '../../util/common'; -import { formatNodeSizeFn, formatNumberFn, formatSizeFn } from '../../util/format'; +import { formatNodeSizeFn } from '../../util/format'; +import { BaseLayoutWithIterations } from '../base-layout'; import { Simulation } from './simulation'; import type { ForceAtlas2LayoutOptions, @@ -97,9 +97,16 @@ export class ForceAtlas2Layout extends BaseLayoutWithIterations { - const nodeSizeFn = formatNodeSizeFn(nodeSize, nodeSpacing); - result[node.id] = nodeSizeFn(node._original); + result[node.id] = Math.max(...nodeSizeFn(node._original!)); }); return result; } @@ -119,8 +126,7 @@ export class ForceAtlas2Layout extends BaseLayoutWithIterations = {}; const n = this.model.nodeCount(); @@ -153,14 +159,6 @@ export class ForceAtlas2Layout extends BaseLayoutWithIterations By default, it will be activated when the number of nodes is greater than 100. Note that pruning can improve the convergence speed, but it may reduce the layout quality of the graph. Setting it to false will not be activated automatically */ prune?: boolean; - /** - * 节点大小(直径)。用于防止节点重叠时的碰撞检测 - * - * Node size (diameter). Used for collision detection when preventing node overlap - */ - nodeSize?: Size | ((d?: NodeData) => Size); - /** - * 节点间距。用于防止节点重叠时的碰撞检测 - * - * Node spacing. Used for collision detection when preventing node overlap - */ - nodeSpacing?: number | ((d?: NodeData) => number); } export type ParsedForceAtlas2LayoutOptions = Required; diff --git a/src/algorithm/force/index.ts b/src/algorithm/force/index.ts index b66208c6..6824836d 100644 --- a/src/algorithm/force/index.ts +++ b/src/algorithm/force/index.ts @@ -1,17 +1,22 @@ import { isEmpty } from '@antv/util'; -import { BaseLayoutWithIterations } from '../base-layout'; +import type { GraphLib } from '../../model/data'; +import { initNodePosition } from '../../model/data'; import type { EdgeData, NodeData, Point, PointObject } from '../../types'; import { normalizeViewport } from '../../util'; -import { initNodePosition } from '../../model/data'; -import type { GraphLib } from '../../model/data'; -import { formatNodeSizeFn, formatNumberFn } from '../../util/format'; +import { formatFn, formatNodeSizeFn, formatNumberFn } from '../../util/format'; +import { BaseLayoutWithIterations } from '../base-layout'; import { forceAttractive } from './attractive'; import { forceCentripetal } from './centripetal'; import { forceCollide } from './collide'; import { forceGravity } from './gravity'; import { forceRepulsive } from './repulsive'; import { ForceSimulation } from './simulation'; -import { ForceLayoutOptions, ParsedForceLayoutOptions } from './types'; +import { + ForceLayoutOptions, + GetCenterFn, + NodeClusterByFn, + ParsedForceLayoutOptions, +} from './types'; export type { ForceLayoutOptions }; @@ -251,31 +256,45 @@ export class ForceLayout extends BaseLayoutWithIterations { ...normalizeViewport(options), } as ParsedForceLayoutOptions; + // Format nodeClusterBy (for clustering / leafCluster) + if (_.nodeClusterBy) { + _.nodeClusterBy = formatFn(_.nodeClusterBy, ['node']) as NodeClusterByFn; + } + // Format node mass if (!options.getMass) { - _.getMass = (d?: NodeData) => { - if (!d) return 1; + _.getMass = (node: NodeData) => { + if (!node) return 1; const massWeight = 1; - const degree = this.model.degree(d.id, 'both'); + const degree = this.model.degree(node.id, 'both'); return !degree || degree < 5 ? massWeight : degree * 5 * massWeight; }; + } else { + _.getMass = formatNumberFn(options.getMass, 1); + } + + // Format per-node center force callback + if (options.getCenter) { + const params = ['node', 'degree']; + _.getCenter = formatFn(options.getCenter, params) as GetCenterFn; } // Format node size - _.nodeSize = formatNodeSizeFn(options.nodeSize, options.nodeSpacing); + const nodeSizeVec = formatNodeSizeFn(options.nodeSize, options.nodeSpacing); + _.nodeSize = (node: NodeData) => { + if (!node) return 0; + const [w, h, z] = nodeSizeVec(node); + return Math.max(w, h, z); + }; // Format node / edge strengths _.linkDistance = options.linkDistance - ? formatNumberFn(options.linkDistance, 1) - : (edge?: EdgeData) => { - return ( - 1 + - _.nodeSize(this.model.node(edge!.source)!._original) + - _.nodeSize(this.model.node(edge!.target)!._original) - ); - }; + ? (formatFn(options.linkDistance, ['edge', 'source', 'target']) as any) + : (_: EdgeData, source: NodeData, target: NodeData) => + 1 + _.nodeSize(source) + _.nodeSize(target); _.nodeStrength = formatNumberFn(options.nodeStrength, 1); - _.edgeStrength = formatNumberFn(options.edgeStrength, 1); + _.edgeStrength = formatNumberFn(options.edgeStrength, 1, 'edge'); + _.clusterNodeStrength = formatNumberFn(options.clusterNodeStrength, 1); // Format centripetal options this.formatCentripetal(_); @@ -291,28 +310,45 @@ export class ForceLayout extends BaseLayoutWithIterations { dimensions, centripetalOptions, center, - clusterNodeStrength, leafCluster, clustering, nodeClusterBy, } = options; - // Basic centripetal settings - const basicCentripetal = centripetalOptions || { - leaf: 2, - single: 2, - others: 1, - center: (_: NodeData) => { + const leafParams = ['node', 'nodes', 'edges']; + const leafFn = formatFn(centripetalOptions?.leaf, leafParams); + const singleFn = formatNumberFn(centripetalOptions?.single, 2); + const othersFn = formatNumberFn(centripetalOptions?.others, 1); + + const centerRaw = + centripetalOptions?.center ?? + ((_: NodeData) => { return { x: center[0], y: center[1], z: dimensions === 3 ? center[2] : undefined, }; - }, + }); + + const centerFn = formatFn(centerRaw, [ + 'node', + 'nodes', + 'edges', + 'width', + 'height', + ]) as any; + + const basicCentripetal = { + ...centripetalOptions, + leaf: leafFn, + single: singleFn, + others: othersFn, + center: centerFn, }; - if (typeof clusterNodeStrength !== 'function') { - options.clusterNodeStrength = () => clusterNodeStrength as number; + // If user provided centripetalOptions, normalize them even without clustering modes. + if (centripetalOptions) { + options.centripetalOptions = basicCentripetal as any; } let sameTypeLeafMap: any; @@ -406,18 +442,6 @@ export class ForceLayout extends BaseLayoutWithIterations { }, }); } - - // Normalize functions - const { leaf, single, others } = options.centripetalOptions || {}; - if (leaf && typeof leaf !== 'function') { - options.centripetalOptions.leaf = () => leaf; - } - if (single && typeof single !== 'function') { - options.centripetalOptions.single = () => single; - } - if (others && typeof others !== 'function') { - options.centripetalOptions.others = () => others; - } } /** diff --git a/src/algorithm/force/types.ts b/src/algorithm/force/types.ts index 1d491530..6bc9b5d5 100644 --- a/src/algorithm/force/types.ts +++ b/src/algorithm/force/types.ts @@ -1,9 +1,9 @@ import type { CommonForceLayoutOptions, EdgeData, + Expr, NodeData, Point, - Size, } from '../../types'; export type AccMap = { [id: string]: { x: number; y: number; z: number } }; @@ -25,6 +25,7 @@ export interface CentripetalOptions { */ leaf?: | number + | Expr | ((node: NodeData, nodes: NodeData[], edges: EdgeData[]) => number); /** * 离散节点(即度数为 0 的节点)受到的向心力大小 @@ -35,7 +36,7 @@ export interface CentripetalOptions { * - ((node: NodeData) => number): return different values according to the node situation * @defaultValue 2 */ - single?: number | ((node: NodeData) => number); + single?: number | Expr | ((node: NodeData) => number); /** * 除离散节点、叶子节点以外的其他节点(即度数 > 1 的节点)受到的向心力大小 * - number: 固定向心力大小 @@ -45,13 +46,35 @@ export interface CentripetalOptions { * - ((node: NodeData) => number): return different values according to the node situation * @defaultValue 1 */ - others?: number | ((node: NodeData) => number); + others?: number | Expr | ((node: NodeData) => number); /** * 向心力发出的位置,可根据节点、边的情况返回不同的值、默认为图的中心 * * The position where the centripetal force is emitted, which can return different values according to the node, edge, and situation. The default is the center of the graph */ - center?: ( + center?: + | Expr + | (( + node: NodeData, + nodes: NodeData[], + edges: EdgeData[], + width: number, + height: number, + ) => { + x: number; + y: number; + z?: number; + centerStrength?: number; + }); +} + +interface FormatCentripetalOptions extends CentripetalOptions { + leaf: (node: NodeData, nodes: NodeData[], edges: EdgeData[]) => number; + /** Force strength for single nodes. */ + single: (node: NodeData) => number; + /** Force strength for other nodes. */ + others: (node: NodeData) => number; + center: ( node: NodeData, nodes: NodeData[], edges: EdgeData[], @@ -65,41 +88,34 @@ export interface CentripetalOptions { }; } -interface FormatCentripetalOptions extends CentripetalOptions { - leaf: (node: NodeData, nodes: NodeData[], edges: EdgeData[]) => number; - /** Force strength for single nodes. */ - single: (node: NodeData) => number; - /** Force strength for other nodes. */ - others: (node: NodeData) => number; -} - export interface ForceLayoutOptions extends CommonForceLayoutOptions { /** * 边的长度 * - number: 固定长度 - * - ((edge?: EdgeData, source?: any, target?: any) => number): 根据边的信息返回长度 + * - ((edge: EdgeData, source: NodeData, target: NodeData) => number): 根据边的信息返回长度 * The length of the edge * - number: fixed length - * - ((edge?: EdgeData, source?: any, target?: any) => number): return length according to the edge information + * - ((edge: EdgeData, source: NodeData, target: NodeData) => number): return length according to the edge information * @defaultValue 200 */ linkDistance?: | number - | ((edge?: EdgeData, source?: any, target?: any) => number); + | Expr + | ((edge: EdgeData, source: NodeData, target: NodeData) => number); /** * 节点作用力,正数代表节点之间的引力作用,负数代表节点之间的斥力作用 * * The force of the node, positive numbers represent the attraction force between nodes, and negative numbers represent the repulsion force between nodes * @defaultValue 1000 */ - nodeStrength?: number | ((d?: NodeData) => number); + nodeStrength?: number | Expr | ((node: NodeData) => number); /** * 边的作用力(引力)大小 * * The size of the force of the edge (attraction) * @defaultValue 50 */ - edgeStrength?: number | ((d?: EdgeData) => number); + edgeStrength?: number | Expr | ((edge: EdgeData) => number); /** * 是否防止重叠,必须配合下面属性 nodeSize 或节点数据中的 data.size 属性,只有在数据中设置了 data.size 或在该布局中配置了与当前图节点大小相同的 nodeSize 值,才能够进行节点重叠的碰撞检测 * @@ -107,18 +123,6 @@ export interface ForceLayoutOptions extends CommonForceLayoutOptions { * @defaultValue true */ preventOverlap?: boolean; - /** - * 节点大小(直径)。用于防止节点重叠时的碰撞检测 - * - * The size of the node (diameter). Used for collision detection when preventing node overlap - */ - nodeSize?: Size | ((d?: NodeData) => Size); - /** - * preventOverlap 为 true 时生效, 防止重叠时节点边缘间距的最小值。可以是回调函数, 为不同节点设置不同的最小间距 - * - * It is effective when preventOverlap is true. The minimum spacing of the node edge when preventing overlap. It can be a callback function to set different minimum spacing for different nodes - */ - nodeSpacing?: number | ((d?: NodeData) => number); /** * 阻尼系数,取值范围 [0, 1]。数字越大,速度降低得越慢 * @@ -194,14 +198,14 @@ export interface ForceLayoutOptions extends CommonForceLayoutOptions { * * Specify the field name of the node data as the clustering basis for the node, and it takes effect when clustering is true. You can combine it with clusterNodeStrength to use it */ - nodeClusterBy?: (node: NodeData) => string; + nodeClusterBy?: Expr | ((node: NodeData) => string); /** * 配合 clustering 和 nodeClusterBy 使用,指定聚类向心力的大小 * * Use it with clustering and nodeClusterBy to specify the size of the centripetal force of the cluster * @defaultValue 20 */ - clusterNodeStrength?: number | ((node: NodeData) => number); + clusterNodeStrength?: number | Expr | ((node: NodeData) => number); /** * 防止重叠的力强度,范围 [0, 1] * @@ -216,7 +220,7 @@ export interface ForceLayoutOptions extends CommonForceLayoutOptions { * @param node - 节点数据 | NodeData data * @returns 节点质量大小 | Mass size of the node */ - getMass?: (node?: NodeData) => number; + getMass?: Expr | ((node: NodeData) => number); /** * 每个节点中心力的 x、y、强度的回调函数,若不指定,则没有额外中心力 * @@ -225,7 +229,7 @@ export interface ForceLayoutOptions extends CommonForceLayoutOptions { * @param degree - 节点度数 | NodeData degree * @returns 中心力 x、y、强度 | Center force x、y、strength */ - getCenter?: (node?: NodeData, degree?: number) => number[]; + getCenter?: Expr | ((node: NodeData, degree: number) => number[]); /** * 每个迭代的监控信息回调,energy 表示布局的收敛能量。若配置可能带来额外的计算能量性能消耗,不配置则不计算 * @@ -240,7 +244,18 @@ export interface ForceLayoutOptions extends CommonForceLayoutOptions { }) => void; } -export interface ParsedForceLayoutOptions extends ForceLayoutOptions { +export interface ParsedForceLayoutOptions + extends Omit< + ForceLayoutOptions, + | 'centripetalOptions' + | 'nodeClusterBy' + | 'clusterNodeStrength' + | 'getMass' + | 'getCenter' + | 'nodeStrength' + | 'edgeStrength' + | 'linkDistance' + > { width: number; height: number; center: Point; @@ -251,15 +266,31 @@ export interface ParsedForceLayoutOptions extends ForceLayoutOptions { damping: number; maxSpeed: number; coulombDisScale: number; - centripetalOptions: FormatCentripetalOptions; - nodeSize: (d?: NodeData) => number; - getMass: (d?: NodeData) => number; - nodeStrength: (d?: NodeData) => number; - edgeStrength: (d?: EdgeData) => number; - linkDistance: ( - edge?: EdgeData, - source?: NodeData, - target?: NodeData, - ) => number; - clusterNodeStrength: (node?: NodeData) => number; + centripetalOptions?: FormatCentripetalOptions; + nodeClusterBy?: NodeClusterByFn; + getCenter?: GetCenterFn; + nodeSize: NodeSizeFn; + getMass: GetMassFn; + nodeStrength: NodeStrengthFn; + edgeStrength: EdgeStrengthFn; + linkDistance: LinkDistanceFn; + clusterNodeStrength: NodeStrengthFn; } + +export type NodeClusterByFn = (node: NodeData) => string; + +export type GetCenterFn = (node: NodeData, degree: number) => number[]; + +export type NodeSizeFn = (node: NodeData) => number; + +export type GetMassFn = (node: NodeData) => number; + +export type NodeStrengthFn = (node: NodeData) => number; + +export type EdgeStrengthFn = (edge: EdgeData) => number; + +export type LinkDistanceFn = ( + edge: EdgeData, + source: NodeData, + target: NodeData, +) => number; diff --git a/src/algorithm/fruchterman/index.ts b/src/algorithm/fruchterman/index.ts index f0d822bd..db18f331 100644 --- a/src/algorithm/fruchterman/index.ts +++ b/src/algorithm/fruchterman/index.ts @@ -1,11 +1,7 @@ -import { BaseLayoutWithIterations } from '../base-layout'; -import type { ID, NodeData, NullablePosition } from '../../types'; -import { - applySingleNodeLayout, - getNestedValue, - normalizeViewport, -} from '../../util'; import { initNodePosition } from '../../model/data'; +import type { ID, NullablePosition } from '../../types'; +import { applySingleNodeLayout, formatFn, normalizeViewport } from '../../util'; +import { BaseLayoutWithIterations } from '../base-layout'; import { Simulation } from './simulation'; import type { FruchtermanLayoutOptions, @@ -22,7 +18,7 @@ const DEFAULTS_LAYOUT_OPTIONS: Partial = { clusterGravity: 10, width: 300, height: 300, - nodeClusterBy: 'data.cluster', + nodeClusterBy: 'node.cluster', dimensions: 2, }; @@ -36,18 +32,14 @@ export class FruchtermanLayout extends BaseLayoutWithIterations, + options: Partial = {}, ): ParsedFruchtermanLayoutOptions { const { clustering, nodeClusterBy } = this.options; const clusteringEnabled = clustering && !!nodeClusterBy; - const nodeClusterByFunc = - typeof nodeClusterBy === 'string' - ? (node: NodeData) => getNestedValue(node, nodeClusterBy) - : nodeClusterBy!; - Object.assign((options ||= {}), normalizeViewport(options), { + Object.assign(options, normalizeViewport(options), { clustering: clusteringEnabled, - nodeClusterBy: nodeClusterByFunc, + nodeClusterBy: formatFn(nodeClusterBy, ['node']), }); return options as ParsedFruchtermanLayoutOptions; diff --git a/src/algorithm/fruchterman/simulation.ts b/src/algorithm/fruchterman/simulation.ts index 3ed66a0a..500ecfc1 100644 --- a/src/algorithm/fruchterman/simulation.ts +++ b/src/algorithm/fruchterman/simulation.ts @@ -1,5 +1,5 @@ import { isNil } from '@antv/util'; -import { BaseSimulation } from '../base-simulation'; +import type { GraphLib } from '../../model/data'; import type { DisplacementMap, ID, @@ -8,7 +8,7 @@ import type { NullablePosition, } from '../../types'; import { normalizeViewport } from '../../util'; -import type { GraphLib } from '../../model/data'; +import { BaseSimulation } from '../base-simulation'; import type { FruchtermanLayoutOptions, ParsedFruchtermanLayoutOptions, diff --git a/src/algorithm/fruchterman/types.ts b/src/algorithm/fruchterman/types.ts index a9bc3bed..f579d3ad 100644 --- a/src/algorithm/fruchterman/types.ts +++ b/src/algorithm/fruchterman/types.ts @@ -1,4 +1,4 @@ -import type { CommonForceLayoutOptions, NodeData } from '../../types'; +import type { CommonForceLayoutOptions, Expr, NodeData } from '../../types'; /** * Fruchterman 力导布局配置项 @@ -13,6 +13,7 @@ export interface FruchtermanLayoutOptions extends CommonForceLayoutOptions { * @defaultValue 10 */ gravity?: number; + /** * 每次迭代节点移动的速度。速度太快可能会导致强烈震荡 * @@ -20,6 +21,7 @@ export interface FruchtermanLayoutOptions extends CommonForceLayoutOptions { * @defaultValue 5 */ speed?: number; + /** * 是否按照聚类布局 * @@ -27,6 +29,7 @@ export interface FruchtermanLayoutOptions extends CommonForceLayoutOptions { * @defaultValue false */ clustering?: boolean; + /** * 聚类内部的重力大小,影响聚类的紧凑程度,在 clustering 为 true 时生效 * @@ -41,7 +44,7 @@ export interface FruchtermanLayoutOptions extends CommonForceLayoutOptions { * The field name of the node data in the data, which is used when cluster is true * @defaultValue 'cluster' */ - nodeClusterBy?: string | ((node: NodeData) => string); + nodeClusterBy?: Expr | ((node: NodeData) => string); } export type ParsedFruchtermanLayoutOptions = Required; diff --git a/src/algorithm/grid/index.ts b/src/algorithm/grid/index.ts index ee7e661f..ae928454 100644 --- a/src/algorithm/grid/index.ts +++ b/src/algorithm/grid/index.ts @@ -1,13 +1,13 @@ -import { BaseLayout } from '../base-layout'; -import { LayoutNode, Point } from '../../types'; -import { applySingleNodeLayout, normalizeViewport, parseSize } from '../../util'; -import { formatNumberFn, formatSizeFn } from '../../util/format'; -import { orderByDegree, orderById, orderBySorter } from '../../util/order'; import type { GraphLib } from '../../model/data'; +import { LayoutNode, NodeData, Point, Sorter } from '../../types'; +import { applySingleNodeLayout, normalizeViewport } from '../../util'; +import { formatFn, formatNodeSizeFn } from '../../util/format'; +import { orderByDegree, orderById, orderBySorter } from '../../util/order'; +import { BaseLayout } from '../base-layout'; import type { GridLayoutOptions, IdMapRowAndCol, - NormalizedGridLayoutOptions, + ParsedGridLayoutOptions, RowAndCol, RowsAndCols, VisitMap, @@ -15,6 +15,20 @@ import type { export type { GridLayoutOptions }; +const DEFAULT_LAYOUT_OPTIONS: Partial = { + begin: [0, 0], + preventOverlap: true, + condense: false, + rows: undefined, + cols: undefined, + position: undefined, + sortBy: 'degree', + nodeSize: 30, + nodeSpacing: 10, + width: 300, + height: 300, +}; + /** * 网格布局 * @@ -24,26 +38,19 @@ export class GridLayout extends BaseLayout { id = 'grid'; protected getDefaultOptions(): Partial { - return { - begin: [0, 0], - preventOverlap: true, - preventOverlapPadding: 10, - condense: false, - rows: undefined, - cols: undefined, - position: undefined, - sortBy: 'degree', - nodeSize: 30, - width: 300, - height: 300, - }; + return DEFAULT_LAYOUT_OPTIONS; } - private normalizeOptions( + private parseOptions( options: Partial = {}, model: GraphLib, - ): NormalizedGridLayoutOptions { - const { rows: propRows, cols: propCols } = options; + ): ParsedGridLayoutOptions { + const { + rows: propRows, + cols: propCols, + position: propPosition, + sortBy: propSortBy, + } = options; const { width, height, center } = normalizeViewport(options); let rows = options.rows; @@ -98,23 +105,20 @@ export class GridLayout extends BaseLayout { } } - const preventOverlap = - options.preventOverlap || options.nodeSpacing !== undefined; - const nodeSpacing = formatNumberFn(options.nodeSpacing, 10); - const nodeSize = formatSizeFn(options.nodeSize, 30); + const sortBy = !propSortBy + ? (DEFAULT_LAYOUT_OPTIONS.sortBy as 'degree') + : propSortBy === 'degree' || propSortBy === 'id' + ? propSortBy + : (formatFn(propSortBy, ['nodeA', 'nodeB']) as Sorter); return { - ...options, - begin: options.begin || [0, 0], - sortBy: options.sortBy || 'degree', - preventOverlapPadding: options.preventOverlapPadding ?? 0, - preventOverlap, - nodeSpacing, - nodeSize, + ...(options as Required), + sortBy, rcs, center, width, height, + position: formatFn(propPosition, ['node']), }; } @@ -126,12 +130,11 @@ export class GridLayout extends BaseLayout { width, height, condense, - preventOverlapPadding, preventOverlap, nodeSpacing, nodeSize, position, - } = this.normalizeOptions(this.options, this.model); + } = this.parseOptions(this.options, this.model); const n = this.model.nodeCount(); @@ -152,18 +155,14 @@ export class GridLayout extends BaseLayout { let cellHeight = condense ? 0 : height / rcs.rows; if (preventOverlap) { + const sizeFn = formatNodeSizeFn( + nodeSize, + nodeSpacing, + DEFAULT_LAYOUT_OPTIONS.nodeSize as number, + DEFAULT_LAYOUT_OPTIONS.nodeSpacing as number, + ); this.model.forEachNode((node) => { - const nodeData = node._original; - const [nodeW, nodeH] = parseSize(nodeSize(nodeData) || 30); - - const p = - nodeSpacing !== undefined - ? nodeSpacing(nodeData) - : preventOverlapPadding; - - const w = nodeW + p; - const h = nodeH + p; - + const [w, h] = sizeFn(node._original); cellWidth = Math.max(cellWidth, w); cellHeight = Math.max(cellHeight, h); }); diff --git a/src/algorithm/grid/types.ts b/src/algorithm/grid/types.ts index c0982e93..0a1f441e 100644 --- a/src/algorithm/grid/types.ts +++ b/src/algorithm/grid/types.ts @@ -1,5 +1,5 @@ +import type { Expr, NodeData, Point, Sorter } from '../../types'; import type { BaseLayoutOptions } from '../types'; -import type { NodeData, Point, Size } from '../../types'; export interface GridLayoutOptions extends BaseLayoutOptions { /** @@ -21,25 +21,6 @@ export interface GridLayoutOptions extends BaseLayoutOptions { * @defaultValue false */ preventOverlap?: boolean; - /** - * 节点大小(直径)。用于防止节点重叠时的碰撞检测 - * - * Node size (diameter). Used for collision detection when nodes overlap - */ - nodeSize?: Size | ((d?: NodeData) => Size); - /** - * 环与环之间最小间距,用于调整半径 - * - * Minimum spacing between rings, used to adjust the radius - */ - nodeSpacing?: number | ((d?: NodeData) => number); - /** - * 避免重叠时节点的间距 padding。preventOverlap 为 true 时生效 - * - * Padding between nodes to prevent overlap. It takes effect when preventOverlap is true - * @defaultValue 10 - */ - preventOverlapPadding?: number; /** * 为 false 时表示利用所有可用画布空间,为 true 时表示利用最小的画布空间 * @@ -67,38 +48,28 @@ export interface GridLayoutOptions extends BaseLayoutOptions { * Specify the basis for sorting (node attribute name). The higher the value, the more the node will be placed in the center. If it is undefined, the degree of the node will be calculated, and the higher the degree, the more the node will be placed in the center * @defaultValue undefined */ - sortBy?: 'id' | 'degree' | ((nodeA: NodeData, nodeB: NodeData) => -1 | 0 | 1); + sortBy?: 'id' | 'degree' | Expr | Sorter; /** * 指定每个节点所在的行和列 * * Specify the row and column where each node is located * @defaultValue undefined */ - position?: (node: NodeData) => { row?: number; col?: number }; + position?: Expr | ((node: NodeData) => { row?: number; col?: number }); } -export interface NormalizedGridLayoutOptions +export interface ParsedGridLayoutOptions extends Omit< GridLayoutOptions, - | 'begin' - | 'nodeSize' - | 'nodeSpacing' - | 'preventOverlap' - | 'preventOverlapPadding' - | 'sortBy' - | 'rows' - | 'cols' + 'begin' | 'preventOverlap' | 'sortBy' | 'rows' | 'cols' > { width: number; height: number; center: Point; begin: Point; rcs: { rows: number; cols: number }; - nodeSize: (node?: NodeData) => Size; - nodeSpacing: (node?: NodeData) => number; preventOverlap: boolean; - preventOverlapPadding: number; - sortBy: 'id' | 'degree' | ((nodeA: NodeData, nodeB: NodeData) => -1 | 0 | 1); + sortBy: 'id' | 'degree' | Sorter; } export type RowsAndCols = { diff --git a/src/algorithm/radial/index.ts b/src/algorithm/radial/index.ts index 2614142d..19de86a4 100644 --- a/src/algorithm/radial/index.ts +++ b/src/algorithm/radial/index.ts @@ -1,10 +1,10 @@ -import { BaseLayout } from '../base-layout'; -import { runMDS } from '../mds'; -import type { ID, Matrix } from '../../types'; import type { GraphLib } from '../../model/data'; +import type { ID, Matrix } from '../../types'; import { getAdjList, johnson, normalizeViewport } from '../../util'; import { applySingleNodeLayout } from '../../util/common'; -import { formatNodeSizeFn } from '../../util/format'; +import { formatFn, formatNodeSizeFn } from '../../util/format'; +import { BaseLayout } from '../base-layout'; +import { runMDS } from '../mds'; import { radialNonoverlapForce, RadialNonoverlapForceOptions, @@ -22,6 +22,8 @@ const DEFAULTS_LAYOUT_OPTIONS: Partial = { sortStrength: 10, strictRadial: true, unitRadius: null, + nodeSize: 10, + nodeSpacing: 0, }; /** @@ -126,7 +128,12 @@ export class RadialLayout extends BaseLayout { // stagger the overlapped nodes if (preventOverlap) { - const nodeSizeFunc = formatNodeSizeFn(nodeSize, nodeSpacing); + const nodeSizeFunc = formatNodeSizeFn( + nodeSize, + nodeSpacing, + DEFAULTS_LAYOUT_OPTIONS.nodeSize as number, + DEFAULTS_LAYOUT_OPTIONS.nodeSpacing as number, + ); const nonoverlapForceParams: RadialNonoverlapForceOptions = { nodeSizeFunc, radiiMap, @@ -230,7 +237,8 @@ const eIdealDisMatrix = ( const baseLink = (linkDistance + unitRadius) / 2; const sortCache = new Map(); - const sortFn = typeof sortBy === 'function' ? sortBy : null; + const sortFn = + !sortBy || sortBy === 'data' ? null : formatFn(sortBy, ['node']); const isDataSort = sortBy === 'data'; for (let i = 0; i < n; i++) { diff --git a/src/algorithm/radial/radial-nonoverlap-force.ts b/src/algorithm/radial/radial-nonoverlap-force.ts index 65e2d4ac..472ded1f 100644 --- a/src/algorithm/radial/radial-nonoverlap-force.ts +++ b/src/algorithm/radial/radial-nonoverlap-force.ts @@ -1,6 +1,11 @@ -import type { DisplacementMap, ID, LayoutNode, NodeData, Size } from '../../types'; import type { GraphLib } from '../../model/data'; -import { parseSize } from '../../util'; +import type { + DisplacementMap, + ID, + LayoutNode, + NodeData, + STDSize, +} from '../../types'; const SPEED_DIVISOR = 800; @@ -22,7 +27,7 @@ export type RadialNonoverlapForceOptions = { /** Gravity factor pulling nodes towards their target radius */ gravity?: number; /** Function to get the size of a node (includes node self and spacing) */ - nodeSizeFunc: (node?: NodeData) => Size; + nodeSizeFunc: (node: NodeData) => STDSize; }; const DEFAULTS_LAYOUT_OPTIONS: Partial = { @@ -83,7 +88,7 @@ const getRepulsion = ( displacements: DisplacementMap, k: number, radiiMap: Map, - nodeSizeFunc: (d?: NodeData) => Size, + nodeSizeFunc: (node: NodeData) => STDSize, ) => { let i = 0; @@ -112,8 +117,8 @@ const getRepulsion = ( vecy = 0.01 * sign; } - const nodeSizeU = Math.max(...parseSize(nodeSizeFunc(nodeU._original))); - const nodeSizeV = Math.max(...parseSize(nodeSizeFunc(nodeV._original))); + const nodeSizeU = Math.max(...nodeSizeFunc(nodeU._original)); + const nodeSizeV = Math.max(...nodeSizeFunc(nodeV._original)); // these two nodes overlap if (vecLength < nodeSizeV / 2 + nodeSizeU / 2) { diff --git a/src/algorithm/radial/types.ts b/src/algorithm/radial/types.ts index 8cc7fae2..c748cca4 100644 --- a/src/algorithm/radial/types.ts +++ b/src/algorithm/radial/types.ts @@ -1,5 +1,5 @@ +import type { Expr, NodeData } from '../../types'; import type { BaseLayoutOptions } from '../base-layout'; -import type { NodeData, Size } from '../../types'; /** * Radial 辐射布局的配置项 @@ -41,19 +41,6 @@ export interface RadialLayoutOptions extends BaseLayoutOptions { * @defaultValue false */ preventOverlap?: boolean; - /** - * 节点大小(直径)。用于防止节点重叠时的碰撞检测 - * - * Node size (diameter). Used for collision detection when preventing node overlap - */ - nodeSize?: Size | ((d?: NodeData) => Size); - /** - * preventOverlap 为 true 时生效, 防止重叠时节点边缘间距的最小值。可以是回调函数, 为不同节点设置不同的最小间距 - * - * Effective when preventOverlap is true. The minimum edge spacing when preventing node overlap. It can be a callback function, and set different minimum spacing for different nodes - * @defaultValue 10 - */ - nodeSpacing?: number | ((d?: NodeData) => number); /** * 防止重叠步骤的最大迭代次数 * @@ -82,7 +69,7 @@ export interface RadialLayoutOptions extends BaseLayoutOptions { * The default is undefined, which means arranging based on the topological structure of the data (the shortest path between nodes). Nodes that are closer in proximity or have a smaller shortest path between them will be arranged as close together as possible. 'data' indicates arranging based on the order of nodes in the data, so nodes that are closer in the data order will be arranged as close together as possible. You can also specify a field name in the node data, such as 'cluster' or 'name' (it must exist in the data of the graph) * @defaultValue undefined */ - sortBy?: 'data' | ((d?: NodeData) => number | string); + sortBy?: 'data' | ((node: NodeData) => number | string) | Expr; /** * 同层节点根据 sortBy 排列的强度,数值越大,sortBy 指定的方式计算出距离越小的越靠近。sortBy 不为 undefined 时生效 * diff --git a/src/algorithm/types.ts b/src/algorithm/types.ts index c52ab652..52f6e706 100644 --- a/src/algorithm/types.ts +++ b/src/algorithm/types.ts @@ -1,11 +1,13 @@ import type { EdgeData, + Expr, GraphData, GraphEdge, GraphNode, ID, NodeData, Point, + Size, } from '../types'; export interface DataOptions< @@ -63,6 +65,22 @@ export interface BaseLayoutOptions< */ height?: number; + /** + * 节点大小(直径)。用于防止节点重叠时的碰撞检测 + * + * Node size (diameter). Used for collision detection when nodes overlap + * @defaultValue 10 + */ + nodeSize?: Size | Expr | ((node: NodeData) => Size); + + /** + * 节点之间的最小间距 + * + * Minimum spacing between nodes + * @defaultValue 0 + */ + nodeSpacing?: Size | Expr | ((node: NodeData) => Size); + /** * 是否启用 WebWorker * diff --git a/src/types/common.ts b/src/types/common.ts index 6699408f..9e7d76bb 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,3 +1,14 @@ export type PlainObject = Record; export type Matrix = number[][]; + +export type Expr = string; + +/** + * CallableExpr<(node: NodeData) => number> + * + * => 'node.degree' | (node: NodeData) => number + */ +export type CallableExpr = Expr | ((data: T) => any); + +export type Sorter = (a: T, b: T) => -1 | 0 | 1; diff --git a/src/types/data.ts b/src/types/data.ts index 5c02f271..221c3765 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -1,5 +1,5 @@ -import type { EdgeLabelPos } from './edge-label'; import type { PlainObject } from './common'; +import type { EdgeLabelPos } from './edge-label'; import type { ID } from './id'; import type { Point } from './point'; import type { Size } from './size'; diff --git a/src/util/expr.ts b/src/util/expr.ts new file mode 100644 index 00000000..33f7cf6a --- /dev/null +++ b/src/util/expr.ts @@ -0,0 +1,24 @@ +import { compile, evaluate } from '@antv/expr'; +import type { Context } from '@antv/expr/dist/interpreter'; + +/** + * Evaluate an expression if (and only if) it's a valid string expression. + * - Returns `undefined` when `expression` is not a string, empty, or invalid. + * + * @example + * evaluateExpression('x + y', { x: 10, y: 20 }) // 30 + */ +export function evaluateExpression( + expression: string, + context: Context, +): Function | undefined { + const source = expression.trim(); + if (!source) return undefined; + + try { + compile(source); + return evaluate(source, context); + } catch { + return undefined; + } +} diff --git a/src/util/format.ts b/src/util/format.ts index a21acb15..900bf5b2 100644 --- a/src/util/format.ts +++ b/src/util/format.ts @@ -1,6 +1,30 @@ -import { isFunction, isNumber, isObject } from '@antv/util'; -import type { NodeData, Size } from '../types'; -import { parseSize } from './size'; +import { isFunction, isNil, isNumber, isString } from '@antv/util'; +import type { Expr, NodeData, Size, STDSize } from '../types'; +import { evaluateExpression } from './expr'; +import { isSize, parseSize } from './size'; + +/** + * Format a value into a callable function when it is a string expression. + * - `string` => `(context) => evaluateExpression(string, context)` + * - `function` => returned as-is + * - other => returned as-is + */ +export function formatFn< + TContext extends Record = Record, +>(value: unknown, argNames: (keyof TContext & string)[]) { + if (typeof value === 'function') return value; + if (typeof value === 'string') { + const expr = value; + return (...argv: any[]) => { + const ctx = {} as TContext; + for (let i = 0; i < argNames.length; i++) { + (ctx as any)[argNames[i]] = argv[i]; + } + return evaluateExpression(expr, ctx); + }; + } + return () => value; +} /** * Format value with multiple types into a function that returns a number @@ -8,10 +32,26 @@ import { parseSize } from './size'; * @param defaultValue The default value when value is invalid * @returns A function that returns a number */ -export function formatNumberFn( - value: number | ((d?: T) => number) | undefined, +export function formatNumberFn( + value: number | Expr | ((d: T) => number) | undefined, defaultValue: number, -): (d?: T) => number { + type: 'node' | 'edge' | 'combo' = 'node', +): (d: T) => number { + // If value is undefined, return default value function + if (!value) { + return () => defaultValue; + } + + // If value is an expression, return a function that evaluates the expression + if (isString(value)) { + const numberFn = formatFn(value, [type]); + return (d: T) => { + const evaluated = numberFn(d); + if (isNumber(evaluated)) return evaluated; + return defaultValue; + }; + } + // If value is a function, return it directly if (isFunction(value)) { return value; @@ -33,15 +73,26 @@ export function formatNumberFn( * @param resultIsNumber Whether to return a number (max of width/height) or size array * @returns A function that returns a size */ -export function formatSizeFn( - value?: Size | { width: number; height: number } | ((d?: T) => Size), +export function formatSizeFn( + value?: Size | Expr | ((d: T) => Size), defaultValue: number = 10, -): (d?: T) => Size { + type: 'node' | 'edge' | 'combo' = 'node', +): (d: T) => Size { // If value is undefined, return default value function - if (!value) { + if (isNil(value)) { return () => defaultValue; } + // If value is an expression, return a function that evaluates the expression + if (isString(value)) { + const sizeFn = formatFn(value, [type]); + return (d: T) => { + const evaluated = sizeFn(d); + if (isSize(evaluated)) return evaluated; + return defaultValue; + }; + } + // If value is a function, return it directly if (isFunction(value)) { return value; @@ -57,11 +108,6 @@ export function formatSizeFn( return () => value; } - // If value is an object with width and height - if (isObject(value) && value.width && value.height) { - return () => [value.width, value.height]; - } - return () => defaultValue; } @@ -70,23 +116,21 @@ export function formatSizeFn( * @param nodeSize The size of the node * @param nodeSpacing The spacing around the node * @param defaultNodeSize The default node size when value is invalid + * @param defaultNodeSpacing The default node spacing when value is invalid * @returns A function that returns the total size (node size + spacing) */ export const formatNodeSizeFn = ( - nodeSize: - | Size - | { width: number; height: number } - | ((node?: NodeData) => Size) - | undefined, - nodeSpacing: number | ((node?: NodeData) => number) | undefined, + nodeSize?: Size | Expr | ((node: NodeData) => Size), + nodeSpacing?: Size | Expr | ((node: NodeData) => Size), defaultNodeSize: number = 10, -): ((node?: NodeData) => number) => { - const nodeSpacingFunc = formatNumberFn(nodeSpacing, 0); + defaultNodeSpacing: number = 0, +): ((d: NodeData) => STDSize) => { + const nodeSpacingFunc = formatSizeFn(nodeSpacing, defaultNodeSpacing); const nodeSizeFunc = formatSizeFn(nodeSize, defaultNodeSize); - return (node?: NodeData) => { - const size = nodeSizeFunc(node); - const spacing = nodeSpacingFunc(node); - return Math.max(...parseSize(size)) + spacing; + return (d: NodeData) => { + const [sizeW, sizeH, sizeD] = parseSize(nodeSizeFunc(d)); + const [spacingW, spacingH, spacingD] = parseSize(nodeSpacingFunc(d)); + return [sizeW + spacingW, sizeH + spacingH, sizeD + spacingD]; }; }; diff --git a/src/util/index.ts b/src/util/index.ts index e415a8fe..0ee7b4fc 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,5 +1,7 @@ export * from './array'; export * from './common'; +export * from './expr'; +export * from './format'; export * from './math'; export * from './object'; export * from './order'; diff --git a/src/util/order.ts b/src/util/order.ts index d51a904f..60a597a5 100644 --- a/src/util/order.ts +++ b/src/util/order.ts @@ -1,11 +1,5 @@ -import type { LayoutNode, NodeData } from '../types'; import type { GraphLib } from '../model/data'; - -export type SortComparator = ( - nodeA: LayoutNode, - nodeB: LayoutNode, - nodes: LayoutNode[], -) => -1 | 0 | 1; +import type { LayoutNode, NodeData, Sorter } from '../types'; /** * 通用排序核心函数 @@ -29,7 +23,7 @@ export function orderByDegree( return sort(model, (nodeA, nodeB) => { const degreeA = model.degree(nodeA.id); const degreeB = model.degree(nodeB.id); - if(order === 'asc') { + if (order === 'asc') { return degreeA - degreeB; // ascending order } return degreeB - degreeA; // descending order @@ -59,7 +53,7 @@ export function orderById( */ export function orderBySorter( model: GraphLib, - sorter: (a: NodeData, b: NodeData) => -1 | 0 | 1, + sorter: Sorter, ): GraphLib { return sort(model, (nodeA, nodeB) => { const a = model.originalNode(nodeA.id); diff --git a/src/util/size.ts b/src/util/size.ts index 2baeec31..09910078 100644 --- a/src/util/size.ts +++ b/src/util/size.ts @@ -8,3 +8,11 @@ export function parseSize(size?: Size): STDSize { const [x, y = x, z = x] = size; return [x, y, z]; } + +export function isSize(value: unknown): value is Size { + if (isNumber(value)) return true; + if (Array.isArray(value)) { + return value.every((item) => isNumber(item)); + } + return false; +} From a15926e1f454b7e7136eebba6efb2e438e72dbdf Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Mon, 12 Jan 2026 17:08:49 +0800 Subject: [PATCH 2/8] test: update tests --- .../combo-combined/demo-combo2-actual.svg | 822 ------------------ .../snapshots/dagre/edge-labels-actual.svg | 198 ----- __tests__/snapshots/dagre/edge-labels.svg | 24 +- __tests__/unit/expr-utils.test.ts | 42 - __tests__/unit/fruchterman.test.ts | 2 +- __tests__/unit/util/expr.test.ts | 22 + __tests__/unit/util/format.test.ts | 52 +- src/algorithm/d3-force-3d/index.ts | 4 +- src/algorithm/d3-force/index.ts | 23 +- src/algorithm/d3-force/types.ts | 12 +- src/util/expr.ts | 4 +- src/util/format.ts | 2 +- 12 files changed, 80 insertions(+), 1127 deletions(-) delete mode 100644 __tests__/snapshots/combo-combined/demo-combo2-actual.svg delete mode 100644 __tests__/snapshots/dagre/edge-labels-actual.svg delete mode 100644 __tests__/unit/expr-utils.test.ts create mode 100644 __tests__/unit/util/expr.test.ts diff --git a/__tests__/snapshots/combo-combined/demo-combo2-actual.svg b/__tests__/snapshots/combo-combined/demo-combo2-actual.svg deleted file mode 100644 index f8fff01c..00000000 --- a/__tests__/snapshots/combo-combined/demo-combo2-actual.svg +++ /dev/null @@ -1,822 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - a - - - - - - b - - - - - - c - - - - - - d - - - - - - 0 - - - - - - 1 - - - - - - 2 - - - - - - 3 - - - - - - 4 - - - - - - 5 - - - - - - 6 - - - - - - 7 - - - - - - 8 - - - - - - 9 - - - - - - 10 - - - - - - 11 - - - - - - 12 - - - - - - 13 - - - - - - 14 - - - - - - 15 - - - - - - 16 - - - - - - 17 - - - - - - 18 - - - - - - 19 - - - - - - 20 - - - - - - 21 - - - - - - 22 - - - - - - 23 - - - - - - 24 - - - - - - 25 - - - - - - 26 - - - - - - 27 - - - - - - 28 - - - - - - 29 - - - - - - 30 - - - - - - 31 - - - - - - 32 - - - - - - 33 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - a - - - - - - b - - - - - - c - - - - - - d - - - - - - 0 - - - - - - 1 - - - - - - 2 - - - - - - 3 - - - - - - 4 - - - - - - 5 - - - - - - 6 - - - - - - 7 - - - - - - 8 - - - - - - 9 - - - - - - 10 - - - - - - 11 - - - - - - 12 - - - - - - 13 - - - - - - 14 - - - - - - 15 - - - - - - 16 - - - - - - 17 - - - - - - 18 - - - - - - 19 - - - - - - 20 - - - - - - 21 - - - - - - 22 - - - - - - 23 - - - - - - 24 - - - - - - 25 - - - - - - 26 - - - - - - 27 - - - - - - 28 - - - - - - 29 - - - - - - 30 - - - - - - 31 - - - - - - 32 - - - - - - 33 - - - - - diff --git a/__tests__/snapshots/dagre/edge-labels-actual.svg b/__tests__/snapshots/dagre/edge-labels-actual.svg deleted file mode 100644 index bf548abe..00000000 --- a/__tests__/snapshots/dagre/edge-labels-actual.svg +++ /dev/null @@ -1,198 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 0 - - - - - - 1 - - - - - - 2 - - - - - - 3 - - - - - - 4 - - - - - - 5 - - - - - - 6 - - - - - - 7 - - - - - - 8 - - - - - - 9 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 0 - - - - - - 1 - - - - - - 2 - - - - - - 3 - - - - - - 4 - - - - - - 5 - - - - - - 6 - - - - - - 7 - - - - - - 8 - - - - - - 9 - - - - - diff --git a/__tests__/snapshots/dagre/edge-labels.svg b/__tests__/snapshots/dagre/edge-labels.svg index 84c9b891..bf548abe 100644 --- a/__tests__/snapshots/dagre/edge-labels.svg +++ b/__tests__/snapshots/dagre/edge-labels.svg @@ -5,7 +5,7 @@ - + @@ -29,13 +29,13 @@ - + - + - + @@ -49,7 +49,7 @@ 1 - + 2 @@ -91,7 +91,7 @@ 8 - + 9 @@ -101,7 +101,7 @@ - + @@ -125,13 +125,13 @@ - + - + - + @@ -145,7 +145,7 @@ 1 - + 2 @@ -187,7 +187,7 @@ 8 - + 9 diff --git a/__tests__/unit/expr-utils.test.ts b/__tests__/unit/expr-utils.test.ts deleted file mode 100644 index 130e09e9..00000000 --- a/__tests__/unit/expr-utils.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { evaluateExpression, format } from '@/src'; - -describe('util/expr', () => { - 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(); - }); -}); - -describe('util/format', () => { - test('format converts string expression to function by default', () => { - const fn = format('x + y') as (ctx: { x: number; y: number }) => unknown; - expect(typeof fn).toBe('function'); - expect(fn({ x: 1, y: 2 })).toBe(3); - }); - - test('format returns function as-is', () => { - const original = (ctx: { x: number }) => ctx.x; - expect(format(original)).toBe(original); - }); - - test('format returns other types as-is', () => { - expect(format(123)).toBe(123); - expect(format({ a: 1 })).toEqual({ a: 1 }); - }); - - test('format returns string as-is when mode is string', () => { - expect(format('x + y', 'string')).toBe('x + y'); - }); -}); diff --git a/__tests__/unit/fruchterman.test.ts b/__tests__/unit/fruchterman.test.ts index f61e44db..065bc1e6 100644 --- a/__tests__/unit/fruchterman.test.ts +++ b/__tests__/unit/fruchterman.test.ts @@ -39,7 +39,7 @@ describe('FruchtermanLayout', () => { clusterGravity: 10, width: 300, height: 300, - nodeClusterBy: 'data.cluster', + nodeClusterBy: 'node.cluster', dimensions: 2, }); }); diff --git a/__tests__/unit/util/expr.test.ts b/__tests__/unit/util/expr.test.ts new file mode 100644 index 00000000..419e7acd --- /dev/null +++ b/__tests__/unit/util/expr.test.ts @@ -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(); + }); + }); +}); diff --git a/__tests__/unit/util/format.test.ts b/__tests__/unit/util/format.test.ts index ed4c9f7b..03e00460 100644 --- a/__tests__/unit/util/format.test.ts +++ b/__tests__/unit/util/format.test.ts @@ -85,16 +85,6 @@ describe('format', () => { expect(result()).toEqual([20, 30]); }); - test('should handle object value with width and height', () => { - const result = formatSizeFn({ width: 40, height: 50 }, 10); - expect(result()).toEqual([40, 50]); - }); - - test('should handle object value when resultIsNumber is false', () => { - const result = formatSizeFn({ 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(undefined, 10); expect(result()).toBe(10); @@ -111,7 +101,7 @@ describe('format', () => { test('should handle number zero as valid size', () => { const result = formatSizeFn(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', () => { @@ -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]); }); 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', () => { @@ -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', () => { @@ -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', () => { @@ -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 }); }); }); diff --git a/src/algorithm/d3-force-3d/index.ts b/src/algorithm/d3-force-3d/index.ts index a95a91e5..2c597fe0 100644 --- a/src/algorithm/d3-force-3d/index.ts +++ b/src/algorithm/d3-force-3d/index.ts @@ -48,9 +48,7 @@ export class D3Force3DLayout extends D3ForceLayout< protected getDefaultOptions(): Partial { return { numDimensions: 3, - link: { - id: 'edge.id', - }, + edgeId: 'edge.id', manyBody: {}, center: { x: 0, diff --git a/src/algorithm/d3-force/index.ts b/src/algorithm/d3-force/index.ts index 2e30135c..3d79d394 100644 --- a/src/algorithm/d3-force/index.ts +++ b/src/algorithm/d3-force/index.ts @@ -12,7 +12,8 @@ import { } from 'd3-force'; import type { ID, Position } from '../../types'; import { assignDefined, normalizeViewport } from '../../util'; -import { formatNodeSizeFn } from '../../util/format'; +import { formatFn, formatNodeSizeFn } from '../../util/format'; +import { getNestedValue } from '../../util/object'; import { BaseLayoutWithIterations } from '../base-layout'; import forceInABox from './force-in-a-box'; import type { @@ -25,9 +26,7 @@ import type { export type { D3ForceLayoutOptions }; const DEFAULTS_LAYOUT_OPTIONS: Partial = { - link: { - id: 'edge.id', - }, + edgeId: 'edge.id', manyBody: { strength: -30, @@ -325,7 +324,9 @@ export class D3ForceLayout< if (options.manyBody === false) return undefined; return assignDefined({}, options.manyBody || {}, { - strength: options.nodeStrength, + strength: options.nodeStrength + ? formatFn(options.nodeStrength, ['node']) + : undefined, distanceMin: options.distanceMin, distanceMax: options.distanceMax, theta: options.theta, @@ -362,9 +363,13 @@ export class D3ForceLayout< if (options.link === false) return undefined; return assignDefined({}, options.link || {}, { - id: options.edgeId, - distance: options.linkDistance, - strength: options.edgeStrength, + id: options.edgeId ? formatFn(options.edgeId, ['edge']) : undefined, + distance: options.linkDistance + ? formatFn(options.linkDistance, ['edge']) + : undefined, + strength: options.edgeStrength + ? formatFn(options.edgeStrength, ['edge']) + : undefined, iterations: options.edgeIterations, }); } @@ -566,7 +571,7 @@ export class D3ForceLayout< ['centerY', center && center.y], ['template', 'force'], ['strength', clusterFociStrength], - ['groupBy', clusterBy], + ['groupBy', clusterBy ? formatFn(clusterBy, ['node']) : undefined], ['nodes', this.model.nodes()], ['links', this.model.edges()], ['forceLinkDistance', clusterEdgeDistance], diff --git a/src/algorithm/d3-force/types.ts b/src/algorithm/d3-force/types.ts index c9e423ca..4f3981a0 100644 --- a/src/algorithm/d3-force/types.ts +++ b/src/algorithm/d3-force/types.ts @@ -253,7 +253,6 @@ export interface D3ForceCommonOptions | { radius?: | number - | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); strength?: number; iterations?: number; @@ -268,7 +267,6 @@ export interface D3ForceCommonOptions | { strength?: | number - | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); theta?: number; distanceMin?: number; @@ -282,16 +280,12 @@ export interface D3ForceCommonOptions link?: | false | { - id?: - | Expr - | ((edge: EdgeDatum, index: number, edges: EdgeDatum[]) => string); + id?: (edge: EdgeDatum, index: number, edges: EdgeDatum[]) => string; distance?: | number - | Expr | ((edge: EdgeDatum, index: number, edges: EdgeDatum[]) => number); strength?: | number - | Expr | ((edge: EdgeDatum, index: number, edges: EdgeDatum[]) => number); iterations?: number; }; @@ -305,11 +299,9 @@ export interface D3ForceCommonOptions | { strength?: | number - | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); x?: | number - | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); }; /** @@ -322,11 +314,9 @@ export interface D3ForceCommonOptions | { strength?: | number - | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); y?: | number - | Expr | ((node: NodeDatum, index: number, nodes: NodeDatum[]) => number); }; } diff --git a/src/util/expr.ts b/src/util/expr.ts index 33f7cf6a..c5f91d88 100644 --- a/src/util/expr.ts +++ b/src/util/expr.ts @@ -9,9 +9,11 @@ import type { Context } from '@antv/expr/dist/interpreter'; * evaluateExpression('x + y', { x: 10, y: 20 }) // 30 */ export function evaluateExpression( - expression: string, + expression: unknown, context: Context, ): Function | undefined { + if (typeof expression !== 'string') return undefined; + const source = expression.trim(); if (!source) return undefined; diff --git a/src/util/format.ts b/src/util/format.ts index 900bf5b2..abb532d7 100644 --- a/src/util/format.ts +++ b/src/util/format.ts @@ -38,7 +38,7 @@ export function formatNumberFn( type: 'node' | 'edge' | 'combo' = 'node', ): (d: T) => number { // If value is undefined, return default value function - if (!value) { + if (isNil(value)) { return () => defaultValue; } From a5f5486edfc0e3aa8a1813189646a7b4811fab02 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Mon, 12 Jan 2026 17:15:38 +0800 Subject: [PATCH 3/8] fix: d3force id --- src/algorithm/d3-force-3d/index.ts | 4 +++- src/algorithm/d3-force/index.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/algorithm/d3-force-3d/index.ts b/src/algorithm/d3-force-3d/index.ts index 2c597fe0..27b1af72 100644 --- a/src/algorithm/d3-force-3d/index.ts +++ b/src/algorithm/d3-force-3d/index.ts @@ -48,7 +48,9 @@ export class D3Force3DLayout extends D3ForceLayout< protected getDefaultOptions(): Partial { return { numDimensions: 3, - edgeId: 'edge.id', + link: { + id: (edge) => edge.id!, + }, manyBody: {}, center: { x: 0, diff --git a/src/algorithm/d3-force/index.ts b/src/algorithm/d3-force/index.ts index 3d79d394..c2d2b197 100644 --- a/src/algorithm/d3-force/index.ts +++ b/src/algorithm/d3-force/index.ts @@ -13,7 +13,6 @@ import { import type { ID, Position } from '../../types'; import { assignDefined, normalizeViewport } from '../../util'; import { formatFn, formatNodeSizeFn } from '../../util/format'; -import { getNestedValue } from '../../util/object'; import { BaseLayoutWithIterations } from '../base-layout'; import forceInABox from './force-in-a-box'; import type { @@ -528,7 +527,11 @@ export class D3ForceLayout< if (radial) { let force = simulation.force('radial'); if (!force) { - force = forceRadial(radial.radius || 100, radial.x, radial.y); + force = forceRadial( + (radial.radius as () => number) || 100, + radial.x, + radial.y, + ); simulation.force('radial', force as any); } From e7ccfd8dc10d795ffd8fab62e145fca73771ebf1 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Mon, 12 Jan 2026 17:44:10 +0800 Subject: [PATCH 4/8] docs: update docs --- site/docs/en/guide/start/_meta.json | 2 +- site/docs/en/guide/start/webworker.mdx | 62 ++++++++++++++++++++++++++ site/docs/zh/guide/start/_meta.json | 2 +- site/docs/zh/guide/start/webworker.mdx | 62 ++++++++++++++++++++++++++ src/algorithm/antv-dagre/types.ts | 2 +- src/algorithm/d3-force-3d/types.ts | 2 +- src/algorithm/dagre/types.ts | 12 ++--- src/types/common.ts | 13 +++++- src/util/expr.ts | 6 +-- 9 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 site/docs/en/guide/start/webworker.mdx create mode 100644 site/docs/zh/guide/start/webworker.mdx diff --git a/site/docs/en/guide/start/_meta.json b/site/docs/en/guide/start/_meta.json index 8a6811d7..7ca8cf42 100644 --- a/site/docs/en/guide/start/_meta.json +++ b/site/docs/en/guide/start/_meta.json @@ -1 +1 @@ -["getting-started", "installation", "data-mapping", "layout-configuration"] +["getting-started", "installation", "webworker", "data-mapping", "layout-configuration"] diff --git a/site/docs/en/guide/start/webworker.mdx b/site/docs/en/guide/start/webworker.mdx new file mode 100644 index 00000000..99d9f04d --- /dev/null +++ b/site/docs/en/guide/start/webworker.mdx @@ -0,0 +1,62 @@ +import { Badge } from '@theme'; + +# Using WebWorker + +When `enableWorker: true` and `Worker` is available, layout computation runs in a WebWorker to reduce main-thread blocking. + +However, if you pass **function callbacks** in options (e.g. `(node) => ...`), Worker mode may throw errors like: + +```txt +DataCloneError: Failed to execute 'postMessage' on 'Worker': function could not be cloned +``` + +## Why it happens + +The library transfers `data/options` between the main thread and the Worker via structured clone. **Functions are not structured-cloneable**, so any function inside options can break the transfer. + +## How to fix + +### 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, + nodeStrength: 'node.data.strength ?? -30', + edgeStrength: 'edge.data.weight ?? 0.1', + nodeSize: 'node.data.size ?? 10', +}); + +await layout.execute(data); +``` + +Expression variable names depend on the field: + +- Node fields usually use `node` +- Edge fields usually use `edge` +- Combo fields usually use `combo` + +### 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. diff --git a/site/docs/zh/guide/start/_meta.json b/site/docs/zh/guide/start/_meta.json index 8a6811d7..7ca8cf42 100644 --- a/site/docs/zh/guide/start/_meta.json +++ b/site/docs/zh/guide/start/_meta.json @@ -1 +1 @@ -["getting-started", "installation", "data-mapping", "layout-configuration"] +["getting-started", "installation", "webworker", "data-mapping", "layout-configuration"] diff --git a/site/docs/zh/guide/start/webworker.mdx b/site/docs/zh/guide/start/webworker.mdx new file mode 100644 index 00000000..069bee39 --- /dev/null +++ b/site/docs/zh/guide/start/webworker.mdx @@ -0,0 +1,62 @@ +import { Badge } from '@theme'; + +# 使用 WebWorker + +当你配置 `enableWorker: true` 且运行环境支持 `Worker` 时,布局计算会在 Worker 中执行,从而减少主线程阻塞。 + +但如果你在配置项里传入了**回调函数**(例如 `(node) => ...`),在 Worker 模式下可能会直接报错,常见报错类似: + +```txt +DataCloneError: Failed to execute 'postMessage' on 'Worker': function could not be cloned +``` + +## 为什么会报错 + +库在主线程与 Worker 之间传递 `data/options` 时依赖结构化克隆(structured clone)。**函数无法被结构化克隆**,因此当 options 中包含函数时,就会触发 `postMessage`/Comlink 的传输错误。 + +## 怎么解决 + +### 方案 1:用 `Expr`(字符串表达式)替代回调 + +`@antv/layout` 内置了基于 `@antv/expr` 的表达式能力:对于类型里标注为 `Expr` 的字段,你可以传入字符串表达式来代替函数。 + +示例(ForceLayout): + +```ts +import { ForceLayout } from '@antv/layout'; + +const layout = new ForceLayout({ + enableWorker: true, + nodeStrength: 'node.data.strength ?? -30', + edgeStrength: 'edge.data.weight ?? 0.1', + nodeSize: 'node.data.size ?? 10', +}); + +await layout.execute(data); +``` + +表达式里的变量名取决于字段类型: + +- 节点相关字段通常使用 `node`(例如 `nodeStrength` / `nodeSize`) +- 边相关字段通常使用 `edge`(例如 `edgeStrength` / `edgeLabelOffset`) +- combo 相关字段通常使用 `combo` + +### 方案 2:把自定义计算结果“提前算好”放进数据里 + +例如你原本想写: + +```ts +nodeSize: (node) => node.data.size; +``` + +可以改为在业务数据里把 `size` 填好,直接传 `nodeSize: 'node.data.size'` 或者不传(让布局从 data 读取)。 + +### 方案 3:需要回调就不要开 Worker + +以下类型的能力天然需要函数回调,Worker 模式下无法传入: + +- `onTick` 这类过程回调 +- `node` / `edge` 数据映射函数 +- 任何只有函数形态的自定义逻辑(未提供 `Expr` 字段的场景) + +此时建议把 `enableWorker` 关掉,或者只在主线程跑布局。 diff --git a/src/algorithm/antv-dagre/types.ts b/src/algorithm/antv-dagre/types.ts index 568828c7..bbebf94c 100644 --- a/src/algorithm/antv-dagre/types.ts +++ b/src/algorithm/antv-dagre/types.ts @@ -1,4 +1,4 @@ -import { Expr, ID, NodeData, Point } from '../../types'; +import type { Expr, ID, NodeData, Point } from '../../types'; import { BaseLayoutOptions } from '../base-layout'; export type DagreRankdir = diff --git a/src/algorithm/d3-force-3d/types.ts b/src/algorithm/d3-force-3d/types.ts index bf13794d..640462b5 100644 --- a/src/algorithm/d3-force-3d/types.ts +++ b/src/algorithm/d3-force-3d/types.ts @@ -1,4 +1,4 @@ -import { Expr } from '../../types'; +import type { Expr } from '../../types'; import type { D3ForceCommonOptions, EdgeDatum as _EdgeDatum, diff --git a/src/algorithm/dagre/types.ts b/src/algorithm/dagre/types.ts index 36f07de6..d08fda2c 100644 --- a/src/algorithm/dagre/types.ts +++ b/src/algorithm/dagre/types.ts @@ -1,5 +1,5 @@ import type { GraphLabel } from 'dagre'; -import type { EdgeData, Size } from '../../types'; +import type { EdgeData, Expr, Size } from '../../types'; import type { EdgeLabelPos } from '../../types/edge-label'; import type { BaseLayoutOptions } from '../types'; @@ -44,33 +44,33 @@ export interface DagreLayoutOptions extends BaseLayoutOptions, GraphLabel { * Sets minimum number of layers an edge spans; larger values create more distance between nodes, controlling layout compactness * @defaultValue 1 */ - edgeMinLen?: number | ((edge: EdgeData) => number); + edgeMinLen?: number | Expr | ((edge: EdgeData) => number); /** * 边的权重,影响边的长度优化优先级,权重大的边倾向于更短 * * Edge weight affecting length optimization priority; higher weight edges tend to be shorter */ - edgeWeight?: number | ((edge: EdgeData) => number); + edgeWeight?: number | Expr | ((edge: EdgeData) => number); /** * 边标签的尺寸,用于为标签预留空间,避免与节点重叠 * * Size of edge labels for reserving space to prevent overlap with nodes */ - edgeLabelSize?: Size | ((edge: EdgeData) => Size); + edgeLabelSize?: Size | Expr | ((edge: EdgeData) => Size); /** * 标签在边上的位置,控制标签相对于边的对齐方式 * * Label position on edge, controlling label alignment relative to the edge */ - edgeLabelPos?: EdgeLabelPos | ((edge: EdgeData) => EdgeLabelPos); + edgeLabelPos?: EdgeLabelPos | Expr | ((edge: EdgeData) => EdgeLabelPos); /** * 标签与边的偏移距离,用于微调标签位置避免视觉重叠 * * Offset distance between label and edge for fine-tuning label position to avoid visual overlap */ - edgeLabelOffset?: number | ((edge: EdgeData) => number); + edgeLabelOffset?: number | Expr | ((edge: EdgeData) => number); } diff --git a/src/types/common.ts b/src/types/common.ts index 9e7d76bb..17bed563 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -2,6 +2,13 @@ export type PlainObject = Record; export type Matrix = number[][]; +/** + * String expression evaluated by `@antv/expr`. + * + * Notes: + * - `Expr` is structured-cloneable and can be passed into WebWorkers. + * - Function callbacks cannot be structured-cloned; prefer `Expr` when `enableWorker: true`. + */ export type Expr = string; /** @@ -9,6 +16,10 @@ export type Expr = string; * * => 'node.degree' | (node: NodeData) => number */ -export type CallableExpr = Expr | ((data: T) => any); +export type CallableExpr = + | Expr + | ((data: TData) => TResult); + +export type ExprContext = Record; export type Sorter = (a: T, b: T) => -1 | 0 | 1; diff --git a/src/util/expr.ts b/src/util/expr.ts index c5f91d88..bb09f440 100644 --- a/src/util/expr.ts +++ b/src/util/expr.ts @@ -1,5 +1,5 @@ import { compile, evaluate } from '@antv/expr'; -import type { Context } from '@antv/expr/dist/interpreter'; +import type { ExprContext } from '../types'; /** * Evaluate an expression if (and only if) it's a valid string expression. @@ -10,8 +10,8 @@ import type { Context } from '@antv/expr/dist/interpreter'; */ export function evaluateExpression( expression: unknown, - context: Context, -): Function | undefined { + context: ExprContext, +): unknown | undefined { if (typeof expression !== 'string') return undefined; const source = expression.trim(); From 2a730f3ed21c3aedb7f4bc38104726288a492303 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Mon, 12 Jan 2026 18:00:14 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BE=B9=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E4=BD=8D=E7=BD=AE=E5=87=BD=E6=95=B0=E7=9A=84=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/algorithm/dagre/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/algorithm/dagre/index.ts b/src/algorithm/dagre/index.ts index e3aa49f2..e4d0ec7b 100644 --- a/src/algorithm/dagre/index.ts +++ b/src/algorithm/dagre/index.ts @@ -100,7 +100,10 @@ export class DagreLayout extends BaseLayout { const edgeLabelSizeFn = formatSizeFn(edgeLabelSize, 0, 'edge'); const edgeLabelOffsetFn = formatNumberFn(edgeLabelOffset, 10, 'edge'); - const edgeLabelPosFn = formatFn(edgeLabelPos, ['edge']); + const edgeLabelPosFn = + typeof edgeLabelPos === 'string' + ? () => edgeLabelPos + : formatFn(edgeLabelPos, ['edge']); const edgeMinLenFn = formatNumberFn(edgeMinLen, 1, 'edge'); const edgeWeightFn = formatNumberFn(edgeWeight, 1, 'edge'); From cf9dda2465431cad191a471a2fd59d92730d0d42 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Mon, 12 Jan 2026 18:00:27 +0800 Subject: [PATCH 6/8] docs: add webworker docs --- site/docs/en/guide/start/webworker.mdx | 61 ++++++++++++++++++++------ site/docs/zh/guide/start/webworker.mdx | 61 ++++++++++++++++++++------ 2 files changed, 94 insertions(+), 28 deletions(-) diff --git a/site/docs/en/guide/start/webworker.mdx b/site/docs/en/guide/start/webworker.mdx index 99d9f04d..8934e380 100644 --- a/site/docs/en/guide/start/webworker.mdx +++ b/site/docs/en/guide/start/webworker.mdx @@ -2,19 +2,32 @@ import { Badge } from '@theme'; # Using WebWorker -When `enableWorker: true` and `Worker` is available, layout computation runs in a WebWorker to reduce main-thread blocking. +WebWorker is a browser capability that runs JavaScript in a **separate thread**, helping you avoid main-thread jank caused by heavy computations. -However, if you pass **function callbacks** in options (e.g. `(node) => ...`), Worker mode may throw errors like: +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. -```txt -DataCloneError: Failed to execute 'postMessage' on 'Worker': function could not be cloned +## 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); ``` -## Why it happens +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. -The library transfers `data/options` between the main thread and the Worker via structured clone. **Functions are not structured-cloneable**, so any function inside options can break the transfer. +## Limitations in Worker mode -## How to fix +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 @@ -27,19 +40,39 @@ import { ForceLayout } from '@antv/layout'; const layout = new ForceLayout({ enableWorker: true, - nodeStrength: 'node.data.strength ?? -30', - edgeStrength: 'edge.data.weight ?? 0.1', - nodeSize: 'node.data.size ?? 10', + // 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 depend on the field: +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: -- Node fields usually use `node` -- Edge fields usually use `edge` -- Combo fields usually use `combo` +- 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 diff --git a/site/docs/zh/guide/start/webworker.mdx b/site/docs/zh/guide/start/webworker.mdx index 069bee39..db4e8fef 100644 --- a/site/docs/zh/guide/start/webworker.mdx +++ b/site/docs/zh/guide/start/webworker.mdx @@ -2,19 +2,32 @@ import { Badge } from '@theme'; # 使用 WebWorker -当你配置 `enableWorker: true` 且运行环境支持 `Worker` 时,布局计算会在 Worker 中执行,从而减少主线程阻塞。 +WebWorker 是浏览器提供的一种并行能力:它可以在**独立线程**里执行 JavaScript,从而避免在主线程做大量计算导致的卡顿。 -但如果你在配置项里传入了**回调函数**(例如 `(node) => ...`),在 Worker 模式下可能会直接报错,常见报错类似: +在 `@antv/layout` 中,当你配置 `enableWorker: true` 且运行环境支持 `Worker` 时,布局计算会优先在 Worker 中执行,从而减少主线程阻塞;当 Worker 不可用或加载失败时,会自动回退到主线程。 -```txt -DataCloneError: Failed to execute 'postMessage' on 'Worker': function could not be cloned +## 如何开启 + +只需要在布局配置中设置 `enableWorker: true`: + +```ts +import { ForceAtlas2Layout } from '@antv/layout'; + +const layout = new ForceAtlas2Layout({ enableWorker: true }); +await layout.execute(data); ``` -## 为什么会报错 +如果你通过 CDN 使用 UMD 构建,请确保 `dist/worker.js` 与 `dist/index.min.js` 位于同一目录且可被访问(Worker 脚本地址会自动从 `index.js/index.min.js` 推导为同目录下的 `worker.js`);更多说明见安装文档的 “Worker 注意事项”。 -库在主线程与 Worker 之间传递 `data/options` 时依赖结构化克隆(structured clone)。**函数无法被结构化克隆**,因此当 options 中包含函数时,就会触发 `postMessage`/Comlink 的传输错误。 +## Worker 模式的限制 -## 怎么解决 +Worker 模式下,主线程与 Worker 之间传递 `data/options` 依赖结构化克隆(structured clone)。**函数无法被结构化克隆**,因此当 options 中包含函数回调时,会触发 `postMessage`/Comlink 的传输错误,常见报错类似: + +```txt +DataCloneError: Failed to execute 'postMessage' on 'Worker': function could not be cloned +``` + +下面给出几种常用的解决方式。 ### 方案 1:用 `Expr`(字符串表达式)替代回调 @@ -27,19 +40,39 @@ import { ForceLayout } from '@antv/layout'; const layout = new ForceLayout({ enableWorker: true, - nodeStrength: 'node.data.strength ?? -30', - edgeStrength: 'edge.data.weight ?? 0.1', - nodeSize: 'node.data.size ?? 10', + // Expr 仅支持“表达式”,不支持 ?? / ?. / 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); ``` -表达式里的变量名取决于字段类型: +表达式里的变量名**从类型定义里的回调参数命名来取**(也就是:如果该字段原本支持函数回调 `(node) => ...`,那么对应的 `Expr` 里就用 `node` 这个变量名): + +- `(node) => ...`:使用 `node`(例如 `nodeStrength` / `nodeSize`) +- `(edge) => ...`:使用 `edge`(例如 `edgeStrength`) +- `(edge, source, target) => ...`:使用 `edge` / `source` / `target`(例如 `linkDistance`) +- combo 回调参数若命名为 `combo`,则使用 `combo` + +#### `Expr` 写法提示 + +`Expr` 语法与 `@antv/expr` 一致(可参考:https://github.com/antvis/expr),常用写法: -- 节点相关字段通常使用 `node`(例如 `nodeStrength` / `nodeSize`) -- 边相关字段通常使用 `edge`(例如 `edgeStrength` / `edgeLabelOffset`) -- combo 相关字段通常使用 `combo` +- 访问字段:`node.data.size`、`edge.source`、`combo.id`、`items[0]` +- 运算/逻辑:`a + b * c`、`a > 10 && b < 5`、`!flag` +- 条件表达式:`node.data.type === "big" ? 20 : 10` +- 内置函数(以 `@` 开头):`@max(a, b)`、`@min(a, b)`、`@sqrt(x)`(内置:`abs/ceil/floor/round/sqrt/pow/max/min`) + +注意:`@antv/expr` 不支持 `??`、`?.`、`undefined` 字面量、`==/!=`;建议用 `?:` / `&&` / `||` 组合实现兜底逻辑,例如: + +```ts +// 当 size 为 0 时也视为有效值 +nodeSize: 'node.data.size === 0 || node.data.size ? node.data.size : 10' +``` ### 方案 2:把自定义计算结果“提前算好”放进数据里 From 985cae24831f9aedd5f5c67ca4b695f2e872179a Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Tue, 13 Jan 2026 10:25:22 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E8=BE=B9=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E4=BD=8D=E7=BD=AE=E7=9A=84=20SVG=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=92=8C=E5=9D=90=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/snapshots/dagre/edge-labels.svg | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/__tests__/snapshots/dagre/edge-labels.svg b/__tests__/snapshots/dagre/edge-labels.svg index bf548abe..84c9b891 100644 --- a/__tests__/snapshots/dagre/edge-labels.svg +++ b/__tests__/snapshots/dagre/edge-labels.svg @@ -5,7 +5,7 @@ - + @@ -29,13 +29,13 @@ - + - + - + @@ -49,7 +49,7 @@ 1 - + 2 @@ -91,7 +91,7 @@ 8 - + 9 @@ -101,7 +101,7 @@ - + @@ -125,13 +125,13 @@ - + - + - + @@ -145,7 +145,7 @@ 1 - + 2 @@ -187,7 +187,7 @@ 8 - + 9 From 8aa1ad251608af7cc1068898640accc6cee19ec5 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Tue, 13 Jan 2026 10:27:00 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E8=87=B3=202.0.0-beta.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9d3d8ca9..ce91d76c 100644 --- a/package.json +++ b/package.json @@ -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",