@invana/graph-layout-d3-force 0.0.2

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.
@@ -0,0 +1,209 @@
1
+ import { Layout } from '@invana/canvas';
2
+ import { GraphNode, GraphLayer } from '@invana/graph';
3
+
4
+ /**
5
+ * `D3ForceLayout` options. Every field maps 1:1 to a d3-force setter
6
+ * documented at https://d3js.org/d3-force.
7
+ *
8
+ * **All options default to `undefined`.** A force is only added to the
9
+ * simulation when its option is provided. A setter is only called when
10
+ * its sub-option is provided. Anything omitted falls through to
11
+ * d3-force's own defaults — or, for forces themselves, is not added at
12
+ * all.
13
+ *
14
+ * @example
15
+ * new D3ForceLayout({
16
+ * charge: {}, // adds forceManyBody at d3 defaults
17
+ * link: { distance: 80 }, // adds forceLink, override distance
18
+ * center: { x: 0, y: 0 }, // adds forceCenter at (0, 0)
19
+ * // no `collide` → no collision force
20
+ * // no `alphaDecay` → d3 default decay rate
21
+ * });
22
+ */
23
+ interface D3ForceLayoutOptions {
24
+ /**
25
+ * When `true` (default), positions are written back to the store on
26
+ * every d3-force tick — the renderer animates the simulation as it
27
+ * settles.
28
+ *
29
+ * When `false`, per-tick writeback is suppressed and positions are
30
+ * flushed to the store exactly once when the simulation settles
31
+ * (`sim.on('end')`). The simulation still runs to completion; only the
32
+ * mirrored renderer updates are skipped. For large graphs (thousands
33
+ * of nodes / tens of thousands of edges) this avoids the ~hundreds of
34
+ * intermediate `setPositionsBulk` → `node:update` → renderer storms
35
+ * that dominate cost — the run finishes noticeably faster and the
36
+ * viewer just sees the settled picture appear.
37
+ *
38
+ * Lifecycle `tick` events are still emitted in both modes — only the
39
+ * store writeback is gated.
40
+ *
41
+ * Default `true`.
42
+ */
43
+ animate?: boolean;
44
+ /** `simulation.alpha(alpha)`. */
45
+ alpha?: number;
46
+ /** `simulation.alphaMin(min)`. */
47
+ alphaMin?: number;
48
+ /** `simulation.alphaDecay(decay)`. */
49
+ alphaDecay?: number;
50
+ /** `simulation.alphaTarget(target)`. */
51
+ alphaTarget?: number;
52
+ /** `simulation.velocityDecay(decay)`. */
53
+ velocityDecay?: number;
54
+ /** `forceLink` — pulls connected nodes toward a target distance. */
55
+ link?: LinkForceOptions;
56
+ /** `forceManyBody` — n-body charge (negative = repulsion). */
57
+ charge?: ChargeForceOptions;
58
+ /** `forceCenter` — translates the cluster's centroid to `(x, y)`. */
59
+ center?: CenterForceOptions;
60
+ /** `forceCollide` — prevents overlap. */
61
+ collide?: CollideForceOptions;
62
+ /** `forceX` — positioning force along x. */
63
+ x?: PositionXForceOptions;
64
+ /** `forceY` — positioning force along y. */
65
+ y?: PositionYForceOptions;
66
+ /** `forceRadial` — pulls toward a circle of given radius. Requires `radius`. */
67
+ radial?: RadialForceOptions;
68
+ }
69
+ /** `forceLink` configuration. */
70
+ interface LinkForceOptions {
71
+ /** `link.distance(d)`. */
72
+ distance?: number;
73
+ /** `link.strength(s)`. */
74
+ strength?: number;
75
+ /** `link.iterations(n)`. */
76
+ iterations?: number;
77
+ }
78
+ /** `forceManyBody` configuration. */
79
+ interface ChargeForceOptions {
80
+ /** `manyBody.strength(s)` — negative repels, positive attracts. */
81
+ strength?: number;
82
+ /** `manyBody.theta(θ)` — Barnes–Hut accuracy threshold. */
83
+ theta?: number;
84
+ /** `manyBody.distanceMin(d)`. */
85
+ distanceMin?: number;
86
+ /** `manyBody.distanceMax(d)`. */
87
+ distanceMax?: number;
88
+ }
89
+ /** `forceCenter` configuration. */
90
+ interface CenterForceOptions {
91
+ /** `center.x(x)`. */
92
+ x?: number;
93
+ /** `center.y(y)`. */
94
+ y?: number;
95
+ /** `center.strength(s)`. */
96
+ strength?: number;
97
+ }
98
+ /** `forceCollide` configuration. */
99
+ interface CollideForceOptions {
100
+ /**
101
+ * `collide.radius(r)`. Either a constant, or a per-node function called
102
+ * once per node at `apply()` time with the underlying `GraphNode`. Use
103
+ * the function form when collision sizes vary per node (e.g. read
104
+ * `node.data.size`).
105
+ */
106
+ radius?: number | ((node: GraphNode) => number);
107
+ /** `collide.strength(s)` in `[0, 1]`. */
108
+ strength?: number;
109
+ /** `collide.iterations(n)`. */
110
+ iterations?: number;
111
+ }
112
+ /** `forceX` configuration. */
113
+ interface PositionXForceOptions {
114
+ /** `forceX.x(x)`. */
115
+ x?: number;
116
+ /** `forceX.strength(s)`. */
117
+ strength?: number;
118
+ }
119
+ /** `forceY` configuration. */
120
+ interface PositionYForceOptions {
121
+ /** `forceY.y(y)`. */
122
+ y?: number;
123
+ /** `forceY.strength(s)`. */
124
+ strength?: number;
125
+ }
126
+ /** `forceRadial` configuration. `radius` is required. */
127
+ interface RadialForceOptions {
128
+ /** Target circle radius. */
129
+ radius: number;
130
+ /** Circle center x. */
131
+ x?: number;
132
+ /** Circle center y. */
133
+ y?: number;
134
+ /** `radial.strength(s)`. */
135
+ strength?: number;
136
+ }
137
+
138
+ /**
139
+ * `D3ForceLayout` — d3-force-directed `Layout` for `@invana/graph`.
140
+ *
141
+ * The flow is intentionally tiny:
142
+ * 1. Snapshot nodes + edges from `layer.store` into d3-force datums.
143
+ * 2. Build a simulation. d3 owns the tick loop.
144
+ * 3. On each tick, bulk-write positions back to the store; the store
145
+ * emits `node:update` events and the renderer reacts on its own.
146
+ * 4. Listen to external `node:update` (e.g. a drag) and mirror new
147
+ * positions onto the sim, reheating α so neighbours readjust.
148
+ *
149
+ * Every option defaults to `undefined`. A force is only added when its
150
+ * option is provided; a setter is only called when its sub-option is
151
+ * provided. d3-force's own defaults apply otherwise. See `./types`.
152
+ *
153
+ * @example
154
+ * const layout = new D3ForceLayout({
155
+ * charge: { strength: -300 },
156
+ * link: { distance: 80 },
157
+ * center: { x: 0, y: 0 },
158
+ * });
159
+ * await layout.apply(graphLayer);
160
+ */
161
+
162
+ declare class D3ForceLayout extends Layout<GraphLayer> {
163
+ private readonly opts;
164
+ private sim;
165
+ private nodes;
166
+ private ids;
167
+ private nodeById;
168
+ /** GraphNode snapshot indexed by id — used by per-node force callbacks
169
+ * (e.g. `collide.radius(d => ...)`) without coupling SimNode to GraphNode. */
170
+ private graphNodeById;
171
+ /** Ids of nodes whose `GraphNode.pinned === true` — permanent pins from
172
+ * user data. Driven via d3-force's `fx/fy` so the simulation keeps them
173
+ * fixed. Live: pin/unpin patches on `node:update` add/remove entries. */
174
+ private pinnedIds;
175
+ /** Ids of nodes currently being dragged by a user behaviour. Populated
176
+ * on `node:drag-start` from the layer, drained on `node:drag-end`. While
177
+ * an id is in this set, position updates mirror onto `fx/fy` so the
178
+ * simulation can't push the node away from the cursor. On drag-end the
179
+ * transient `fx/fy` clears (unless the node is also in `pinnedIds`,
180
+ * which is the permanent-pin path). Decoupled from `pinned` so a drag
181
+ * never mutates user-data semantics — pin-on-release is opt-in via a
182
+ * separate behaviour. */
183
+ private draggedIds;
184
+ private buffer;
185
+ /** True while our own bulk write is in-flight, so the `node:update`
186
+ * events it triggers don't bounce back into the sim. Relies on the
187
+ * store's default sync flush firing events inside the bulk call. */
188
+ private writing;
189
+ private unsubscribe;
190
+ private offDragStart;
191
+ private offDragEnd;
192
+ /** True while a run is active. Guards `stop()` so it only emits `end`
193
+ * once per run, even if called externally after a natural settle. */
194
+ private running;
195
+ constructor(opts?: D3ForceLayoutOptions);
196
+ /**
197
+ * Run the layout against `layer`. Resolves when the simulation settles
198
+ * naturally OR is cancelled via `stop()` / a second `apply()` call.
199
+ * Lifecycle events (`start` / `tick` / `end`) fire around the run.
200
+ */
201
+ apply(layer: GraphLayer): Promise<void>;
202
+ /** Cancel an in-flight run. Positions stay in the store. No-op when idle. */
203
+ stop(): void;
204
+ private configureForces;
205
+ private configureSimulation;
206
+ private writeBack;
207
+ }
208
+
209
+ export { type CenterForceOptions, type ChargeForceOptions, type CollideForceOptions, D3ForceLayout, type D3ForceLayoutOptions, type LinkForceOptions, type PositionXForceOptions, type PositionYForceOptions, type RadialForceOptions };
package/dist/index.js ADDED
@@ -0,0 +1,255 @@
1
+ import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, forceRadial } from 'd3-force';
2
+ import { Layout } from '@invana/canvas';
3
+
4
+ // src/D3ForceLayout.ts
5
+ var REHEAT_ALPHA = 0.3;
6
+ var D3ForceLayout = class extends Layout {
7
+ opts;
8
+ sim = null;
9
+ nodes = [];
10
+ ids = [];
11
+ nodeById = /* @__PURE__ */ new Map();
12
+ /** GraphNode snapshot indexed by id — used by per-node force callbacks
13
+ * (e.g. `collide.radius(d => ...)`) without coupling SimNode to GraphNode. */
14
+ graphNodeById = /* @__PURE__ */ new Map();
15
+ /** Ids of nodes whose `GraphNode.pinned === true` — permanent pins from
16
+ * user data. Driven via d3-force's `fx/fy` so the simulation keeps them
17
+ * fixed. Live: pin/unpin patches on `node:update` add/remove entries. */
18
+ pinnedIds = /* @__PURE__ */ new Set();
19
+ /** Ids of nodes currently being dragged by a user behaviour. Populated
20
+ * on `node:drag-start` from the layer, drained on `node:drag-end`. While
21
+ * an id is in this set, position updates mirror onto `fx/fy` so the
22
+ * simulation can't push the node away from the cursor. On drag-end the
23
+ * transient `fx/fy` clears (unless the node is also in `pinnedIds`,
24
+ * which is the permanent-pin path). Decoupled from `pinned` so a drag
25
+ * never mutates user-data semantics — pin-on-release is opt-in via a
26
+ * separate behaviour. */
27
+ draggedIds = /* @__PURE__ */ new Set();
28
+ buffer = new Float32Array(0);
29
+ /** True while our own bulk write is in-flight, so the `node:update`
30
+ * events it triggers don't bounce back into the sim. Relies on the
31
+ * store's default sync flush firing events inside the bulk call. */
32
+ writing = false;
33
+ unsubscribe = null;
34
+ offDragStart = null;
35
+ offDragEnd = null;
36
+ /** True while a run is active. Guards `stop()` so it only emits `end`
37
+ * once per run, even if called externally after a natural settle. */
38
+ running = false;
39
+ constructor(opts = {}) {
40
+ super();
41
+ this.opts = opts;
42
+ }
43
+ /**
44
+ * Run the layout against `layer`. Resolves when the simulation settles
45
+ * naturally OR is cancelled via `stop()` / a second `apply()` call.
46
+ * Lifecycle events (`start` / `tick` / `end`) fire around the run.
47
+ */
48
+ async apply(layer) {
49
+ this.stop();
50
+ const store = layer.store;
51
+ this.nodes = [];
52
+ this.ids = [];
53
+ this.nodeById.clear();
54
+ this.graphNodeById.clear();
55
+ this.pinnedIds.clear();
56
+ this.draggedIds.clear();
57
+ for (const n of store.nodes()) {
58
+ const pos = store.getPosition(n.id);
59
+ const node = { id: n.id };
60
+ if (n.pinned) {
61
+ const px = pos?.x ?? 0;
62
+ const py = pos?.y ?? 0;
63
+ node.fx = px;
64
+ node.fy = py;
65
+ node.x = px;
66
+ node.y = py;
67
+ this.pinnedIds.add(n.id);
68
+ } else if (pos && (pos.x !== 0 || pos.y !== 0)) {
69
+ node.x = pos.x;
70
+ node.y = pos.y;
71
+ }
72
+ this.nodes.push(node);
73
+ this.ids.push(n.id);
74
+ this.nodeById.set(n.id, node);
75
+ this.graphNodeById.set(n.id, n);
76
+ }
77
+ if (this.nodes.length === 0) return;
78
+ this.buffer = new Float32Array(this.nodes.length * 2);
79
+ const links = [];
80
+ for (const e of store.edges()) {
81
+ links.push({ source: e.source, target: e.target });
82
+ }
83
+ const sim = forceSimulation(this.nodes);
84
+ this.configureForces(sim, links);
85
+ this.configureSimulation(sim);
86
+ this.sim = sim;
87
+ const animate = this.opts.animate ?? true;
88
+ sim.on("tick", () => {
89
+ if (animate) this.writeBack(store);
90
+ this.events.emit("tick", {});
91
+ });
92
+ this.unsubscribe = store.events.on("node:update", ({ nodeId, patch }) => {
93
+ if (this.writing) return;
94
+ const node = this.nodeById.get(nodeId);
95
+ if (!node) return;
96
+ if ("pinned" in patch) {
97
+ if (patch.pinned) this.pinnedIds.add(nodeId);
98
+ else this.pinnedIds.delete(nodeId);
99
+ }
100
+ if (!patch.position) return;
101
+ const locked = this.pinnedIds.has(nodeId) || this.draggedIds.has(nodeId);
102
+ if (locked) {
103
+ node.fx = patch.position.x;
104
+ node.fy = patch.position.y;
105
+ node.x = patch.position.x;
106
+ node.y = patch.position.y;
107
+ } else {
108
+ if (node.fx !== void 0) node.fx = void 0;
109
+ if (node.fy !== void 0) node.fy = void 0;
110
+ node.x = patch.position.x;
111
+ node.y = patch.position.y;
112
+ }
113
+ if (sim.alpha() < REHEAT_ALPHA) sim.alpha(REHEAT_ALPHA).restart();
114
+ });
115
+ this.offDragStart = layer.events.on("node:drag-start", ({ nodeId, nodeIds }) => {
116
+ for (const id of nodeIds ?? [nodeId]) {
117
+ const node = this.nodeById.get(id);
118
+ if (!node) continue;
119
+ this.draggedIds.add(id);
120
+ const pos = store.getNode(id)?.position;
121
+ if (pos) {
122
+ node.fx = pos.x;
123
+ node.fy = pos.y;
124
+ node.x = pos.x;
125
+ node.y = pos.y;
126
+ }
127
+ }
128
+ if (sim.alpha() < REHEAT_ALPHA) sim.alpha(REHEAT_ALPHA).restart();
129
+ });
130
+ this.offDragEnd = layer.events.on("node:drag-end", ({ nodeId, nodeIds }) => {
131
+ for (const id of nodeIds ?? [nodeId]) {
132
+ this.draggedIds.delete(id);
133
+ const node = this.nodeById.get(id);
134
+ if (!node) continue;
135
+ if (!this.pinnedIds.has(id)) {
136
+ node.fx = void 0;
137
+ node.fy = void 0;
138
+ }
139
+ }
140
+ });
141
+ this.running = true;
142
+ this.events.emit("start", {});
143
+ return new Promise((resolve) => {
144
+ sim.on("end", () => {
145
+ if (this.running) {
146
+ if (!animate) this.writeBack(store);
147
+ this.running = false;
148
+ this.events.emit("end", { reason: "completed" });
149
+ }
150
+ resolve();
151
+ });
152
+ });
153
+ }
154
+ /** Cancel an in-flight run. Positions stay in the store. No-op when idle. */
155
+ stop() {
156
+ const wasRunning = this.running;
157
+ this.running = false;
158
+ this.sim?.stop();
159
+ this.sim = null;
160
+ this.unsubscribe?.();
161
+ this.unsubscribe = null;
162
+ this.offDragStart?.();
163
+ this.offDragStart = null;
164
+ this.offDragEnd?.();
165
+ this.offDragEnd = null;
166
+ this.nodes = [];
167
+ this.ids = [];
168
+ this.nodeById.clear();
169
+ this.graphNodeById.clear();
170
+ this.pinnedIds.clear();
171
+ this.draggedIds.clear();
172
+ if (wasRunning) this.events.emit("end", { reason: "stopped" });
173
+ }
174
+ // ─── Configuration ─────────────────────────────────────────────────────
175
+ configureForces(sim, links) {
176
+ const { link, charge, center, collide, x, y, radial } = this.opts;
177
+ if (link !== void 0) {
178
+ const force = forceLink(links).id((d) => d.id);
179
+ if (link.distance !== void 0) force.distance(link.distance);
180
+ if (link.strength !== void 0) force.strength(link.strength);
181
+ if (link.iterations !== void 0) force.iterations(link.iterations);
182
+ sim.force("link", force);
183
+ }
184
+ if (charge !== void 0) {
185
+ const force = forceManyBody();
186
+ if (charge.strength !== void 0) force.strength(charge.strength);
187
+ if (charge.theta !== void 0) force.theta(charge.theta);
188
+ if (charge.distanceMin !== void 0) force.distanceMin(charge.distanceMin);
189
+ if (charge.distanceMax !== void 0) force.distanceMax(charge.distanceMax);
190
+ sim.force("charge", force);
191
+ }
192
+ if (center !== void 0) {
193
+ const force = forceCenter(center.x ?? 0, center.y ?? 0);
194
+ if (center.strength !== void 0) force.strength(center.strength);
195
+ sim.force("center", force);
196
+ }
197
+ if (collide !== void 0) {
198
+ const force = forceCollide();
199
+ if (collide.radius !== void 0) {
200
+ if (typeof collide.radius === "function") {
201
+ const fn = collide.radius;
202
+ const refs = this.graphNodeById;
203
+ force.radius((d) => {
204
+ const node = refs.get(d.id);
205
+ return node ? fn(node) : 0;
206
+ });
207
+ } else {
208
+ force.radius(collide.radius);
209
+ }
210
+ }
211
+ if (collide.strength !== void 0) force.strength(collide.strength);
212
+ if (collide.iterations !== void 0) force.iterations(collide.iterations);
213
+ sim.force("collide", force);
214
+ }
215
+ if (x !== void 0) {
216
+ const force = forceX();
217
+ if (x.x !== void 0) force.x(x.x);
218
+ if (x.strength !== void 0) force.strength(x.strength);
219
+ sim.force("x", force);
220
+ }
221
+ if (y !== void 0) {
222
+ const force = forceY();
223
+ if (y.y !== void 0) force.y(y.y);
224
+ if (y.strength !== void 0) force.strength(y.strength);
225
+ sim.force("y", force);
226
+ }
227
+ if (radial !== void 0) {
228
+ const force = forceRadial(radial.radius, radial.x ?? 0, radial.y ?? 0);
229
+ if (radial.strength !== void 0) force.strength(radial.strength);
230
+ sim.force("radial", force);
231
+ }
232
+ }
233
+ configureSimulation(sim) {
234
+ const { alpha, alphaMin, alphaDecay, alphaTarget, velocityDecay } = this.opts;
235
+ if (alpha !== void 0) sim.alpha(alpha);
236
+ if (alphaMin !== void 0) sim.alphaMin(alphaMin);
237
+ if (alphaDecay !== void 0) sim.alphaDecay(alphaDecay);
238
+ if (alphaTarget !== void 0) sim.alphaTarget(alphaTarget);
239
+ if (velocityDecay !== void 0) sim.velocityDecay(velocityDecay);
240
+ }
241
+ writeBack(store) {
242
+ const { nodes, buffer } = this;
243
+ for (let i = 0, j = 0; i < nodes.length; i++, j += 2) {
244
+ buffer[j] = nodes[i].x;
245
+ buffer[j + 1] = nodes[i].y;
246
+ }
247
+ this.writing = true;
248
+ store.setPositionsBulk(this.ids, buffer);
249
+ this.writing = false;
250
+ }
251
+ };
252
+
253
+ export { D3ForceLayout };
254
+ //# sourceMappingURL=index.js.map
255
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/D3ForceLayout.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `D3ForceLayout` — d3-force-directed `Layout` for `@invana/graph`.\n *\n * The flow is intentionally tiny:\n * 1. Snapshot nodes + edges from `layer.store` into d3-force datums.\n * 2. Build a simulation. d3 owns the tick loop.\n * 3. On each tick, bulk-write positions back to the store; the store\n * emits `node:update` events and the renderer reacts on its own.\n * 4. Listen to external `node:update` (e.g. a drag) and mirror new\n * positions onto the sim, reheating α so neighbours readjust.\n *\n * Every option defaults to `undefined`. A force is only added when its\n * option is provided; a setter is only called when its sub-option is\n * provided. d3-force's own defaults apply otherwise. See `./types`.\n *\n * @example\n * const layout = new D3ForceLayout({\n * charge: { strength: -300 },\n * link: { distance: 80 },\n * center: { x: 0, y: 0 },\n * });\n * await layout.apply(graphLayer);\n */\n\nimport {\n forceCenter,\n forceCollide,\n forceLink,\n forceManyBody,\n forceRadial,\n forceSimulation,\n forceX,\n forceY,\n type Simulation,\n type SimulationLinkDatum,\n type SimulationNodeDatum,\n} from 'd3-force';\n\nimport { Layout } from '@invana/canvas';\nimport type { GraphLayer, GraphNode } from '@invana/graph';\n\nimport type { D3ForceLayoutOptions } from './types';\n\ninterface SimNode extends SimulationNodeDatum {\n id: string;\n}\ninterface SimLink extends SimulationLinkDatum<SimNode> {}\n\n/** α target re-applied when an external `node:update` arrives. */\nconst REHEAT_ALPHA = 0.3;\n\nexport class D3ForceLayout extends Layout<GraphLayer> {\n private readonly opts: D3ForceLayoutOptions;\n private sim: Simulation<SimNode, SimLink> | null = null;\n private nodes: SimNode[] = [];\n private ids: string[] = [];\n private nodeById = new Map<string, SimNode>();\n /** GraphNode snapshot indexed by id — used by per-node force callbacks\n * (e.g. `collide.radius(d => ...)`) without coupling SimNode to GraphNode. */\n private graphNodeById = new Map<string, GraphNode>();\n /** Ids of nodes whose `GraphNode.pinned === true` — permanent pins from\n * user data. Driven via d3-force's `fx/fy` so the simulation keeps them\n * fixed. Live: pin/unpin patches on `node:update` add/remove entries. */\n private pinnedIds = new Set<string>();\n /** Ids of nodes currently being dragged by a user behaviour. Populated\n * on `node:drag-start` from the layer, drained on `node:drag-end`. While\n * an id is in this set, position updates mirror onto `fx/fy` so the\n * simulation can't push the node away from the cursor. On drag-end the\n * transient `fx/fy` clears (unless the node is also in `pinnedIds`,\n * which is the permanent-pin path). Decoupled from `pinned` so a drag\n * never mutates user-data semantics — pin-on-release is opt-in via a\n * separate behaviour. */\n private draggedIds = new Set<string>();\n private buffer = new Float32Array(0);\n /** True while our own bulk write is in-flight, so the `node:update`\n * events it triggers don't bounce back into the sim. Relies on the\n * store's default sync flush firing events inside the bulk call. */\n private writing = false;\n private unsubscribe: (() => void) | null = null;\n private offDragStart: (() => void) | null = null;\n private offDragEnd: (() => void) | null = null;\n /** True while a run is active. Guards `stop()` so it only emits `end`\n * once per run, even if called externally after a natural settle. */\n private running = false;\n\n constructor(opts: D3ForceLayoutOptions = {}) {\n super();\n this.opts = opts;\n }\n\n /**\n * Run the layout against `layer`. Resolves when the simulation settles\n * naturally OR is cancelled via `stop()` / a second `apply()` call.\n * Lifecycle events (`start` / `tick` / `end`) fire around the run.\n */\n async apply(layer: GraphLayer): Promise<void> {\n this.stop();\n const store = layer.store;\n\n // 1. Snapshot store → sim datums.\n this.nodes = [];\n this.ids = [];\n this.nodeById.clear();\n this.graphNodeById.clear();\n this.pinnedIds.clear();\n this.draggedIds.clear();\n for (const n of store.nodes()) {\n const pos = store.getPosition(n.id);\n const node: SimNode = { id: n.id };\n if (n.pinned) {\n // Pinned nodes use d3-force's `fx/fy` so the sim treats them as\n // immovable but still applies their forces to neighbours\n // (e.g. a cursor-follower that pushes other nodes via collide).\n const px = pos?.x ?? 0;\n const py = pos?.y ?? 0;\n node.fx = px;\n node.fy = py;\n node.x = px;\n node.y = py;\n this.pinnedIds.add(n.id);\n } else if (pos && (pos.x !== 0 || pos.y !== 0)) {\n // Only seed if the store has a real position. (0, 0) is treated as\n // the typed-array default; leaving x/y `undefined` lets\n // `forceSimulation` phyllotaxis-scatter the cluster — without it,\n // all-colocated nodes can't break their tie under d3 defaults.\n node.x = pos.x;\n node.y = pos.y;\n }\n this.nodes.push(node);\n this.ids.push(n.id);\n this.nodeById.set(n.id, node);\n this.graphNodeById.set(n.id, n);\n }\n if (this.nodes.length === 0) return;\n this.buffer = new Float32Array(this.nodes.length * 2);\n\n const links: SimLink[] = [];\n for (const e of store.edges()) {\n links.push({ source: e.source, target: e.target });\n }\n\n // 2. Build simulation + apply only options the user provided.\n const sim = forceSimulation<SimNode, SimLink>(this.nodes);\n this.configureForces(sim, links);\n this.configureSimulation(sim);\n this.sim = sim;\n\n // 3. Each d3 tick → optionally bulk write to store + emit lifecycle\n // `tick`. When `animate` is `false` we skip the per-tick store\n // writeback (and the renderer storm that follows) and defer to a\n // single writeback in the natural-end handler below — see\n // `D3ForceLayoutOptions.animate` for the rationale.\n const animate = this.opts.animate ?? true;\n sim.on('tick', () => {\n if (animate) this.writeBack(store);\n this.events.emit('tick', {});\n });\n\n // 4. External writes (drag, cursor-follower, etc.) → mirror onto sim,\n // reheat. Nodes that are either permanently pinned (`pinnedIds`,\n // user-data semantics) or transiently locked by an in-flight drag\n // (`draggedIds`, signalled by `node:drag-start` / `node:drag-end`\n // on the layer's events) write to `fx/fy` so the simulation can't\n // push them away from the supplied position. Otherwise the update\n // flows into `x/y` and the next force tick may move the node.\n //\n // Pin patches are tracked live so a mid-run flip (e.g. a feed\n // enabling `pinned: true` on a node) takes effect on the next\n // `node:update` without needing a fresh `apply()`.\n this.unsubscribe = store.events.on('node:update', ({ nodeId, patch }) => {\n if (this.writing) return;\n const node = this.nodeById.get(nodeId);\n if (!node) return;\n\n if ('pinned' in patch) {\n if (patch.pinned) this.pinnedIds.add(nodeId);\n else this.pinnedIds.delete(nodeId);\n }\n\n if (!patch.position) return;\n const locked =\n this.pinnedIds.has(nodeId) || this.draggedIds.has(nodeId);\n if (locked) {\n node.fx = patch.position.x;\n node.fy = patch.position.y;\n // Mirror onto `x/y` so rendered position matches before the next\n // force tick — `forceCenter`/`forceX`/`forceY` read `x/y`.\n node.x = patch.position.x;\n node.y = patch.position.y;\n } else {\n // Free node: ensure no stale `fx/fy` are holding it.\n if (node.fx !== undefined) node.fx = undefined as unknown as number;\n if (node.fy !== undefined) node.fy = undefined as unknown as number;\n node.x = patch.position.x;\n node.y = patch.position.y;\n }\n if (sim.alpha() < REHEAT_ALPHA) sim.alpha(REHEAT_ALPHA).restart();\n });\n\n // 4b. Subscribe to drag lifecycle on the layer. `node:drag-start` puts\n // the node in `draggedIds` (so subsequent position updates lock via\n // `fx/fy`) and reheats the sim. `node:drag-end` removes it and —\n // unless the node is also permanently pinned — clears `fx/fy` so\n // forces can move it again. The store's `pinned` flag is never\n // touched here; permanent pin-on-release is a separate behaviour's\n // concern.\n // `nodeIds` carries every primary being dragged (a multi-selection drag\n // moves them all); fall back to `[nodeId]` for safety. Clamp / release\n // each one's `fx/fy` independently.\n this.offDragStart = layer.events.on('node:drag-start', ({ nodeId, nodeIds }) => {\n for (const id of nodeIds ?? [nodeId]) {\n const node = this.nodeById.get(id);\n if (!node) continue;\n this.draggedIds.add(id);\n const pos = store.getNode(id)?.position;\n if (pos) {\n node.fx = pos.x;\n node.fy = pos.y;\n node.x = pos.x;\n node.y = pos.y;\n }\n }\n if (sim.alpha() < REHEAT_ALPHA) sim.alpha(REHEAT_ALPHA).restart();\n });\n this.offDragEnd = layer.events.on('node:drag-end', ({ nodeId, nodeIds }) => {\n for (const id of nodeIds ?? [nodeId]) {\n this.draggedIds.delete(id);\n const node = this.nodeById.get(id);\n if (!node) continue;\n if (!this.pinnedIds.has(id)) {\n node.fx = undefined as unknown as number;\n node.fy = undefined as unknown as number;\n }\n }\n });\n\n // 5. Mark run as active and announce `start` after wiring is in place\n // so handlers see a fully-initialised layout.\n this.running = true;\n this.events.emit('start', {});\n\n // 6. Resolve on natural settle. The `end` emission happens here (not in\n // `stop()`) so a natural settle reports `reason: 'completed'`;\n // external `stop()` flips `running` first and emits `'stopped'`.\n return new Promise<void>((resolve) => {\n sim.on('end', () => {\n if (this.running) {\n // `animate: false` runs deferred every per-tick writeback —\n // flush the final settled positions now so the renderer\n // actually sees the layout result.\n if (!animate) this.writeBack(store);\n this.running = false;\n this.events.emit('end', { reason: 'completed' });\n }\n resolve();\n });\n });\n }\n\n /** Cancel an in-flight run. Positions stay in the store. No-op when idle. */\n stop(): void {\n const wasRunning = this.running;\n this.running = false;\n this.sim?.stop();\n this.sim = null;\n this.unsubscribe?.();\n this.unsubscribe = null;\n this.offDragStart?.();\n this.offDragStart = null;\n this.offDragEnd?.();\n this.offDragEnd = null;\n this.nodes = [];\n this.ids = [];\n this.nodeById.clear();\n this.graphNodeById.clear();\n this.pinnedIds.clear();\n this.draggedIds.clear();\n if (wasRunning) this.events.emit('end', { reason: 'stopped' });\n }\n\n // ─── Configuration ─────────────────────────────────────────────────────\n\n private configureForces(sim: Simulation<SimNode, SimLink>, links: SimLink[]): void {\n const { link, charge, center, collide, x, y, radial } = this.opts;\n\n if (link !== undefined) {\n const force = forceLink<SimNode, SimLink>(links).id((d) => d.id);\n if (link.distance !== undefined) force.distance(link.distance);\n if (link.strength !== undefined) force.strength(link.strength);\n if (link.iterations !== undefined) force.iterations(link.iterations);\n sim.force('link', force);\n }\n\n if (charge !== undefined) {\n const force = forceManyBody<SimNode>();\n if (charge.strength !== undefined) force.strength(charge.strength);\n if (charge.theta !== undefined) force.theta(charge.theta);\n if (charge.distanceMin !== undefined) force.distanceMin(charge.distanceMin);\n if (charge.distanceMax !== undefined) force.distanceMax(charge.distanceMax);\n sim.force('charge', force);\n }\n\n if (center !== undefined) {\n const force = forceCenter<SimNode>(center.x ?? 0, center.y ?? 0);\n if (center.strength !== undefined) force.strength(center.strength);\n sim.force('center', force);\n }\n\n if (collide !== undefined) {\n const force = forceCollide<SimNode>();\n if (collide.radius !== undefined) {\n if (typeof collide.radius === 'function') {\n const fn = collide.radius;\n const refs = this.graphNodeById;\n force.radius((d) => {\n const node = refs.get(d.id);\n return node ? fn(node) : 0;\n });\n } else {\n force.radius(collide.radius);\n }\n }\n if (collide.strength !== undefined) force.strength(collide.strength);\n if (collide.iterations !== undefined) force.iterations(collide.iterations);\n sim.force('collide', force);\n }\n\n if (x !== undefined) {\n const force = forceX<SimNode>();\n if (x.x !== undefined) force.x(x.x);\n if (x.strength !== undefined) force.strength(x.strength);\n sim.force('x', force);\n }\n\n if (y !== undefined) {\n const force = forceY<SimNode>();\n if (y.y !== undefined) force.y(y.y);\n if (y.strength !== undefined) force.strength(y.strength);\n sim.force('y', force);\n }\n\n if (radial !== undefined) {\n const force = forceRadial<SimNode>(radial.radius, radial.x ?? 0, radial.y ?? 0);\n if (radial.strength !== undefined) force.strength(radial.strength);\n sim.force('radial', force);\n }\n }\n\n private configureSimulation(sim: Simulation<SimNode, SimLink>): void {\n const { alpha, alphaMin, alphaDecay, alphaTarget, velocityDecay } = this.opts;\n if (alpha !== undefined) sim.alpha(alpha);\n if (alphaMin !== undefined) sim.alphaMin(alphaMin);\n if (alphaDecay !== undefined) sim.alphaDecay(alphaDecay);\n if (alphaTarget !== undefined) sim.alphaTarget(alphaTarget);\n if (velocityDecay !== undefined) sim.velocityDecay(velocityDecay);\n }\n\n private writeBack(store: GraphLayer['store']): void {\n const { nodes, buffer } = this;\n for (let i = 0, j = 0; i < nodes.length; i++, j += 2) {\n buffer[j] = nodes[i]!.x!;\n buffer[j + 1] = nodes[i]!.y!;\n }\n this.writing = true;\n store.setPositionsBulk(this.ids, buffer);\n this.writing = false;\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@invana/graph-layout-d3-force",
3
+ "version": "0.0.2",
4
+ "description": "D3 force-directed Layout for @invana/graph.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "typedocOptions": {
9
+ "entryPoints": [
10
+ "src/index.ts"
11
+ ]
12
+ },
13
+ "dependencies": {
14
+ "d3-force": "^3.0.0"
15
+ },
16
+ "peerDependencies": {
17
+ "@invana/graph": "0.0.2",
18
+ "@invana/canvas": "0.0.2"
19
+ },
20
+ "devDependencies": {
21
+ "@types/d3-force": "^3.0.10",
22
+ "tsup": "^8.3.5",
23
+ "typescript": "5.9.2",
24
+ "vitest": "^2.1.8",
25
+ "@invana/canvas": "0.0.2",
26
+ "@invana/graph": "0.0.2",
27
+ "@repo/typescript-config": "0.0.0"
28
+ },
29
+ "keywords": [
30
+ "canvas",
31
+ "graph",
32
+ "layout",
33
+ "d3",
34
+ "d3-force",
35
+ "force-directed"
36
+ ],
37
+ "license": "Apache-2.0",
38
+ "module": "./dist/index.js",
39
+ "exports": {
40
+ ".": {
41
+ "types": "./dist/index.d.ts",
42
+ "import": "./dist/index.js",
43
+ "default": "./dist/index.js"
44
+ }
45
+ },
46
+ "files": [
47
+ "dist"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "dev": "tsup --watch",
55
+ "lint": "eslint src/",
56
+ "check-types": "tsc --noEmit",
57
+ "clean": "rm -rf dist",
58
+ "test": "vitest run"
59
+ }
60
+ }