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__/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/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/__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/package.json b/package.json index 2dc025ef..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", @@ -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/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..8934e380 --- /dev/null +++ b/site/docs/en/guide/start/webworker.mdx @@ -0,0 +1,95 @@ +import { Badge } from '@theme'; + +# Using WebWorker + +WebWorker is a browser capability that runs JavaScript in a **separate thread**, helping you avoid main-thread jank caused by heavy computations. + +In `@antv/layout`, when `enableWorker: true` is set and `Worker` is available, layout computation runs in a Worker to reduce main-thread blocking. If the Worker is unavailable or fails to load, it automatically falls back to the main thread. + +## How to enable + +Just set `enableWorker: true` in layout options: + +```ts +import { ForceAtlas2Layout } from '@antv/layout'; + +const layout = new ForceAtlas2Layout({ enableWorker: true }); +await layout.execute(data); +``` + +If you use the UMD build via CDN, make sure `dist/worker.js` is accessible and lives alongside `dist/index.min.js` (the worker URL is derived from `index.js/index.min.js` and points to `worker.js` in the same directory). See “Worker notes” in the Installation page for details. + +## Limitations in Worker mode + +In Worker mode, the library transfers `data/options` between the main thread and the Worker via structured clone. **Functions are not structured-cloneable**, so function callbacks inside options can break the transfer, typically with: + +```txt +DataCloneError: Failed to execute 'postMessage' on 'Worker': function could not be cloned +``` + +Below are common ways to address it. + +### Option 1: Use `Expr` (string expressions) instead of callbacks + +`@antv/layout` supports `Expr` (powered by `@antv/expr`) for many numeric/size fields. If a field’s type includes `Expr`, you can pass a string expression instead of a function. + +Example (ForceLayout): + +```ts +import { ForceLayout } from '@antv/layout'; + +const layout = new ForceLayout({ + enableWorker: true, + // Expr supports expressions only; it does NOT support ?? / ?. / undefined / == / != + nodeStrength: + 'node.data.strength === 0 || node.data.strength ? node.data.strength : -30', + edgeStrength: + 'edge.data.weight === 0 || edge.data.weight ? edge.data.weight : 0.1', + nodeSize: 'node.data.size === 0 || node.data.size ? node.data.size : 10', +}); + +await layout.execute(data); +``` + +Expression variable names are **taken from the callback parameter names in the type definitions** (i.e. if the field originally supports a function like `(node) => ...`, then your `Expr` should use `node`): + +- `(node) => ...`: use `node` (e.g. `nodeStrength` / `nodeSize`) +- `(edge) => ...`: use `edge` (e.g. `edgeStrength`) +- `(edge, source, target) => ...`: use `edge` / `source` / `target` (e.g. `linkDistance`) +- If the combo callback parameter is named `combo`, use `combo` + +#### `Expr` syntax tips + +`Expr` follows `@antv/expr` syntax (see: https://github.com/antvis/expr). Common patterns: + +- Property access: `node.data.size`, `edge.source`, `combo.id`, `items[0]` +- Arithmetic / logic: `a + b * c`, `a > 10 && b < 5`, `!flag` +- Ternary: `node.data.type === "big" ? 20 : 10` +- Built-in functions (prefixed with `@`): `@max(a, b)`, `@min(a, b)`, `@sqrt(x)` (built-ins: `abs/ceil/floor/round/sqrt/pow/max/min`) + +Notes: `@antv/expr` does not support `??`, `?.`, `undefined` literals, or `==/!=`. For fallbacks, prefer `?:` / `&&` / `||`, for example: + +```ts +// Treat 0 as a valid value +nodeSize: 'node.data.size === 0 || node.data.size ? node.data.size : 10' +``` + +### Option 2: Pre-compute values into your data + +If you used to do: + +```ts +nodeSize: (node) => node.data.size +``` + +You can pre-fill `data.nodes[i].data.size` and then use `nodeSize: 'node.data.size'` (or rely on defaults). + +### Option 3: If you need callbacks, don’t enable Worker + +Some capabilities inherently require functions and cannot be passed into Workers: + +- `onTick`-style callbacks +- `node` / `edge` mapping functions +- Any custom logic without an `Expr` alternative + +In these cases, run the layout on the main thread by keeping `enableWorker` off. 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..db4e8fef --- /dev/null +++ b/site/docs/zh/guide/start/webworker.mdx @@ -0,0 +1,95 @@ +import { Badge } from '@theme'; + +# 使用 WebWorker + +WebWorker 是浏览器提供的一种并行能力:它可以在**独立线程**里执行 JavaScript,从而避免在主线程做大量计算导致的卡顿。 + +在 `@antv/layout` 中,当你配置 `enableWorker: true` 且运行环境支持 `Worker` 时,布局计算会优先在 Worker 中执行,从而减少主线程阻塞;当 Worker 不可用或加载失败时,会自动回退到主线程。 + +## 如何开启 + +只需要在布局配置中设置 `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 模式的限制 + +Worker 模式下,主线程与 Worker 之间传递 `data/options` 依赖结构化克隆(structured clone)。**函数无法被结构化克隆**,因此当 options 中包含函数回调时,会触发 `postMessage`/Comlink 的传输错误,常见报错类似: + +```txt +DataCloneError: Failed to execute 'postMessage' on 'Worker': function could not be cloned +``` + +下面给出几种常用的解决方式。 + +### 方案 1:用 `Expr`(字符串表达式)替代回调 + +`@antv/layout` 内置了基于 `@antv/expr` 的表达式能力:对于类型里标注为 `Expr` 的字段,你可以传入字符串表达式来代替函数。 + +示例(ForceLayout): + +```ts +import { ForceLayout } from '@antv/layout'; + +const layout = new ForceLayout({ + enableWorker: true, + // 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.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:把自定义计算结果“提前算好”放进数据里 + +例如你原本想写: + +```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/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..bbebf94c 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 type { 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..27b1af72 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) => edge.id!, }, manyBody: {}, center: { diff --git a/src/algorithm/d3-force-3d/types.ts b/src/algorithm/d3-force-3d/types.ts index d2d8f515..640462b5 100644 --- a/src/algorithm/d3-force-3d/types.ts +++ b/src/algorithm/d3-force-3d/types.ts @@ -1,3 +1,4 @@ +import type { 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..c2d2b197 100644 --- a/src/algorithm/d3-force/index.ts +++ b/src/algorithm/d3-force/index.ts @@ -12,7 +12,7 @@ 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 { BaseLayoutWithIterations } from '../base-layout'; import forceInABox from './force-in-a-box'; import type { @@ -25,9 +25,7 @@ import type { export type { D3ForceLayoutOptions }; const DEFAULTS_LAYOUT_OPTIONS: Partial = { - link: { - id: (d) => String(d.id), - }, + edgeId: 'edge.id', manyBody: { strength: -30, @@ -325,7 +323,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 +362,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, }); } @@ -401,14 +405,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, @@ -524,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); } @@ -567,7 +574,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 83a0e4f2..4f3981a0 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); /** * 碰撞力 * @@ -360,9 +345,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..e4d0ec7b 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,14 @@ export class DagreLayout extends BaseLayout { edgeWeight, } = this.options; - const edgeLabelSizeFn = formatSizeFn(edgeLabelSize, 0); - const edgeLabelOffsetFn = formatNumberFn(edgeLabelOffset, 10); + const edgeLabelSizeFn = formatSizeFn(edgeLabelSize, 0, 'edge'); + const edgeLabelOffsetFn = formatNumberFn(edgeLabelOffset, 10, 'edge'); const edgeLabelPosFn = - typeof edgeLabelPos === 'function' ? edgeLabelPos : () => edgeLabelPos; - const edgeMinLenFn = formatNumberFn(edgeMinLen, 1); - const edgeWeightFn = formatNumberFn(edgeWeight, 1); + typeof edgeLabelPos === 'string' + ? () => edgeLabelPos + : 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..d08fda2c 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, Expr, 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 | Expr | ((edge: EdgeData) => number); /** * 边的权重,影响边的长度优化优先级,权重大的边倾向于更短 * * Edge weight affecting length optimization priority; higher weight edges tend to be shorter */ - edgeWeight?: number | ((d?: NodeData) => number); + edgeWeight?: number | Expr | ((edge: EdgeData) => number); /** * 边标签的尺寸,用于为标签预留空间,避免与节点重叠 * * Size of edge labels for reserving space to prevent overlap with nodes */ - edgeLabelSize?: Size | ((d?: NodeData) => Size); + edgeLabelSize?: Size | Expr | ((edge: EdgeData) => Size); /** * 标签在边上的位置,控制标签相对于边的对齐方式 * * Label position on edge, controlling label alignment relative to the edge */ - edgeLabelPos?: EdgeLabelPos | ((d?: NodeData) => EdgeLabelPos); + edgeLabelPos?: EdgeLabelPos | Expr | ((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 | Expr | ((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..17bed563 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,3 +1,25 @@ 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; + +/** + * CallableExpr<(node: NodeData) => number> + * + * => 'node.degree' | (node: NodeData) => number + */ +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/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..bb09f440 --- /dev/null +++ b/src/util/expr.ts @@ -0,0 +1,26 @@ +import { compile, evaluate } from '@antv/expr'; +import type { ExprContext } from '../types'; + +/** + * 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: unknown, + context: ExprContext, +): unknown | undefined { + if (typeof expression !== 'string') return 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..abb532d7 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 (isNil(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; +}