@invana/graph-layout-d3-hierarchy 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,170 @@
1
+ import { Layout } from '@invana/canvas';
2
+ import { GraphLayer } from '@invana/graph';
3
+ import { HierarchyNode } from 'd3-hierarchy';
4
+
5
+ /**
6
+ * Layout mode.
7
+ *
8
+ * - `'tree'` — `d3.tree()` tidy layout, Cartesian (x, y) positions.
9
+ * - `'cluster'` — `d3.cluster()` dendrogram (leaves aligned), Cartesian positions.
10
+ * - `'radial-tree'` — `d3.tree()` projected to polar coordinates.
11
+ * - `'radial-cluster'` — `d3.cluster()` projected to polar coordinates.
12
+ * - `'pack'` — `d3.pack()` enclosure layout. Each node is sized by the
13
+ * accumulated `value` and positioned so children are packed inside the
14
+ * parent's circle. The layout also writes per-node sizes onto each node
15
+ * (`data.size = 2 * r`), so the renderer can draw the correct circle
16
+ * diameter; this is unique to pack and is why it needs `value`.
17
+ * - `'sunburst'` — `d3.partition()` over polar coordinates. Each node becomes
18
+ * an annular sector; positions all collapse to `(center.x, center.y)` and
19
+ * the per-node shape (innerR / outerR / startAngle / endAngle) is written
20
+ * onto `data` so the renderer can paint it as an `'arc'` shape. Sized off
21
+ * the accumulated `value` like pack. Ring radii grow with `sqrt(y)` so
22
+ * every ring covers an area proportional to its summed leaves — the
23
+ * convention d3's example uses.
24
+ */
25
+ type D3HierarchyLayoutMode = 'tree' | 'cluster' | 'radial-tree' | 'radial-cluster' | 'pack' | 'sunburst';
26
+ /**
27
+ * Per-pair separation accessor — passed straight through to d3's
28
+ * `.separation(fn)` setter on `tree()` / `cluster()`. See d3-hierarchy docs.
29
+ */
30
+ type SeparationFn = (a: HierarchyNode<{
31
+ id: string;
32
+ }>, b: HierarchyNode<{
33
+ id: string;
34
+ }>) => number;
35
+ /**
36
+ * Cartesian-mode orientation.
37
+ *
38
+ * - `'vertical'` (default) — depth axis runs top-to-bottom; root at top,
39
+ * leaves at bottom. Pairs naturally with `pathType: 'bezier'` (axis 'auto'
40
+ * picks vertical) or `pathType: 'smooth'`.
41
+ * - `'horizontal'` — depth axis runs left-to-right; root on the left,
42
+ * leaves aligned on the right. Matches the d3 cluster / tidy-tree
43
+ * examples. Pairs with `pathType: 'bezier'` (axis 'auto' picks horizontal).
44
+ *
45
+ * Ignored in `radial-*` modes.
46
+ */
47
+ type CartesianOrientation = 'vertical' | 'horizontal';
48
+ /**
49
+ * `D3HierarchyLayout` options.
50
+ *
51
+ * **All options default to `undefined`.** Only `mode` has an internal default
52
+ * (`'radial-tree'`). Anything you omit falls through to d3-hierarchy's own
53
+ * defaults — no setter is called when you don't provide a value.
54
+ */
55
+ interface D3HierarchyLayoutOptions {
56
+ /** Layout mode. Default `'radial-tree'`. */
57
+ mode?: D3HierarchyLayoutMode;
58
+ /**
59
+ * Explicit root node id. If omitted, the layout auto-detects the root as
60
+ * the unique node with no incoming edge in the snapshot. Throws if there
61
+ * is none or more than one.
62
+ */
63
+ rootId?: string;
64
+ /**
65
+ * `tree.size([w, h])` / `cluster.size([w, h])`. Cartesian modes default
66
+ * to `[640, 480]` if neither `size` nor `nodeSize` is provided.
67
+ *
68
+ * For radial modes, the underlying d3 layout uses `[2π, radius]` —
69
+ * configure the polar layout with `radius` (and optionally `nodeSize` for
70
+ * per-node angular spacing) instead.
71
+ */
72
+ size?: [number, number];
73
+ /**
74
+ * `tree.nodeSize([dx, dy])` / `cluster.nodeSize([dx, dy])`. Mutually
75
+ * exclusive with `size`.
76
+ */
77
+ nodeSize?: [number, number];
78
+ /**
79
+ * Polar radius for `radial-*` modes. Default `400`. Ignored for Cartesian
80
+ * modes.
81
+ */
82
+ radius?: number;
83
+ /**
84
+ * Cartesian orientation. Default `'vertical'`. See {@link CartesianOrientation}.
85
+ * Ignored in `radial-*` modes.
86
+ */
87
+ orientation?: CartesianOrientation;
88
+ /** Custom separation function. See d3-hierarchy `tree.separation`. */
89
+ separation?: SeparationFn;
90
+ /**
91
+ * Translate the projected coordinates by `(x, y)` after layout. Default
92
+ * `{ x: 0, y: 0 }`. Useful for centring the cluster around the world
93
+ * origin in radial modes (the default already does this).
94
+ */
95
+ center?: {
96
+ x?: number;
97
+ y?: number;
98
+ };
99
+ /**
100
+ * Pack-only: padding between sibling circles, in world units. Default `0`
101
+ * (d3's default). Ignored in non-pack modes.
102
+ */
103
+ padding?: number;
104
+ /**
105
+ * Pack-only: per-node value accessor used by `hierarchy.sum()`. Defaults
106
+ * to reading `node.data.value` (treats missing as `1`). The accumulated
107
+ * sum drives each circle's radius. Ignored in non-pack modes.
108
+ *
109
+ * Note: the input is the raw `GraphNode<unknown>`, not the d3 hierarchy
110
+ * node. Cast `data` if you know its shape.
111
+ */
112
+ value?: (node: {
113
+ id: string;
114
+ data?: unknown;
115
+ }) => number;
116
+ /**
117
+ * Pack-only: sibling sort comparator. Defaults to `(a, b) => b.value - a.value`
118
+ * (descending by value, which gives a tighter pack). Set to `null` to
119
+ * leave d3's input order. Ignored in non-pack modes.
120
+ */
121
+ sort?: ((a: {
122
+ value?: number;
123
+ }, b: {
124
+ value?: number;
125
+ }) => number) | null;
126
+ }
127
+
128
+ /**
129
+ * `D3HierarchyLayout` — `Layout` for `@invana/graph` that wraps
130
+ * `d3-hierarchy`'s `tree()` / `cluster()` / `pack()` algorithms (with optional
131
+ * polar projection for radial variants).
132
+ *
133
+ * One-shot synchronous: `apply()` snapshots the store, computes positions in
134
+ * a single pass, bulk-writes them back, emits `start` → `tick` → `end`, and
135
+ * resolves. There is no tick loop — radial / tidy / pack layouts all have a
136
+ * closed-form solution.
137
+ *
138
+ * Tree topology is derived from edges. Each `edge.source → edge.target` is
139
+ * read as "source is parent of target". The snapshot must form a single
140
+ * tree (one root, every non-root has exactly one parent, no cycles); the
141
+ * layout throws otherwise.
142
+ *
143
+ * Pack mode is special: in addition to writing positions, it writes a
144
+ * per-node `data.size = 2 * r` so the renderer can draw each node at the
145
+ * pack-computed diameter. The other modes leave node sizes alone.
146
+ *
147
+ * @example
148
+ * const layout = new D3HierarchyLayout({ mode: 'radial-tree', radius: 400 });
149
+ * await layout.apply(graphLayer);
150
+ */
151
+
152
+ declare class D3HierarchyLayout extends Layout<GraphLayer> {
153
+ private readonly opts;
154
+ /** True while a run is active. Guards `stop()` so `end` only fires once. */
155
+ private running;
156
+ constructor(opts?: D3HierarchyLayoutOptions);
157
+ /**
158
+ * Run the layout against `layer`. Resolves after the single position pass
159
+ * has been written to the store. Lifecycle events fire in order:
160
+ * `start` → `tick` (once) → `end`.
161
+ */
162
+ apply(layer: GraphLayer): Promise<void>;
163
+ /** Cancel a run. The synchronous body of `apply()` rarely yields control
164
+ * long enough for this to fire, but it keeps the API contract symmetric
165
+ * with iterative layouts. */
166
+ stop(): void;
167
+ private resolveRoot;
168
+ }
169
+
170
+ export { type CartesianOrientation, D3HierarchyLayout, type D3HierarchyLayoutMode, type D3HierarchyLayoutOptions, type SeparationFn };
package/dist/index.js ADDED
@@ -0,0 +1,244 @@
1
+ import { hierarchy, partition, pack, cluster, tree } from 'd3-hierarchy';
2
+ import { Layout } from '@invana/canvas';
3
+
4
+ // src/D3HierarchyLayout.ts
5
+ var DEFAULT_MODE = "radial-tree";
6
+ var DEFAULT_RADIUS = 400;
7
+ var DEFAULT_CARTESIAN_SIZE = [640, 480];
8
+ var DEFAULT_PACK_SIZE = [800, 800];
9
+ var defaultPackValue = (n) => {
10
+ if (n.data && typeof n.data === "object" && "value" in n.data) {
11
+ const v = n.data.value;
12
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
13
+ }
14
+ return 1;
15
+ };
16
+ var D3HierarchyLayout = class extends Layout {
17
+ opts;
18
+ /** True while a run is active. Guards `stop()` so `end` only fires once. */
19
+ running = false;
20
+ constructor(opts = {}) {
21
+ super();
22
+ this.opts = opts;
23
+ }
24
+ /**
25
+ * Run the layout against `layer`. Resolves after the single position pass
26
+ * has been written to the store. Lifecycle events fire in order:
27
+ * `start` → `tick` (once) → `end`.
28
+ */
29
+ async apply(layer) {
30
+ this.stop();
31
+ const store = layer.store;
32
+ const ids = [];
33
+ const nodeById = /* @__PURE__ */ new Map();
34
+ for (const n of store.nodes()) {
35
+ ids.push(n.id);
36
+ nodeById.set(n.id, { id: n.id, data: n.data });
37
+ }
38
+ if (ids.length === 0) return;
39
+ const parentCount = /* @__PURE__ */ new Map();
40
+ for (const id of ids) parentCount.set(id, 0);
41
+ for (const e of store.edges()) {
42
+ const parent = nodeById.get(e.source);
43
+ const child = nodeById.get(e.target);
44
+ if (!parent || !child) {
45
+ throw new Error(
46
+ `D3HierarchyLayout: edge "${e.id}" references unknown endpoint(s) (source="${e.source}", target="${e.target}")`
47
+ );
48
+ }
49
+ parent.children = parent.children ?? [];
50
+ parent.children.push(child);
51
+ parentCount.set(e.target, (parentCount.get(e.target) ?? 0) + 1);
52
+ }
53
+ const root = this.resolveRoot(ids, parentCount, nodeById);
54
+ const mode = this.opts.mode ?? DEFAULT_MODE;
55
+ const isRadial = mode === "radial-tree" || mode === "radial-cluster";
56
+ const isCluster = mode === "cluster" || mode === "radial-cluster";
57
+ const isPack = mode === "pack";
58
+ const isSunburst = mode === "sunburst";
59
+ const h = hierarchy(root, (d) => d.children);
60
+ if (isSunburst) {
61
+ const valueFn = this.opts.value ?? defaultPackValue;
62
+ h.sum((d) => valueFn(d));
63
+ const sortFn = this.opts.sort === void 0 ? (a, b) => (b.value ?? 0) - (a.value ?? 0) : this.opts.sort;
64
+ if (sortFn !== null) h.sort(sortFn);
65
+ const radius = this.opts.radius ?? DEFAULT_RADIUS;
66
+ const partitionFn = partition().size([2 * Math.PI, radius * radius]);
67
+ partitionFn(h);
68
+ } else if (isPack) {
69
+ const valueFn = this.opts.value ?? defaultPackValue;
70
+ h.sum((d) => valueFn(d));
71
+ const sortFn = this.opts.sort === void 0 ? (a, b) => (b.value ?? 0) - (a.value ?? 0) : this.opts.sort;
72
+ if (sortFn !== null) h.sort(sortFn);
73
+ const packFn = pack().size(this.opts.size ?? DEFAULT_PACK_SIZE).padding(this.opts.padding ?? 0);
74
+ packFn(h);
75
+ } else {
76
+ const layoutFn = isCluster ? cluster() : tree();
77
+ if (this.opts.nodeSize !== void 0) {
78
+ layoutFn.nodeSize(this.opts.nodeSize);
79
+ } else if (isRadial) {
80
+ layoutFn.size([2 * Math.PI, this.opts.radius ?? DEFAULT_RADIUS]);
81
+ } else {
82
+ layoutFn.size(this.opts.size ?? DEFAULT_CARTESIAN_SIZE);
83
+ }
84
+ if (this.opts.separation !== void 0) {
85
+ layoutFn.separation(this.opts.separation);
86
+ }
87
+ layoutFn(h);
88
+ }
89
+ const cx = this.opts.center?.x ?? 0;
90
+ const cy = this.opts.center?.y ?? 0;
91
+ const rootEpsilon = isRadial ? (this.opts.radius ?? DEFAULT_RADIUS) * 1e-3 : 0;
92
+ const positions = /* @__PURE__ */ new Map();
93
+ const sizes = /* @__PURE__ */ new Map();
94
+ const arcs = /* @__PURE__ */ new Map();
95
+ h.each((node) => {
96
+ let x;
97
+ let y;
98
+ if (isSunburst) {
99
+ const p = node;
100
+ arcs.set(node.data.id, {
101
+ innerR: Math.sqrt(p.y0 ?? 0),
102
+ outerR: Math.sqrt(p.y1 ?? 0),
103
+ startAngle: (p.x0 ?? 0) - Math.PI / 2,
104
+ endAngle: (p.x1 ?? 0) - Math.PI / 2
105
+ });
106
+ x = 0;
107
+ y = 0;
108
+ } else if (isPack) {
109
+ const packed = node;
110
+ const [w, hSize] = this.opts.size ?? DEFAULT_PACK_SIZE;
111
+ x = (node.x ?? 0) - w / 2;
112
+ y = (node.y ?? 0) - hSize / 2;
113
+ sizes.set(node.data.id, 2 * (packed.r ?? 0));
114
+ } else if (isRadial) {
115
+ const angle = node.x ?? 0;
116
+ const r = node.y === 0 ? rootEpsilon : node.y ?? 0;
117
+ x = r * Math.cos(angle - Math.PI / 2);
118
+ y = r * Math.sin(angle - Math.PI / 2);
119
+ } else {
120
+ const orientation = this.opts.orientation ?? "vertical";
121
+ const w = this.opts.size?.[0] ?? DEFAULT_CARTESIAN_SIZE[0];
122
+ const breadth = (node.x ?? 0) - w / 2;
123
+ const depth = node.y ?? 0;
124
+ if (orientation === "horizontal") {
125
+ x = depth;
126
+ y = breadth;
127
+ } else {
128
+ x = breadth;
129
+ y = depth;
130
+ }
131
+ }
132
+ positions.set(node.data.id, [x + cx, y + cy]);
133
+ });
134
+ this.running = true;
135
+ this.events.emit("start", {});
136
+ const buffer = new Float32Array(ids.length * 2);
137
+ for (let i = 0, j = 0; i < ids.length; i++, j += 2) {
138
+ const p = positions.get(ids[i]);
139
+ if (p) {
140
+ buffer[j] = p[0];
141
+ buffer[j + 1] = p[1];
142
+ }
143
+ }
144
+ if (isPack) {
145
+ store.batch(() => {
146
+ store.setPositionsBulk(ids, buffer);
147
+ for (const id of ids) {
148
+ const diameter = sizes.get(id);
149
+ if (diameter === void 0) continue;
150
+ const existing = store.getNode(id);
151
+ if (!existing) continue;
152
+ const existingStyle = existing.style && typeof existing.style === "object" ? existing.style : {};
153
+ store.updateNode(id, {
154
+ style: {
155
+ ...existingStyle,
156
+ shape: { kind: "circle", radius: diameter / 2 }
157
+ }
158
+ });
159
+ }
160
+ });
161
+ } else if (isSunburst) {
162
+ store.batch(() => {
163
+ store.setPositionsBulk(ids, buffer);
164
+ for (const id of ids) {
165
+ const arc = arcs.get(id);
166
+ if (arc === void 0) continue;
167
+ const existing = store.getNode(id);
168
+ if (!existing) continue;
169
+ const existingStyle = existing.style && typeof existing.style === "object" ? existing.style : {};
170
+ store.updateNode(id, {
171
+ style: {
172
+ ...existingStyle,
173
+ shape: {
174
+ kind: "arc",
175
+ innerR: arc.innerR,
176
+ outerR: arc.outerR,
177
+ startAngle: arc.startAngle,
178
+ endAngle: arc.endAngle
179
+ }
180
+ }
181
+ });
182
+ }
183
+ });
184
+ } else {
185
+ store.setPositionsBulk(ids, buffer);
186
+ }
187
+ this.events.emit("tick", {});
188
+ if (this.running) {
189
+ this.running = false;
190
+ this.events.emit("end", { reason: "completed" });
191
+ }
192
+ }
193
+ /** Cancel a run. The synchronous body of `apply()` rarely yields control
194
+ * long enough for this to fire, but it keeps the API contract symmetric
195
+ * with iterative layouts. */
196
+ stop() {
197
+ if (!this.running) return;
198
+ this.running = false;
199
+ this.events.emit("end", { reason: "stopped" });
200
+ }
201
+ // ─── internals ────────────────────────────────────────────────────────
202
+ resolveRoot(ids, parentCount, nodeById) {
203
+ if (this.opts.rootId !== void 0) {
204
+ const node = nodeById.get(this.opts.rootId);
205
+ if (!node) {
206
+ throw new Error(`D3HierarchyLayout: rootId "${this.opts.rootId}" not found`);
207
+ }
208
+ return node;
209
+ }
210
+ let rootId = null;
211
+ let multipleRoots = false;
212
+ for (const id of ids) {
213
+ if (parentCount.get(id) === 0) {
214
+ if (rootId === null) rootId = id;
215
+ else {
216
+ multipleRoots = true;
217
+ break;
218
+ }
219
+ }
220
+ }
221
+ if (rootId === null) {
222
+ throw new Error(
223
+ "D3HierarchyLayout: no root found \u2014 the snapshot has no node without an incoming edge (cycle?)"
224
+ );
225
+ }
226
+ if (multipleRoots) {
227
+ throw new Error(
228
+ "D3HierarchyLayout: snapshot has more than one root. Pass `rootId` to disambiguate."
229
+ );
230
+ }
231
+ for (const [id, count] of parentCount) {
232
+ if (id !== rootId && count !== 1) {
233
+ throw new Error(
234
+ `D3HierarchyLayout: node "${id}" has ${count} parents \u2014 input must be a tree (each non-root node has exactly one parent).`
235
+ );
236
+ }
237
+ }
238
+ return nodeById.get(rootId);
239
+ }
240
+ };
241
+
242
+ export { D3HierarchyLayout };
243
+ //# sourceMappingURL=index.js.map
244
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/D3HierarchyLayout.ts"],"names":["d3hierarchy","d3partition","d3pack","d3cluster","d3tree"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `D3HierarchyLayout` — `Layout` for `@invana/graph` that wraps\n * `d3-hierarchy`'s `tree()` / `cluster()` / `pack()` algorithms (with optional\n * polar projection for radial variants).\n *\n * One-shot synchronous: `apply()` snapshots the store, computes positions in\n * a single pass, bulk-writes them back, emits `start` → `tick` → `end`, and\n * resolves. There is no tick loop — radial / tidy / pack layouts all have a\n * closed-form solution.\n *\n * Tree topology is derived from edges. Each `edge.source → edge.target` is\n * read as \"source is parent of target\". The snapshot must form a single\n * tree (one root, every non-root has exactly one parent, no cycles); the\n * layout throws otherwise.\n *\n * Pack mode is special: in addition to writing positions, it writes a\n * per-node `data.size = 2 * r` so the renderer can draw each node at the\n * pack-computed diameter. The other modes leave node sizes alone.\n *\n * @example\n * const layout = new D3HierarchyLayout({ mode: 'radial-tree', radius: 400 });\n * await layout.apply(graphLayer);\n */\n\nimport {\n cluster as d3cluster,\n hierarchy as d3hierarchy,\n pack as d3pack,\n partition as d3partition,\n tree as d3tree,\n} from 'd3-hierarchy';\n\nimport { Layout } from '@invana/canvas';\nimport type { GraphLayer } from '@invana/graph';\n\nimport type { D3HierarchyLayoutOptions, D3HierarchyLayoutMode } from './types';\n\ninterface TreeNode {\n id: string;\n /** Original `GraphNode.data` payload, carried through so pack-mode's\n * `value` accessor can read user data without an extra lookup. */\n data?: unknown;\n children?: TreeNode[];\n}\n\nconst DEFAULT_MODE: D3HierarchyLayoutMode = 'radial-tree';\nconst DEFAULT_RADIUS = 400;\nconst DEFAULT_CARTESIAN_SIZE: [number, number] = [640, 480];\nconst DEFAULT_PACK_SIZE: [number, number] = [800, 800];\n\n/**\n * Default `value` accessor for pack mode — reads `data.value` if present,\n * otherwise treats the node as having `value: 1` (so a tree with no `value`\n * field still packs sensibly, with all leaves the same size).\n */\nconst defaultPackValue = (n: { data?: unknown }): number => {\n if (n.data && typeof n.data === 'object' && 'value' in n.data) {\n const v = (n.data as { value?: unknown }).value;\n if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;\n }\n return 1;\n};\n\nexport class D3HierarchyLayout extends Layout<GraphLayer> {\n private readonly opts: D3HierarchyLayoutOptions;\n /** True while a run is active. Guards `stop()` so `end` only fires once. */\n private running = false;\n\n constructor(opts: D3HierarchyLayoutOptions = {}) {\n super();\n this.opts = opts;\n }\n\n /**\n * Run the layout against `layer`. Resolves after the single position pass\n * has been written to the store. Lifecycle events fire in order:\n * `start` → `tick` (once) → `end`.\n */\n async apply(layer: GraphLayer): Promise<void> {\n this.stop();\n const store = layer.store;\n\n // 1. Snapshot store → ids + parent/child map. Build TreeNode objects up\n // front (one per node) so we can stitch them by reference instead of\n // looking ids up during the recursive build. The original `data`\n // payload travels along so pack-mode's `value` accessor can read it.\n const ids: string[] = [];\n const nodeById = new Map<string, TreeNode>();\n for (const n of store.nodes()) {\n ids.push(n.id);\n nodeById.set(n.id, { id: n.id, data: n.data });\n }\n if (ids.length === 0) return;\n\n // Track parent count per node to validate the snapshot is a tree.\n const parentCount = new Map<string, number>();\n for (const id of ids) parentCount.set(id, 0);\n for (const e of store.edges()) {\n const parent = nodeById.get(e.source);\n const child = nodeById.get(e.target);\n if (!parent || !child) {\n throw new Error(\n `D3HierarchyLayout: edge \"${e.id}\" references unknown endpoint(s) (` +\n `source=\"${e.source}\", target=\"${e.target}\")`,\n );\n }\n parent.children = parent.children ?? [];\n parent.children.push(child);\n parentCount.set(e.target, (parentCount.get(e.target) ?? 0) + 1);\n }\n\n // 2. Determine root.\n const root = this.resolveRoot(ids, parentCount, nodeById);\n\n // 3. Build d3 hierarchy + run the chosen layout.\n const mode = this.opts.mode ?? DEFAULT_MODE;\n const isRadial = mode === 'radial-tree' || mode === 'radial-cluster';\n const isCluster = mode === 'cluster' || mode === 'radial-cluster';\n const isPack = mode === 'pack';\n const isSunburst = mode === 'sunburst';\n\n const h = d3hierarchy<TreeNode>(root, (d) => d.children);\n\n if (isSunburst) {\n // Sunburst = polar `d3.partition`. Same value/sort plumbing as pack:\n // each leaf contributes its `value` (default reads `data.value`);\n // siblings sort descending by value for a stable visual order. The\n // partition layout assigns `(x0, x1, y0, y1)` to every node, with\n // `x` in [0, 2π] (angle, 0 = 12 o'clock running clockwise — d3's\n // convention) and `y` in [0, radius²] (the squared-radius form gives\n // every ring equal area when we take `sqrt(y)` below).\n const valueFn = this.opts.value ?? defaultPackValue;\n h.sum((d) => valueFn(d));\n const sortFn =\n this.opts.sort === undefined\n ? (a: { value?: number }, b: { value?: number }): number =>\n (b.value ?? 0) - (a.value ?? 0)\n : this.opts.sort;\n if (sortFn !== null) h.sort(sortFn);\n\n const radius = this.opts.radius ?? DEFAULT_RADIUS;\n const partitionFn = d3partition<TreeNode>().size([2 * Math.PI, radius * radius]);\n partitionFn(h);\n } else if (isPack) {\n // Pack needs an accumulated value per node. `.sum(fn)` walks bottom-up,\n // so internal nodes get the sum of their leaves. Sort siblings by\n // descending value (d3's recommended default) for tighter packing —\n // unless the caller explicitly passed `sort: null` to preserve input\n // order.\n const valueFn = this.opts.value ?? defaultPackValue;\n h.sum((d) => valueFn(d));\n const sortFn =\n this.opts.sort === undefined\n ? (a: { value?: number }, b: { value?: number }): number =>\n (b.value ?? 0) - (a.value ?? 0)\n : this.opts.sort;\n if (sortFn !== null) h.sort(sortFn);\n\n const packFn = d3pack<TreeNode>()\n .size(this.opts.size ?? DEFAULT_PACK_SIZE)\n .padding(this.opts.padding ?? 0);\n packFn(h);\n } else {\n const layoutFn = isCluster ? d3cluster<TreeNode>() : d3tree<TreeNode>();\n if (this.opts.nodeSize !== undefined) {\n layoutFn.nodeSize(this.opts.nodeSize);\n } else if (isRadial) {\n // Radial layouts use [angle, radius] = [2π, radius] in polar space.\n // The radius is then projected to Cartesian distance from origin.\n layoutFn.size([2 * Math.PI, this.opts.radius ?? DEFAULT_RADIUS]);\n } else {\n layoutFn.size(this.opts.size ?? DEFAULT_CARTESIAN_SIZE);\n }\n if (this.opts.separation !== undefined) {\n layoutFn.separation(this.opts.separation);\n }\n layoutFn(h);\n }\n\n // 4. Project to (x, y). For radial modes, h.x is the angle and h.y is\n // the radius — convert with the standard polar projection. The\n // `θ − π/2` rotation puts the root at the origin and the first child\n // pointing up, matching the d3 example.\n //\n // Sub-pixel root nudge: in radial modes the root naturally projects\n // to (0, 0). A polar pathStyle (e.g. `bump-radial`) can't recover an\n // angle from a zero-radius source, so root edges would degenerate to\n // straight radial lines. Nudging the root a fraction of a pixel in\n // the direction of its tree-assigned angle (`h.x`) lets `atan2`\n // succeed; the offset is imperceptible against typical 3–6px node\n // glyphs but unblocks the polar curve formula.\n const cx = this.opts.center?.x ?? 0;\n const cy = this.opts.center?.y ?? 0;\n const rootEpsilon = isRadial ? (this.opts.radius ?? DEFAULT_RADIUS) * 0.001 : 0;\n const positions = new Map<string, [number, number]>();\n /** Pack-only: per-node diameter the layout assigns. Empty for other modes. */\n const sizes = new Map<string, number>();\n /** Sunburst-only: per-node arc params. Empty for other modes. */\n const arcs = new Map<\n string,\n { innerR: number; outerR: number; startAngle: number; endAngle: number }\n >();\n h.each((node) => {\n let x: number;\n let y: number;\n if (isSunburst) {\n // Every sunburst node renders as an arc centred at the same origin —\n // the per-node geometry is the arc spec, not a translated position.\n // d3.partition writes (x0, x1, y0, y1) with `x` in [0, 2π] (angle,\n // 0 = 12 o'clock) and `y` in [0, radius²]. Convert to ArcShape's\n // convention (0 = 3 o'clock, increasing clockwise on screen) by\n // subtracting π/2, and take `sqrt(y)` so each ring covers equal area\n // per unit `value` — d3's standard sunburst projection.\n const p = node as typeof node & {\n x0?: number;\n x1?: number;\n y0?: number;\n y1?: number;\n };\n arcs.set(node.data.id, {\n innerR: Math.sqrt(p.y0 ?? 0),\n outerR: Math.sqrt(p.y1 ?? 0),\n startAngle: (p.x0 ?? 0) - Math.PI / 2,\n endAngle: (p.x1 ?? 0) - Math.PI / 2,\n });\n x = 0;\n y = 0;\n } else if (isPack) {\n // Pack writes node.x / node.y / node.r in [0, w] × [0, h]. Centre\n // the pack on the world origin so a `fitContent` frames it\n // naturally without the caller knowing the pack viewport. `r` is\n // added by d3.pack() and isn't on the base HierarchyNode type, so\n // we cast.\n const packed = node as typeof node & { r?: number };\n const [w, hSize] = this.opts.size ?? DEFAULT_PACK_SIZE;\n x = (node.x ?? 0) - w / 2;\n y = (node.y ?? 0) - hSize / 2;\n // Diameter goes back to the renderer via store.updateNode below.\n sizes.set(node.data.id, 2 * (packed.r ?? 0));\n } else if (isRadial) {\n const angle = node.x ?? 0;\n // For the root, `r` is 0 → use `rootEpsilon` so the projected point\n // sits a sub-pixel distance off origin in the angle direction. See\n // the comment above on why the path style needs this.\n const r = node.y === 0 ? rootEpsilon : node.y ?? 0;\n x = r * Math.cos(angle - Math.PI / 2);\n y = r * Math.sin(angle - Math.PI / 2);\n } else {\n // Cartesian d3 layouts produce x along the breadth axis (sibling\n // separation) and y along the depth axis (root → leaves). For\n // 'vertical' that maps to (cartesian_x = breadth, cartesian_y = depth).\n // For 'horizontal' the depth axis runs left→right, so we swap:\n // (cartesian_x = depth, cartesian_y = breadth). Centering on the\n // breadth axis lets a fresh `fitContent` frame the tree without the\n // caller knowing the layout's bounds.\n const orientation = this.opts.orientation ?? 'vertical';\n const w = this.opts.size?.[0] ?? DEFAULT_CARTESIAN_SIZE[0];\n const breadth = (node.x ?? 0) - w / 2;\n const depth = node.y ?? 0;\n if (orientation === 'horizontal') {\n x = depth;\n y = breadth;\n } else {\n x = breadth;\n y = depth;\n }\n }\n positions.set(node.data.id, [x + cx, y + cy]);\n });\n\n // 5. Mark running, fire start, bulk-write, fire tick + end.\n this.running = true;\n this.events.emit('start', {});\n\n const buffer = new Float32Array(ids.length * 2);\n for (let i = 0, j = 0; i < ids.length; i++, j += 2) {\n const p = positions.get(ids[i]!);\n if (p) {\n buffer[j] = p[0];\n buffer[j + 1] = p[1];\n }\n }\n if (isPack) {\n // Pack writes per-node sizes in addition to positions. Wrap both in a\n // store batch so the renderer sees a single coalesced flush instead of\n // N separate node:update events firing renders. Size is projected onto\n // `style.shape` as a circle radius.\n store.batch(() => {\n store.setPositionsBulk(ids, buffer);\n for (const id of ids) {\n const diameter = sizes.get(id);\n if (diameter === undefined) continue;\n const existing = store.getNode(id);\n if (!existing) continue;\n const existingStyle =\n existing.style && typeof existing.style === 'object'\n ? (existing.style as Record<string, unknown>)\n : {};\n store.updateNode(id, {\n style: {\n ...existingStyle,\n shape: { kind: 'circle', radius: diameter / 2 },\n },\n });\n }\n });\n } else if (isSunburst) {\n // Sunburst writes arc geometry onto each node's `style.shape`. The\n // renderer reads the `kind: 'arc'` discriminant + (innerR / outerR /\n // startAngle / endAngle) to instantiate the right ArcSpec. Wrap in a\n // batch for the same reason as pack — one coalesced flush.\n store.batch(() => {\n store.setPositionsBulk(ids, buffer);\n for (const id of ids) {\n const arc = arcs.get(id);\n if (arc === undefined) continue;\n const existing = store.getNode(id);\n if (!existing) continue;\n const existingStyle =\n existing.style && typeof existing.style === 'object'\n ? (existing.style as Record<string, unknown>)\n : {};\n store.updateNode(id, {\n style: {\n ...existingStyle,\n shape: {\n kind: 'arc',\n innerR: arc.innerR,\n outerR: arc.outerR,\n startAngle: arc.startAngle,\n endAngle: arc.endAngle,\n },\n },\n });\n }\n });\n } else {\n store.setPositionsBulk(ids, buffer);\n }\n this.events.emit('tick', {});\n\n if (this.running) {\n this.running = false;\n this.events.emit('end', { reason: 'completed' });\n }\n }\n\n /** Cancel a run. The synchronous body of `apply()` rarely yields control\n * long enough for this to fire, but it keeps the API contract symmetric\n * with iterative layouts. */\n stop(): void {\n if (!this.running) return;\n this.running = false;\n this.events.emit('end', { reason: 'stopped' });\n }\n\n // ─── internals ────────────────────────────────────────────────────────\n\n private resolveRoot(\n ids: string[],\n parentCount: Map<string, number>,\n nodeById: Map<string, TreeNode>,\n ): TreeNode {\n if (this.opts.rootId !== undefined) {\n const node = nodeById.get(this.opts.rootId);\n if (!node) {\n throw new Error(`D3HierarchyLayout: rootId \"${this.opts.rootId}\" not found`);\n }\n return node;\n }\n let rootId: string | null = null;\n let multipleRoots = false;\n for (const id of ids) {\n if (parentCount.get(id) === 0) {\n if (rootId === null) rootId = id;\n else {\n multipleRoots = true;\n break;\n }\n }\n }\n if (rootId === null) {\n throw new Error(\n 'D3HierarchyLayout: no root found — the snapshot has no node without an incoming edge (cycle?)',\n );\n }\n if (multipleRoots) {\n throw new Error(\n 'D3HierarchyLayout: snapshot has more than one root. Pass `rootId` to disambiguate.',\n );\n }\n for (const [id, count] of parentCount) {\n if (id !== rootId && count !== 1) {\n throw new Error(\n `D3HierarchyLayout: node \"${id}\" has ${count} parents — input must be a tree (each non-root node has exactly one parent).`,\n );\n }\n }\n return nodeById.get(rootId)!;\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@invana/graph-layout-d3-hierarchy",
3
+ "version": "0.0.2",
4
+ "description": "D3 hierarchy (tree / cluster / radial) 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-hierarchy": "^3.1.2"
15
+ },
16
+ "peerDependencies": {
17
+ "@invana/canvas": "0.0.2",
18
+ "@invana/graph": "0.0.2"
19
+ },
20
+ "devDependencies": {
21
+ "@types/d3-hierarchy": "^3.1.7",
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-hierarchy",
35
+ "tree",
36
+ "cluster",
37
+ "radial"
38
+ ],
39
+ "license": "Apache-2.0",
40
+ "module": "./dist/index.js",
41
+ "exports": {
42
+ ".": {
43
+ "types": "./dist/index.d.ts",
44
+ "import": "./dist/index.js",
45
+ "default": "./dist/index.js"
46
+ }
47
+ },
48
+ "files": [
49
+ "dist"
50
+ ],
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "scripts": {
55
+ "build": "tsup",
56
+ "dev": "tsup --watch",
57
+ "lint": "eslint src/",
58
+ "check-types": "tsc --noEmit",
59
+ "clean": "rm -rf dist",
60
+ "test": "vitest run"
61
+ }
62
+ }