@graph-render/core 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +6 -0
- package/CHANGELOG.md +45 -0
- package/dist/edges/collision.d.ts +8 -0
- package/dist/edges/collision.d.ts.map +1 -0
- package/dist/edges/collision.js +26 -0
- package/dist/edges/collision.js.map +1 -0
- package/dist/edges/geometry.d.ts +22 -0
- package/dist/edges/geometry.d.ts.map +1 -0
- package/dist/edges/geometry.js +75 -0
- package/dist/edges/geometry.js.map +1 -0
- package/dist/edges/index.d.ts +4 -0
- package/dist/edges/index.d.ts.map +1 -0
- package/dist/edges/index.js +4 -0
- package/dist/edges/index.js.map +1 -0
- package/dist/edges/pathBuilder.d.ts +11 -0
- package/dist/edges/pathBuilder.d.ts.map +1 -0
- package/dist/edges/pathBuilder.js +114 -0
- package/dist/edges/pathBuilder.js.map +1 -0
- package/dist/edges/pathCalculation.d.ts +14 -0
- package/dist/edges/pathCalculation.d.ts.map +1 -0
- package/dist/edges/pathCalculation.js +47 -0
- package/dist/edges/pathCalculation.js.map +1 -0
- package/dist/edges/routing.d.ts +6 -0
- package/dist/edges/routing.d.ts.map +1 -0
- package/dist/edges/routing.js +243 -0
- package/dist/edges/routing.js.map +1 -0
- package/dist/edges/sideSelection.d.ts +17 -0
- package/dist/edges/sideSelection.d.ts.map +1 -0
- package/dist/edges/sideSelection.js +45 -0
- package/dist/edges/sideSelection.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/layouts/centered.d.ts +6 -0
- package/dist/layouts/centered.d.ts.map +1 -0
- package/dist/layouts/centered.js +69 -0
- package/dist/layouts/centered.js.map +1 -0
- package/dist/layouts/compactBracket.d.ts +3 -0
- package/dist/layouts/compactBracket.d.ts.map +1 -0
- package/dist/layouts/compactBracket.js +7 -0
- package/dist/layouts/compactBracket.js.map +1 -0
- package/dist/layouts/dag.d.ts +3 -0
- package/dist/layouts/dag.d.ts.map +1 -0
- package/dist/layouts/dag.js +52 -0
- package/dist/layouts/dag.js.map +1 -0
- package/dist/layouts/forceDirected.d.ts +3 -0
- package/dist/layouts/forceDirected.d.ts.map +1 -0
- package/dist/layouts/forceDirected.js +176 -0
- package/dist/layouts/forceDirected.js.map +1 -0
- package/dist/layouts/grid.d.ts +6 -0
- package/dist/layouts/grid.d.ts.map +1 -0
- package/dist/layouts/grid.js +34 -0
- package/dist/layouts/grid.js.map +1 -0
- package/dist/layouts/index.d.ts +12 -0
- package/dist/layouts/index.d.ts.map +1 -0
- package/dist/layouts/index.js +88 -0
- package/dist/layouts/index.js.map +1 -0
- package/dist/layouts/orthogonalFlow.d.ts +3 -0
- package/dist/layouts/orthogonalFlow.d.ts.map +1 -0
- package/dist/layouts/orthogonalFlow.js +81 -0
- package/dist/layouts/orthogonalFlow.js.map +1 -0
- package/dist/layouts/radialTree.d.ts +3 -0
- package/dist/layouts/radialTree.d.ts.map +1 -0
- package/dist/layouts/radialTree.js +45 -0
- package/dist/layouts/radialTree.js.map +1 -0
- package/dist/layouts/tree.d.ts +6 -0
- package/dist/layouts/tree.d.ts.map +1 -0
- package/dist/layouts/tree.js +19 -0
- package/dist/layouts/tree.js.map +1 -0
- package/dist/layouts/treeAlignment.d.ts +10 -0
- package/dist/layouts/treeAlignment.d.ts.map +1 -0
- package/dist/layouts/treeAlignment.js +69 -0
- package/dist/layouts/treeAlignment.js.map +1 -0
- package/dist/layouts/treePositioning.d.ts +14 -0
- package/dist/layouts/treePositioning.d.ts.map +1 -0
- package/dist/layouts/treePositioning.js +30 -0
- package/dist/layouts/treePositioning.js.map +1 -0
- package/dist/layouts/treeTopology.d.ts +29 -0
- package/dist/layouts/treeTopology.d.ts.map +1 -0
- package/dist/layouts/treeTopology.js +137 -0
- package/dist/layouts/treeTopology.js.map +1 -0
- package/dist/rendering/defaultRenderers.d.ts +10 -0
- package/dist/rendering/defaultRenderers.d.ts.map +1 -0
- package/dist/rendering/defaultRenderers.js +85 -0
- package/dist/rendering/defaultRenderers.js.map +1 -0
- package/dist/rendering/index.d.ts +4 -0
- package/dist/rendering/index.d.ts.map +1 -0
- package/dist/rendering/index.js +4 -0
- package/dist/rendering/index.js.map +1 -0
- package/dist/rendering/svg.d.ts +7 -0
- package/dist/rendering/svg.d.ts.map +1 -0
- package/dist/rendering/svg.js +256 -0
- package/dist/rendering/svg.js.map +1 -0
- package/dist/rendering/utils.d.ts +5 -0
- package/dist/rendering/utils.d.ts.map +1 -0
- package/dist/rendering/utils.js +33 -0
- package/dist/rendering/utils.js.map +1 -0
- package/dist/utils/config.d.ts +36 -0
- package/dist/utils/config.d.ts.map +1 -0
- package/dist/utils/config.js +115 -0
- package/dist/utils/config.js.map +1 -0
- package/dist/utils/constants.d.ts +15 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/constants.js +19 -0
- package/dist/utils/constants.js.map +1 -0
- package/dist/utils/graphParser.d.ts +16 -0
- package/dist/utils/graphParser.d.ts.map +1 -0
- package/dist/utils/graphParser.js +277 -0
- package/dist/utils/graphParser.js.map +1 -0
- package/dist/utils/graphTraversal.d.ts +13 -0
- package/dist/utils/graphTraversal.d.ts.map +1 -0
- package/dist/utils/graphTraversal.js +28 -0
- package/dist/utils/graphTraversal.js.map +1 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +7 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/nodeMetrics.d.ts +8 -0
- package/dist/utils/nodeMetrics.d.ts.map +1 -0
- package/dist/utils/nodeMetrics.js +12 -0
- package/dist/utils/nodeMetrics.js.map +1 -0
- package/dist/utils/nodeSizing.d.ts +3 -0
- package/dist/utils/nodeSizing.d.ts.map +1 -0
- package/dist/utils/nodeSizing.js +77 -0
- package/dist/utils/nodeSizing.js.map +1 -0
- package/package.json +29 -0
- package/project.json +32 -0
- package/src/edges/collision.ts +31 -0
- package/src/edges/geometry.ts +85 -0
- package/src/edges/index.ts +3 -0
- package/src/edges/pathBuilder.ts +136 -0
- package/src/edges/pathCalculation.ts +69 -0
- package/src/edges/routing.ts +459 -0
- package/src/edges/sideSelection.ts +67 -0
- package/src/index.ts +50 -0
- package/src/layouts/centered.ts +114 -0
- package/src/layouts/compactBracket.ts +14 -0
- package/src/layouts/dag.ts +76 -0
- package/src/layouts/forceDirected.ts +224 -0
- package/src/layouts/grid.ts +50 -0
- package/src/layouts/index.ts +148 -0
- package/src/layouts/orthogonalFlow.ts +112 -0
- package/src/layouts/radialTree.ts +77 -0
- package/src/layouts/tree.ts +35 -0
- package/src/layouts/treeAlignment.ts +107 -0
- package/src/layouts/treePositioning.ts +55 -0
- package/src/layouts/treeTopology.ts +184 -0
- package/src/rendering/defaultRenderers.ts +110 -0
- package/src/rendering/index.ts +3 -0
- package/src/rendering/svg.ts +346 -0
- package/src/rendering/utils.ts +41 -0
- package/src/utils/config.ts +198 -0
- package/src/utils/constants.ts +24 -0
- package/src/utils/graphParser.ts +495 -0
- package/src/utils/graphTraversal.ts +32 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/nodeMetrics.ts +23 -0
- package/src/utils/nodeSizing.ts +97 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { EdgeData, LayoutDirection, NodeData, PositionedNode } from '@graph-render/types';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_NODE_GAP,
|
|
4
|
+
DEFAULT_NODE_SIZE,
|
|
5
|
+
DEFAULT_PADDING,
|
|
6
|
+
getMaxNodeDimensions,
|
|
7
|
+
} from '../utils';
|
|
8
|
+
import { assignDagLevels } from './treeTopology';
|
|
9
|
+
|
|
10
|
+
const groupNodesByLayer = (nodes: NodeData[], levels: Map<string, number>): NodeData[][] => {
|
|
11
|
+
const buckets = new Map<number, NodeData[]>();
|
|
12
|
+
nodes.forEach((node) => {
|
|
13
|
+
const level = levels.get(node.id) ?? 0;
|
|
14
|
+
const entries = buckets.get(level) ?? [];
|
|
15
|
+
entries.push(node);
|
|
16
|
+
buckets.set(level, entries);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return Array.from(buckets.entries())
|
|
20
|
+
.sort((a, b) => a[0] - b[0])
|
|
21
|
+
.map(([, entries]) => entries);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const dagLayout = (
|
|
25
|
+
nodes: NodeData[],
|
|
26
|
+
edges: EdgeData[],
|
|
27
|
+
pad: number = DEFAULT_PADDING,
|
|
28
|
+
gap: number = DEFAULT_NODE_GAP,
|
|
29
|
+
direction: LayoutDirection = LayoutDirection.LTR,
|
|
30
|
+
width: number = 960,
|
|
31
|
+
height: number = 720
|
|
32
|
+
): PositionedNode[] => {
|
|
33
|
+
if (!nodes.length) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { levels } = assignDagLevels(nodes, edges);
|
|
38
|
+
const layers = groupNodesByLayer(nodes, levels);
|
|
39
|
+
const { maxWidth: maxNodeWidth, maxHeight: maxNodeHeight } = getMaxNodeDimensions(nodes);
|
|
40
|
+
const columnGap = maxNodeWidth + gap;
|
|
41
|
+
const rowGap = maxNodeHeight + Math.max(24, gap * 0.7);
|
|
42
|
+
const contentWidth = Math.max(width - pad * 2, columnGap * Math.max(layers.length - 1, 1));
|
|
43
|
+
const baseX = direction === LayoutDirection.RTL ? width - pad - maxNodeWidth : pad;
|
|
44
|
+
const stepX = direction === LayoutDirection.RTL ? -columnGap : columnGap;
|
|
45
|
+
|
|
46
|
+
return layers.flatMap((layer, layerIndex) => {
|
|
47
|
+
const totalHeight = layer.reduce(
|
|
48
|
+
(sum, node, index) =>
|
|
49
|
+
sum +
|
|
50
|
+
(node.size?.height ?? DEFAULT_NODE_SIZE.height) +
|
|
51
|
+
(index > 0 ? rowGap - maxNodeHeight : 0),
|
|
52
|
+
0
|
|
53
|
+
);
|
|
54
|
+
let y = Math.max(pad, (height - totalHeight) / 2);
|
|
55
|
+
|
|
56
|
+
return layer.map((node, nodeIndex) => {
|
|
57
|
+
const nodeWidth = node.size?.width ?? DEFAULT_NODE_SIZE.width;
|
|
58
|
+
const x =
|
|
59
|
+
baseX +
|
|
60
|
+
layerIndex * stepX +
|
|
61
|
+
(direction === LayoutDirection.RTL ? maxNodeWidth - nodeWidth : 0);
|
|
62
|
+
const positioned = {
|
|
63
|
+
...node,
|
|
64
|
+
position: {
|
|
65
|
+
x: layers.length > 1 ? x : pad + Math.max(0, (contentWidth - nodeWidth) / 2),
|
|
66
|
+
y,
|
|
67
|
+
},
|
|
68
|
+
} as PositionedNode;
|
|
69
|
+
|
|
70
|
+
y +=
|
|
71
|
+
(node.size?.height ?? DEFAULT_NODE_SIZE.height) +
|
|
72
|
+
(nodeIndex < layer.length - 1 ? rowGap - maxNodeHeight : 0);
|
|
73
|
+
return positioned;
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { EdgeData, NodeData, Point, PositionedNode } from '@graph-render/types';
|
|
2
|
+
import { DEFAULT_NODE_GAP, DEFAULT_NODE_SIZE, DEFAULT_PADDING } from '../utils';
|
|
3
|
+
import { gridLayout } from './grid';
|
|
4
|
+
|
|
5
|
+
const FORCE_LAYOUT_CACHE_LIMIT = 24;
|
|
6
|
+
const MAX_SYNC_FORCE_NODES = 250;
|
|
7
|
+
// NOTE: this cache is intentionally module-level so warm hits persist across
|
|
8
|
+
// sequential renders of the same graph (common during viewport-only updates).
|
|
9
|
+
// Trade-off: all <Graph> instances in the same JS bundle share the same 24-slot
|
|
10
|
+
// LRU. Keys include the full node/edge topology, so stale hits are extremely
|
|
11
|
+
// unlikely. If you mount many independent graphs with similar-but-distinct
|
|
12
|
+
// topologies and see layout lag, increase FORCE_LAYOUT_CACHE_LIMIT.
|
|
13
|
+
const forceLayoutCache = new Map<string, PositionedNode[]>();
|
|
14
|
+
|
|
15
|
+
const buildForceLayoutCacheKey = (
|
|
16
|
+
nodes: NodeData[],
|
|
17
|
+
edges: EdgeData[],
|
|
18
|
+
pad: number,
|
|
19
|
+
width: number,
|
|
20
|
+
height: number,
|
|
21
|
+
gap: number
|
|
22
|
+
): string | null => {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
pad,
|
|
26
|
+
width,
|
|
27
|
+
height,
|
|
28
|
+
gap,
|
|
29
|
+
nodes: nodes.map((node) => ({
|
|
30
|
+
id: node.id,
|
|
31
|
+
size: node.size,
|
|
32
|
+
label: node.label,
|
|
33
|
+
})),
|
|
34
|
+
edges: edges.map((edge) => ({
|
|
35
|
+
id: edge.id,
|
|
36
|
+
source: edge.source,
|
|
37
|
+
target: edge.target,
|
|
38
|
+
type: edge.type,
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const getCachedForceLayout = (cacheKey: string | null): PositionedNode[] | undefined => {
|
|
47
|
+
if (!cacheKey) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cached = forceLayoutCache.get(cacheKey);
|
|
52
|
+
if (!cached) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
forceLayoutCache.delete(cacheKey);
|
|
57
|
+
forceLayoutCache.set(cacheKey, cached);
|
|
58
|
+
return cached.map((node) => ({
|
|
59
|
+
...node,
|
|
60
|
+
position: { ...node.position },
|
|
61
|
+
size: node.size ? { ...node.size } : undefined,
|
|
62
|
+
}));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const setCachedForceLayout = (cacheKey: string | null, nodes: PositionedNode[]): void => {
|
|
66
|
+
if (!cacheKey) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (forceLayoutCache.size >= FORCE_LAYOUT_CACHE_LIMIT) {
|
|
71
|
+
const oldestKey = forceLayoutCache.keys().next().value;
|
|
72
|
+
if (oldestKey) {
|
|
73
|
+
forceLayoutCache.delete(oldestKey);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
forceLayoutCache.set(
|
|
78
|
+
cacheKey,
|
|
79
|
+
nodes.map((node) => ({
|
|
80
|
+
...node,
|
|
81
|
+
position: { ...node.position },
|
|
82
|
+
size: node.size ? { ...node.size } : undefined,
|
|
83
|
+
}))
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const clampPoint = (
|
|
88
|
+
point: Point,
|
|
89
|
+
width: number,
|
|
90
|
+
height: number,
|
|
91
|
+
pad: number,
|
|
92
|
+
node: NodeData
|
|
93
|
+
): Point => {
|
|
94
|
+
const nodeWidth = node.size?.width ?? DEFAULT_NODE_SIZE.width;
|
|
95
|
+
const nodeHeight = node.size?.height ?? DEFAULT_NODE_SIZE.height;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
x: Math.min(Math.max(point.x, pad), width - pad - nodeWidth),
|
|
99
|
+
y: Math.min(Math.max(point.y, pad), height - pad - nodeHeight),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const getRequiredPoint = (points: Map<string, Point>, nodeId: string): Point => {
|
|
104
|
+
const point = points.get(nodeId);
|
|
105
|
+
|
|
106
|
+
if (!point) {
|
|
107
|
+
throw new Error(`Force-directed layout could not resolve point data for node "${nodeId}".`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return point;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const forceDirectedLayout = (
|
|
114
|
+
nodes: NodeData[],
|
|
115
|
+
edges: EdgeData[],
|
|
116
|
+
pad: number = DEFAULT_PADDING,
|
|
117
|
+
width: number = 960,
|
|
118
|
+
height: number = 720,
|
|
119
|
+
gap: number = DEFAULT_NODE_GAP
|
|
120
|
+
): PositionedNode[] => {
|
|
121
|
+
if (!nodes.length) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (nodes.length > MAX_SYNC_FORCE_NODES) {
|
|
126
|
+
return gridLayout(nodes, pad, gap);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cacheKey = buildForceLayoutCacheKey(nodes, edges, pad, width, height, gap);
|
|
130
|
+
const cached = getCachedForceLayout(cacheKey);
|
|
131
|
+
if (cached) {
|
|
132
|
+
return cached;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const area = Math.max((width - pad * 2) * (height - pad * 2), 1);
|
|
136
|
+
const k = Math.sqrt(area / Math.max(nodes.length, 1));
|
|
137
|
+
const positions = new Map<string, Point>();
|
|
138
|
+
// FIX: removed the `adjacency` Map that was built here but never read by the
|
|
139
|
+
// algorithm. Repulsion iterates node pairs directly; attraction iterates the
|
|
140
|
+
// `edges` array directly. Building the map was O(e) wasted work per layout call.
|
|
141
|
+
|
|
142
|
+
nodes.forEach((node, index) => {
|
|
143
|
+
const angle = (2 * Math.PI * index) / Math.max(nodes.length, 1);
|
|
144
|
+
const radius = Math.min(width, height) * 0.25;
|
|
145
|
+
positions.set(node.id, {
|
|
146
|
+
x: width / 2 + radius * Math.cos(angle),
|
|
147
|
+
y: height / 2 + radius * Math.sin(angle),
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// FIX: removed the edges.forEach that populated `adjacency` (now deleted).
|
|
152
|
+
// The attraction-force loop below already iterates `edges` directly.
|
|
153
|
+
for (let iteration = 0; iteration < 80; iteration += 1) {
|
|
154
|
+
const displacement = new Map<string, Point>();
|
|
155
|
+
nodes.forEach((node) => displacement.set(node.id, { x: 0, y: 0 }));
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
158
|
+
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
159
|
+
const source = nodes[i];
|
|
160
|
+
const target = nodes[j];
|
|
161
|
+
const sourcePos = getRequiredPoint(positions, source.id);
|
|
162
|
+
const targetPos = getRequiredPoint(positions, target.id);
|
|
163
|
+
const dx = sourcePos.x - targetPos.x;
|
|
164
|
+
const dy = sourcePos.y - targetPos.y;
|
|
165
|
+
const distance = Math.max(1, Math.hypot(dx, dy));
|
|
166
|
+
const force = (k * k) / distance;
|
|
167
|
+
const offsetX = (dx / distance) * force;
|
|
168
|
+
const offsetY = (dy / distance) * force;
|
|
169
|
+
const sourceDisp = getRequiredPoint(displacement, source.id);
|
|
170
|
+
const targetDisp = getRequiredPoint(displacement, target.id);
|
|
171
|
+
sourceDisp.x += offsetX;
|
|
172
|
+
sourceDisp.y += offsetY;
|
|
173
|
+
targetDisp.x -= offsetX;
|
|
174
|
+
targetDisp.y -= offsetY;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
edges.forEach((edge) => {
|
|
179
|
+
const sourcePos = getRequiredPoint(positions, edge.source);
|
|
180
|
+
const targetPos = getRequiredPoint(positions, edge.target);
|
|
181
|
+
const dx = sourcePos.x - targetPos.x;
|
|
182
|
+
const dy = sourcePos.y - targetPos.y;
|
|
183
|
+
const distance = Math.max(1, Math.hypot(dx, dy));
|
|
184
|
+
const force = (distance * distance) / k;
|
|
185
|
+
const offsetX = (dx / distance) * force;
|
|
186
|
+
const offsetY = (dy / distance) * force;
|
|
187
|
+
const sourceDisp = getRequiredPoint(displacement, edge.source);
|
|
188
|
+
const targetDisp = getRequiredPoint(displacement, edge.target);
|
|
189
|
+
sourceDisp.x -= offsetX;
|
|
190
|
+
sourceDisp.y -= offsetY;
|
|
191
|
+
targetDisp.x += offsetX;
|
|
192
|
+
targetDisp.y += offsetY;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const temperature = Math.max(2, gap * (1 - iteration / 80));
|
|
196
|
+
nodes.forEach((node) => {
|
|
197
|
+
const point = getRequiredPoint(positions, node.id);
|
|
198
|
+
const disp = getRequiredPoint(displacement, node.id);
|
|
199
|
+
const magnitude = Math.max(1, Math.hypot(disp.x, disp.y));
|
|
200
|
+
const nextPoint = {
|
|
201
|
+
x: point.x + (disp.x / magnitude) * Math.min(magnitude, temperature),
|
|
202
|
+
y: point.y + (disp.y / magnitude) * Math.min(magnitude, temperature),
|
|
203
|
+
};
|
|
204
|
+
positions.set(node.id, clampPoint(nextPoint, width, height, pad, node));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const positionedNodes = nodes.map((node) => {
|
|
209
|
+
const point = getRequiredPoint(positions, node.id);
|
|
210
|
+
const size = node.size ?? DEFAULT_NODE_SIZE;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
...node,
|
|
214
|
+
position: {
|
|
215
|
+
x: point.x - size.width / 2,
|
|
216
|
+
y: point.y - size.height / 2,
|
|
217
|
+
},
|
|
218
|
+
} as PositionedNode;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
setCachedForceLayout(cacheKey, positionedNodes);
|
|
222
|
+
|
|
223
|
+
return positionedNodes;
|
|
224
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NodeData, PositionedNode, Point } from '@graph-render/types';
|
|
2
|
+
import { DEFAULT_NODE_SIZE, DEFAULT_PADDING, DEFAULT_NODE_GAP } from '../utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculate grid dimensions based on node count
|
|
6
|
+
*/
|
|
7
|
+
const calculateGridColumns = (nodeCount: number): number => {
|
|
8
|
+
return Math.ceil(Math.sqrt(nodeCount));
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Calculate grid position for a node at a given index
|
|
13
|
+
*/
|
|
14
|
+
const calculateGridPosition = (
|
|
15
|
+
index: number,
|
|
16
|
+
columns: number,
|
|
17
|
+
nodeWidth: number,
|
|
18
|
+
nodeHeight: number,
|
|
19
|
+
padding: number,
|
|
20
|
+
gap: number
|
|
21
|
+
): Point => {
|
|
22
|
+
const col = index % columns;
|
|
23
|
+
const row = Math.floor(index / columns);
|
|
24
|
+
return {
|
|
25
|
+
x: padding + col * (nodeWidth + gap),
|
|
26
|
+
y: padding + row * (nodeHeight + gap),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Layout nodes in a grid pattern
|
|
32
|
+
*/
|
|
33
|
+
export const gridLayout = (
|
|
34
|
+
nodes: NodeData[],
|
|
35
|
+
pad: number = DEFAULT_PADDING,
|
|
36
|
+
gap: number = DEFAULT_NODE_GAP
|
|
37
|
+
): PositionedNode[] => {
|
|
38
|
+
const count = nodes.length;
|
|
39
|
+
const cols = calculateGridColumns(count);
|
|
40
|
+
|
|
41
|
+
return nodes.map((node, idx) => {
|
|
42
|
+
if (node.position) return node as PositionedNode;
|
|
43
|
+
|
|
44
|
+
const nodeWidth = node.size?.width ?? DEFAULT_NODE_SIZE.width;
|
|
45
|
+
const nodeHeight = node.size?.height ?? DEFAULT_NODE_SIZE.height;
|
|
46
|
+
const position = calculateGridPosition(idx, cols, nodeWidth, nodeHeight, pad, gap);
|
|
47
|
+
|
|
48
|
+
return { ...node, position } as PositionedNode;
|
|
49
|
+
});
|
|
50
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NodeData,
|
|
3
|
+
PositionedNode,
|
|
4
|
+
LayoutOptions,
|
|
5
|
+
LayoutType,
|
|
6
|
+
LayoutDirection,
|
|
7
|
+
} from '@graph-render/types';
|
|
8
|
+
import { DEFAULT_NODE_GAP, DEFAULT_PADDING, applyNodeSizing } from '../utils';
|
|
9
|
+
import { gridLayout } from './grid';
|
|
10
|
+
import { centeredLayout } from './centered';
|
|
11
|
+
import { radialTreeLayout } from './radialTree';
|
|
12
|
+
import { treeLayout } from './tree';
|
|
13
|
+
import { dagLayout } from './dag';
|
|
14
|
+
import { forceDirectedLayout } from './forceDirected';
|
|
15
|
+
import { compactBracketLayout } from './compactBracket';
|
|
16
|
+
import { orthogonalFlowLayout } from './orthogonalFlow';
|
|
17
|
+
|
|
18
|
+
const runSelectedLayout = (options: LayoutOptions, sizedNodes: NodeData[]): PositionedNode[] => {
|
|
19
|
+
const { edges, padding, theme, layout, width, height, layoutDirection } = options;
|
|
20
|
+
const gap = theme?.nodeGap ?? DEFAULT_NODE_GAP;
|
|
21
|
+
const pad = padding ?? DEFAULT_PADDING;
|
|
22
|
+
const resolvedLayout = layout ?? LayoutType.Grid;
|
|
23
|
+
|
|
24
|
+
const assertUnreachable = (value: never): never => {
|
|
25
|
+
throw new Error(`Unsupported layout type: ${String(value)}`);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
switch (resolvedLayout) {
|
|
29
|
+
case LayoutType.Tree:
|
|
30
|
+
return treeLayout(sizedNodes, edges, pad, gap, layoutDirection ?? LayoutDirection.LTR, height);
|
|
31
|
+
case LayoutType.Radial:
|
|
32
|
+
return radialTreeLayout(sizedNodes, edges, pad, width, height, gap);
|
|
33
|
+
case LayoutType.Centered:
|
|
34
|
+
return centeredLayout(sizedNodes, pad, width, height);
|
|
35
|
+
case LayoutType.Dag:
|
|
36
|
+
return dagLayout(
|
|
37
|
+
sizedNodes,
|
|
38
|
+
edges,
|
|
39
|
+
pad,
|
|
40
|
+
gap,
|
|
41
|
+
layoutDirection ?? LayoutDirection.LTR,
|
|
42
|
+
width,
|
|
43
|
+
height
|
|
44
|
+
);
|
|
45
|
+
case LayoutType.ForceDirected:
|
|
46
|
+
return forceDirectedLayout(sizedNodes, edges, pad, width, height, gap);
|
|
47
|
+
case LayoutType.CompactBracket:
|
|
48
|
+
return compactBracketLayout(
|
|
49
|
+
sizedNodes,
|
|
50
|
+
edges,
|
|
51
|
+
pad,
|
|
52
|
+
gap,
|
|
53
|
+
layoutDirection ?? LayoutDirection.LTR,
|
|
54
|
+
height
|
|
55
|
+
);
|
|
56
|
+
case LayoutType.OrthogonalFlow:
|
|
57
|
+
return orthogonalFlowLayout(
|
|
58
|
+
sizedNodes,
|
|
59
|
+
edges,
|
|
60
|
+
pad,
|
|
61
|
+
gap,
|
|
62
|
+
layoutDirection ?? LayoutDirection.LTR,
|
|
63
|
+
width,
|
|
64
|
+
height
|
|
65
|
+
);
|
|
66
|
+
case LayoutType.Grid:
|
|
67
|
+
return gridLayout(sizedNodes, pad, gap);
|
|
68
|
+
default:
|
|
69
|
+
return assertUnreachable(resolvedLayout);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const getAnchoredLayoutOffset = (
|
|
74
|
+
autoLayout: PositionedNode[],
|
|
75
|
+
fixedNodes: PositionedNode[]
|
|
76
|
+
): { x: number; y: number } => {
|
|
77
|
+
if (!fixedNodes.length) {
|
|
78
|
+
return { x: 0, y: 0 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const positionedFixed = fixedNodes
|
|
82
|
+
.map((fixedNode) => {
|
|
83
|
+
const laidOut = autoLayout.find((node) => node.id === fixedNode.id);
|
|
84
|
+
return laidOut ? { fixedNode, laidOut } : null;
|
|
85
|
+
})
|
|
86
|
+
.filter(
|
|
87
|
+
(entry): entry is { fixedNode: PositionedNode; laidOut: PositionedNode } => entry !== null
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!positionedFixed.length) {
|
|
91
|
+
return { x: 0, y: 0 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const totals = positionedFixed.reduce(
|
|
95
|
+
(acc, entry) => ({
|
|
96
|
+
x: acc.x + (entry.fixedNode.position.x - entry.laidOut.position.x),
|
|
97
|
+
y: acc.y + (entry.fixedNode.position.y - entry.laidOut.position.y),
|
|
98
|
+
}),
|
|
99
|
+
{ x: 0, y: 0 }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
x: totals.x / positionedFixed.length,
|
|
104
|
+
y: totals.y / positionedFixed.length,
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const layoutNodes = (options: LayoutOptions): PositionedNode[] => {
|
|
109
|
+
const sizedNodes = applyNodeSizing(options.nodes, options);
|
|
110
|
+
|
|
111
|
+
const missingPositions = sizedNodes.some((node) => !node.position);
|
|
112
|
+
|
|
113
|
+
if (!missingPositions) {
|
|
114
|
+
return sizedNodes as PositionedNode[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const fixedNodes = sizedNodes.filter((node): node is PositionedNode => Boolean(node.position));
|
|
118
|
+
const autoLayoutInput = sizedNodes.map((node) => ({ ...node, position: undefined }));
|
|
119
|
+
const autoLayout = runSelectedLayout(options, autoLayoutInput);
|
|
120
|
+
const offset = getAnchoredLayoutOffset(autoLayout, fixedNodes);
|
|
121
|
+
const fixedNodeMap = new Map(fixedNodes.map((node) => [node.id, node]));
|
|
122
|
+
|
|
123
|
+
return autoLayout.map((node) => {
|
|
124
|
+
const fixedNode = fixedNodeMap.get(node.id);
|
|
125
|
+
if (fixedNode) {
|
|
126
|
+
return fixedNode;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
...node,
|
|
131
|
+
position: {
|
|
132
|
+
x: node.position.x + offset.x,
|
|
133
|
+
y: node.position.y + offset.y,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export {
|
|
140
|
+
gridLayout,
|
|
141
|
+
centeredLayout,
|
|
142
|
+
radialTreeLayout,
|
|
143
|
+
treeLayout,
|
|
144
|
+
dagLayout,
|
|
145
|
+
forceDirectedLayout,
|
|
146
|
+
compactBracketLayout,
|
|
147
|
+
orthogonalFlowLayout,
|
|
148
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { EdgeData, LayoutDirection, NodeData, PositionedNode } from '@graph-render/types';
|
|
2
|
+
import { DEFAULT_NODE_GAP, DEFAULT_NODE_SIZE, DEFAULT_PADDING } from '../utils';
|
|
3
|
+
import { assignDagLevels } from './treeTopology';
|
|
4
|
+
|
|
5
|
+
const VERTICAL_GAP_RATIO = 0.45;
|
|
6
|
+
const VERTICAL_GAP_MIN = 20;
|
|
7
|
+
const VERTICAL_GAP_HEIGHT_RATIO = 0.3;
|
|
8
|
+
const DEFAULT_WIDTH = 960;
|
|
9
|
+
const DEFAULT_HEIGHT = 720;
|
|
10
|
+
|
|
11
|
+
export const orthogonalFlowLayout = (
|
|
12
|
+
nodes: NodeData[],
|
|
13
|
+
edges: EdgeData[],
|
|
14
|
+
pad: number = DEFAULT_PADDING,
|
|
15
|
+
gap: number = DEFAULT_NODE_GAP,
|
|
16
|
+
direction: LayoutDirection = LayoutDirection.LTR,
|
|
17
|
+
width: number = DEFAULT_WIDTH,
|
|
18
|
+
height: number = DEFAULT_HEIGHT
|
|
19
|
+
): PositionedNode[] => {
|
|
20
|
+
if (!nodes.length) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const safePad = Number.isFinite(pad) && pad >= 0 ? pad : DEFAULT_PADDING;
|
|
25
|
+
const safeGap = Number.isFinite(gap) && gap >= 0 ? gap : DEFAULT_NODE_GAP;
|
|
26
|
+
const safeWidth = Number.isFinite(width) && width > 0 ? width : DEFAULT_WIDTH;
|
|
27
|
+
const safeHeight = Number.isFinite(height) && height > 0 ? height : DEFAULT_HEIGHT;
|
|
28
|
+
|
|
29
|
+
const { levels } = assignDagLevels(nodes, edges);
|
|
30
|
+
const buckets = new Map<number, NodeData[]>();
|
|
31
|
+
nodes.forEach((node) => {
|
|
32
|
+
const level = levels.get(node.id);
|
|
33
|
+
if (level == null) {
|
|
34
|
+
throw new Error(`DAG layout could not assign a level to node "${node.id}".`);
|
|
35
|
+
}
|
|
36
|
+
const bucket = buckets.get(level) ?? [];
|
|
37
|
+
bucket.push(node);
|
|
38
|
+
buckets.set(level, bucket);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const isRTL = direction === LayoutDirection.RTL;
|
|
42
|
+
|
|
43
|
+
// Sort columns by level once so per-column x accumulation is deterministic.
|
|
44
|
+
const sortedColumns = Array.from(buckets.entries()).sort((a, b) => a[0] - b[0]);
|
|
45
|
+
|
|
46
|
+
// Per-column max node width drives column pitch; avoids a single wide node in
|
|
47
|
+
// one column inflating the spacing of every other column.
|
|
48
|
+
const colMaxWidths = new Map<number, number>(
|
|
49
|
+
sortedColumns.map(([level, levelNodes]) => [
|
|
50
|
+
level,
|
|
51
|
+
levelNodes.reduce(
|
|
52
|
+
(max, node) => Math.max(max, node.size?.width ?? DEFAULT_NODE_SIZE.width),
|
|
53
|
+
0
|
|
54
|
+
),
|
|
55
|
+
])
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Accumulate column x start positions so each column occupies exactly its own
|
|
59
|
+
// content width rather than the global max width.
|
|
60
|
+
const colX = new Map<number, number>();
|
|
61
|
+
if (isRTL) {
|
|
62
|
+
let xCursor = safeWidth - safePad;
|
|
63
|
+
for (const [level] of sortedColumns) {
|
|
64
|
+
xCursor -= colMaxWidths.get(level)!;
|
|
65
|
+
colX.set(level, xCursor);
|
|
66
|
+
xCursor -= safeGap;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
let xCursor = safePad;
|
|
70
|
+
for (const [level] of sortedColumns) {
|
|
71
|
+
colX.set(level, xCursor);
|
|
72
|
+
xCursor += colMaxWidths.get(level)! + safeGap;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return sortedColumns.flatMap(([level, levelNodes]) => {
|
|
77
|
+
const colMaxWidth = colMaxWidths.get(level)!;
|
|
78
|
+
const colStartX = colX.get(level)!;
|
|
79
|
+
const colMaxNodeHeight = levelNodes.reduce(
|
|
80
|
+
(max, node) => Math.max(max, node.size?.height ?? DEFAULT_NODE_SIZE.height),
|
|
81
|
+
0
|
|
82
|
+
);
|
|
83
|
+
// Gap is proportional to both the user-supplied spacing parameter and each
|
|
84
|
+
// column's tallest node so that visually dense columns stay readable.
|
|
85
|
+
const verticalGap = Math.max(
|
|
86
|
+
VERTICAL_GAP_MIN,
|
|
87
|
+
safeGap * VERTICAL_GAP_RATIO,
|
|
88
|
+
colMaxNodeHeight * VERTICAL_GAP_HEIGHT_RATIO
|
|
89
|
+
);
|
|
90
|
+
const contentHeight = levelNodes.reduce(
|
|
91
|
+
(sum, node) => sum + (node.size?.height ?? DEFAULT_NODE_SIZE.height),
|
|
92
|
+
0
|
|
93
|
+
);
|
|
94
|
+
const totalGap = verticalGap * Math.max(levelNodes.length - 1, 0);
|
|
95
|
+
const centeredY = (safeHeight - contentHeight - totalGap) / 2;
|
|
96
|
+
// When content + gaps exceed the available height, centeredY goes negative
|
|
97
|
+
// and the column is top-anchored at safePad instead of disappearing off-canvas.
|
|
98
|
+
const maxY = safeHeight - safePad;
|
|
99
|
+
let y = Math.max(safePad, centeredY);
|
|
100
|
+
|
|
101
|
+
return levelNodes.map((node) => {
|
|
102
|
+
const nodeHeight = node.size?.height ?? DEFAULT_NODE_SIZE.height;
|
|
103
|
+
const nodeWidth = node.size?.width ?? DEFAULT_NODE_SIZE.width;
|
|
104
|
+
const position = {
|
|
105
|
+
x: colStartX + (isRTL ? colMaxWidth - nodeWidth : 0),
|
|
106
|
+
y: Math.min(y, maxY - nodeHeight),
|
|
107
|
+
};
|
|
108
|
+
y += nodeHeight + verticalGap;
|
|
109
|
+
return { ...node, position } as PositionedNode;
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { EdgeData, NodeData, PositionedNode } from '@graph-render/types';
|
|
2
|
+
import { DEFAULT_NODE_GAP, DEFAULT_NODE_SIZE, DEFAULT_PADDING } from '../utils';
|
|
3
|
+
import {
|
|
4
|
+
assertHierarchicalGraph,
|
|
5
|
+
assignNodesToLevels,
|
|
6
|
+
buildGraphTopology,
|
|
7
|
+
findRootNodes,
|
|
8
|
+
groupNodesByLevel,
|
|
9
|
+
} from './treeTopology';
|
|
10
|
+
import { centeredLayout } from './centered';
|
|
11
|
+
|
|
12
|
+
export const radialTreeLayout = (
|
|
13
|
+
nodes: NodeData[],
|
|
14
|
+
edges: EdgeData[],
|
|
15
|
+
pad: number = DEFAULT_PADDING,
|
|
16
|
+
width: number = 960,
|
|
17
|
+
height: number = 720,
|
|
18
|
+
gap: number = DEFAULT_NODE_GAP
|
|
19
|
+
): PositionedNode[] => {
|
|
20
|
+
if (!nodes.length) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!edges.length) {
|
|
25
|
+
return centeredLayout(nodes, pad, width, height);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
assertHierarchicalGraph(nodes, edges);
|
|
29
|
+
|
|
30
|
+
const { incoming, outgoing } = buildGraphTopology(edges);
|
|
31
|
+
const rootIds = findRootNodes(nodes, incoming);
|
|
32
|
+
const levelsMap = assignNodesToLevels(nodes, rootIds, outgoing);
|
|
33
|
+
const levels = groupNodesByLevel(nodes, levelsMap);
|
|
34
|
+
const centerX = width / 2;
|
|
35
|
+
const centerY = height / 2;
|
|
36
|
+
// FIX: use reduce instead of spread+Math.max to avoid a RangeError when the
|
|
37
|
+
// node array exceeds the JS engine's argument-count limit (~125 k in V8).
|
|
38
|
+
const maxNodeSize = nodes.reduce(
|
|
39
|
+
(max, node) =>
|
|
40
|
+
Math.max(
|
|
41
|
+
max,
|
|
42
|
+
node.size?.width ?? DEFAULT_NODE_SIZE.width,
|
|
43
|
+
node.size?.height ?? DEFAULT_NODE_SIZE.height
|
|
44
|
+
),
|
|
45
|
+
0
|
|
46
|
+
);
|
|
47
|
+
const maxRadius = Math.max(0, Math.min(width, height) / 2 - pad - maxNodeSize / 2);
|
|
48
|
+
const radiusStep = levels.length > 1 ? maxRadius / (levels.length - 1) : 0;
|
|
49
|
+
|
|
50
|
+
// FIX: pre-build an id→node map to avoid an O(n) Array.find inside the
|
|
51
|
+
// levels.flatMap loop, which was O(n²) overall.
|
|
52
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
53
|
+
|
|
54
|
+
return levels.flatMap((level, levelIndex) => {
|
|
55
|
+
const radius =
|
|
56
|
+
levelIndex === 0 ? 0 : Math.max(radiusStep * levelIndex, maxNodeSize + gap * 0.4);
|
|
57
|
+
|
|
58
|
+
return level.map((nodeId, nodeIndex) => {
|
|
59
|
+
const node = nodeById.get(nodeId) as NodeData;
|
|
60
|
+
const size = node.size ?? DEFAULT_NODE_SIZE;
|
|
61
|
+
const angle =
|
|
62
|
+
level.length === 1 ? -Math.PI / 2 : (2 * Math.PI * nodeIndex) / level.length - Math.PI / 2;
|
|
63
|
+
const position =
|
|
64
|
+
levelIndex === 0
|
|
65
|
+
? { x: centerX - size.width / 2, y: centerY - size.height / 2 }
|
|
66
|
+
: {
|
|
67
|
+
x: centerX + radius * Math.cos(angle) - size.width / 2,
|
|
68
|
+
y: centerY + radius * Math.sin(angle) - size.height / 2,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
...node,
|
|
73
|
+
position,
|
|
74
|
+
} as PositionedNode;
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
};
|