@invana/graph-layout-d3-sankey 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,122 @@
1
+ import { Layout } from '@invana/canvas';
2
+ import { GraphLayer } from '@invana/graph';
3
+ import { SankeyNodeMinimal, SankeyLinkMinimal } from 'd3-sankey';
4
+
5
+ /**
6
+ * Column-alignment strategy. Mirrors d3-sankey's `nodeAlign` setters:
7
+ *
8
+ * - `'left'` — push every node as far left as possible (depth = longest path
9
+ * from a source). Sources column together on the left, sinks float toward
10
+ * the right depending on their depth.
11
+ * - `'right'` — mirror of `'left'`: sinks together on the right, sources
12
+ * float toward the left.
13
+ * - `'center'` — average of left and right; tidy when the graph is roughly
14
+ * symmetric.
15
+ * - `'justify'` (default) — sources on the left, sinks on the right; every
16
+ * node is pushed to the latest column it can occupy without rerouting.
17
+ * This is the d3 example's default and the one to pick first.
18
+ */
19
+ type D3SankeyNodeAlign = 'left' | 'right' | 'center' | 'justify';
20
+ /**
21
+ * Working types used by the layout when handing data to `d3-sankey`. They
22
+ * mirror the original `GraphNode` / `GraphEdge` ids so we can map the
23
+ * `d3-sankey` output back onto the store after the run.
24
+ */
25
+ interface SankeyNodeRef extends SankeyNodeMinimal<SankeyNodeRef, SankeyLinkRef> {
26
+ id: string;
27
+ }
28
+ interface SankeyLinkRef extends SankeyLinkMinimal<SankeyNodeRef, SankeyLinkRef> {
29
+ id: string;
30
+ source: string | SankeyNodeRef;
31
+ target: string | SankeyNodeRef;
32
+ value: number;
33
+ }
34
+ /**
35
+ * `D3SankeyLayout` options.
36
+ *
37
+ * Mirrors `d3-sankey`'s configuration surface 1:1. All fields are optional;
38
+ * defaults follow d3's defaults except `size`, which defaults to
39
+ * `[1000, 600]` so a fresh layout has somewhere to draw.
40
+ */
41
+ interface D3SankeyLayoutOptions {
42
+ /**
43
+ * Viewport size `[width, height]` the layout fills. Translated to
44
+ * `d3.sankey().extent([[0, 0], [width, height]])`. Default `[1000, 600]`.
45
+ */
46
+ size?: [number, number];
47
+ /** Column rectangle width in pixels. Default `24` (d3's default). */
48
+ nodeWidth?: number;
49
+ /** Vertical padding between nodes within a column. Default `8` (d3's default). */
50
+ nodePadding?: number;
51
+ /** Relaxation iterations. More = tighter packing, slower run. Default `6`. */
52
+ iterations?: number;
53
+ /** Column-alignment strategy. See {@link D3SankeyNodeAlign}. Default `'justify'`. */
54
+ nodeAlign?: D3SankeyNodeAlign;
55
+ /**
56
+ * Sibling node sort within a column. `null` preserves d3's default
57
+ * (ascending by incoming flow); `undefined` falls back to the default;
58
+ * a function sorts explicitly.
59
+ */
60
+ nodeSort?: ((a: SankeyNodeRef, b: SankeyNodeRef) => number) | null;
61
+ /**
62
+ * Link sort within each node's source-side / target-side stack. `null`
63
+ * preserves d3's default order; `undefined` falls back to the default.
64
+ */
65
+ linkSort?: ((a: SankeyLinkRef, b: SankeyLinkRef) => number) | null;
66
+ /**
67
+ * Translate the projected coordinates by `(x, y)` after layout. Default
68
+ * `{ x: 0, y: 0 }`. Useful for centring the diagram around the world
69
+ * origin so a fresh `fitContent` frames it naturally.
70
+ */
71
+ center?: {
72
+ x?: number;
73
+ y?: number;
74
+ };
75
+ }
76
+
77
+ /**
78
+ * `D3SankeyLayout` — `Layout` for `@invana/graph` that wraps `d3-sankey`.
79
+ *
80
+ * Treats the graph as a DAG of flows: edges carry a numeric `value` (read
81
+ * from `edge.data.value`), `d3-sankey` arranges nodes into columns and
82
+ * solves for vertical positions that minimise link crossings.
83
+ *
84
+ * One-shot synchronous: `apply()` snapshots the store, runs the sankey
85
+ * solver once, bulk-writes positions plus per-edge anchor opts, emits
86
+ * `start` → `tick` → `end`, and resolves. No tick loop.
87
+ *
88
+ * The layout writes:
89
+ * - per node: position (centre of the d3-sankey rect), and into `data`:
90
+ * `shape: 'rect'`, `size: rectWidth`, `height: rectHeight`.
91
+ * - per edge: into `data`: `strokeWidth: link.width`,
92
+ * `pathType: 'bump-horizontal'`, plus per-endpoint anchor opts
93
+ * (`sourceAnchor: 'edge-port'` with `{ side: 'right', offset }`,
94
+ * `targetAnchor: 'edge-port'` with `{ side: 'left', offset }`) so the
95
+ * ribbons attach at the correct y on each rect face.
96
+ *
97
+ * Pair with `edge: { style: { shape: { pathType: 'bump-horizontal' }, strokeAlpha: 0.5, arrowTargetShape: 'none' } }`
98
+ * on the `GraphLayer` to reproduce d3-sankey's SVG appearance.
99
+ *
100
+ * @example
101
+ * const layout = new D3SankeyLayout({ size: [1200, 720], nodeWidth: 15, nodePadding: 10 });
102
+ * await layout.apply(graphLayer);
103
+ */
104
+
105
+ declare class D3SankeyLayout extends Layout<GraphLayer> {
106
+ private readonly opts;
107
+ /** True while a run is active. Guards `stop()` so `end` only fires once. */
108
+ private running;
109
+ constructor(opts?: D3SankeyLayoutOptions);
110
+ /**
111
+ * Run the layout against `layer`. Resolves once positions and per-edge
112
+ * hints have been written. Lifecycle events fire in order:
113
+ * `start` → `tick` (once) → `end`.
114
+ */
115
+ apply(layer: GraphLayer): Promise<void>;
116
+ /** Cancel a run. The synchronous body of `apply()` rarely yields long
117
+ * enough for this to fire, but it keeps the contract symmetric with
118
+ * iterative layouts. */
119
+ stop(): void;
120
+ }
121
+
122
+ export { D3SankeyLayout, type D3SankeyLayoutOptions, type D3SankeyNodeAlign };
package/dist/index.js ADDED
@@ -0,0 +1,145 @@
1
+ import { sankey, sankeyJustify, sankeyCenter, sankeyRight, sankeyLeft } from 'd3-sankey';
2
+ import { Layout } from '@invana/canvas';
3
+
4
+ // src/D3SankeyLayout.ts
5
+ var DEFAULT_SIZE = [1e3, 600];
6
+ var NODE_ALIGN_FNS = {
7
+ left: sankeyLeft,
8
+ right: sankeyRight,
9
+ center: sankeyCenter,
10
+ justify: sankeyJustify
11
+ };
12
+ function pickAlign(name) {
13
+ return NODE_ALIGN_FNS[name ?? "justify"];
14
+ }
15
+ var D3SankeyLayout = class extends Layout {
16
+ opts;
17
+ /** True while a run is active. Guards `stop()` so `end` only fires once. */
18
+ running = false;
19
+ constructor(opts = {}) {
20
+ super();
21
+ this.opts = opts;
22
+ }
23
+ /**
24
+ * Run the layout against `layer`. Resolves once positions and per-edge
25
+ * hints have been written. Lifecycle events fire in order:
26
+ * `start` → `tick` (once) → `end`.
27
+ */
28
+ async apply(layer) {
29
+ this.stop();
30
+ const store = layer.store;
31
+ const ids = [];
32
+ const nodes = [];
33
+ for (const n of store.nodes()) {
34
+ ids.push(n.id);
35
+ nodes.push({ id: n.id });
36
+ }
37
+ if (ids.length === 0) return;
38
+ const links = [];
39
+ for (const e of store.edges()) {
40
+ const data = e.data;
41
+ const value = data && typeof data.value === "number" ? data.value : void 0;
42
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) {
43
+ throw new Error(
44
+ `D3SankeyLayout: edge "${e.id}" is missing a positive numeric \`data.value\` \u2014 sankey needs per-link weights.`
45
+ );
46
+ }
47
+ links.push({
48
+ id: e.id,
49
+ source: e.source,
50
+ target: e.target,
51
+ value
52
+ });
53
+ }
54
+ const [w, h] = this.opts.size ?? DEFAULT_SIZE;
55
+ const sankey$1 = sankey().nodeId((d) => d.id).nodeAlign(pickAlign(this.opts.nodeAlign)).nodeWidth(this.opts.nodeWidth ?? 24).nodePadding(this.opts.nodePadding ?? 8).extent([
56
+ [0, 0],
57
+ [w, h]
58
+ ]);
59
+ if (this.opts.iterations !== void 0) sankey$1.iterations(this.opts.iterations);
60
+ if (this.opts.nodeSort !== void 0) sankey$1.nodeSort(this.opts.nodeSort);
61
+ if (this.opts.linkSort !== void 0) sankey$1.linkSort(this.opts.linkSort);
62
+ sankey$1({ nodes, links });
63
+ const cx = (this.opts.center?.x ?? 0) - w / 2;
64
+ const cy = (this.opts.center?.y ?? 0) - h / 2;
65
+ const buffer = new Float32Array(ids.length * 2);
66
+ const sizes = /* @__PURE__ */ new Map();
67
+ for (let i = 0; i < nodes.length; i++) {
68
+ const node = nodes[i];
69
+ const x0 = node.x0 ?? 0;
70
+ const x1 = node.x1 ?? 0;
71
+ const y0 = node.y0 ?? 0;
72
+ const y1 = node.y1 ?? 0;
73
+ const width = x1 - x0;
74
+ const height = y1 - y0;
75
+ const centreX = (x0 + x1) / 2 + cx;
76
+ const centreY = (y0 + y1) / 2 + cy;
77
+ buffer[i * 2] = centreX;
78
+ buffer[i * 2 + 1] = centreY;
79
+ sizes.set(node.id, { width, height, cx: centreX, cy: centreY });
80
+ }
81
+ this.running = true;
82
+ this.events.emit("start", {});
83
+ store.batch(() => {
84
+ store.setPositionsBulk(ids, buffer);
85
+ for (const id of ids) {
86
+ const size = sizes.get(id);
87
+ if (!size) continue;
88
+ const existing = store.getNode(id);
89
+ if (!existing) continue;
90
+ const existingStyle = existing.style && typeof existing.style === "object" ? existing.style : {};
91
+ store.updateNode(id, {
92
+ style: {
93
+ ...existingStyle,
94
+ shape: { kind: "rect", width: size.width, height: size.height }
95
+ }
96
+ });
97
+ }
98
+ for (const link of links) {
99
+ const srcId = typeof link.source === "string" ? link.source : link.source.id;
100
+ const tgtId = typeof link.target === "string" ? link.target : link.target.id;
101
+ const src = sizes.get(srcId);
102
+ const tgt = sizes.get(tgtId);
103
+ if (!src || !tgt) continue;
104
+ const linkWidth = link.width ?? 1;
105
+ const linkY0 = (link.y0 ?? 0) + cy;
106
+ const linkY1 = (link.y1 ?? 0) + cy;
107
+ const sourceOffset = linkY0 - src.cy;
108
+ const targetOffset = linkY1 - tgt.cy;
109
+ const existing = store.getEdge(link.id);
110
+ if (!existing) continue;
111
+ const existingStyle = existing.style && typeof existing.style === "object" ? existing.style : {};
112
+ store.updateEdge(link.id, {
113
+ style: {
114
+ ...existingStyle,
115
+ shape: {
116
+ pathType: "bump-horizontal",
117
+ sourceAnchor: "edge-port",
118
+ sourceAnchorOpts: { side: "right", offset: sourceOffset },
119
+ targetAnchor: "edge-port",
120
+ targetAnchorOpts: { side: "left", offset: targetOffset }
121
+ },
122
+ strokeWidth: Math.max(1, linkWidth)
123
+ }
124
+ });
125
+ }
126
+ });
127
+ this.events.emit("tick", {});
128
+ if (this.running) {
129
+ this.running = false;
130
+ this.events.emit("end", { reason: "completed" });
131
+ }
132
+ }
133
+ /** Cancel a run. The synchronous body of `apply()` rarely yields long
134
+ * enough for this to fire, but it keeps the contract symmetric with
135
+ * iterative layouts. */
136
+ stop() {
137
+ if (!this.running) return;
138
+ this.running = false;
139
+ this.events.emit("end", { reason: "stopped" });
140
+ }
141
+ };
142
+
143
+ export { D3SankeyLayout };
144
+ //# sourceMappingURL=index.js.map
145
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/D3SankeyLayout.ts"],"names":["sankey","d3sankey"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `D3SankeyLayout` — `Layout` for `@invana/graph` that wraps `d3-sankey`.\n *\n * Treats the graph as a DAG of flows: edges carry a numeric `value` (read\n * from `edge.data.value`), `d3-sankey` arranges nodes into columns and\n * solves for vertical positions that minimise link crossings.\n *\n * One-shot synchronous: `apply()` snapshots the store, runs the sankey\n * solver once, bulk-writes positions plus per-edge anchor opts, emits\n * `start` → `tick` → `end`, and resolves. No tick loop.\n *\n * The layout writes:\n * - per node: position (centre of the d3-sankey rect), and into `data`:\n * `shape: 'rect'`, `size: rectWidth`, `height: rectHeight`.\n * - per edge: into `data`: `strokeWidth: link.width`,\n * `pathType: 'bump-horizontal'`, plus per-endpoint anchor opts\n * (`sourceAnchor: 'edge-port'` with `{ side: 'right', offset }`,\n * `targetAnchor: 'edge-port'` with `{ side: 'left', offset }`) so the\n * ribbons attach at the correct y on each rect face.\n *\n * Pair with `edge: { style: { shape: { pathType: 'bump-horizontal' }, strokeAlpha: 0.5, arrowTargetShape: 'none' } }`\n * on the `GraphLayer` to reproduce d3-sankey's SVG appearance.\n *\n * @example\n * const layout = new D3SankeyLayout({ size: [1200, 720], nodeWidth: 15, nodePadding: 10 });\n * await layout.apply(graphLayer);\n */\n\nimport {\n sankey as d3sankey,\n sankeyCenter,\n sankeyJustify,\n sankeyLeft,\n sankeyRight,\n} from 'd3-sankey';\n\nimport { Layout } from '@invana/canvas';\nimport type { GraphLayer } from '@invana/graph';\n\nimport type {\n D3SankeyLayoutOptions,\n D3SankeyNodeAlign,\n SankeyLinkRef,\n SankeyNodeRef,\n} from './types';\n\nconst DEFAULT_SIZE: [number, number] = [1000, 600];\n\nconst NODE_ALIGN_FNS = {\n left: sankeyLeft,\n right: sankeyRight,\n center: sankeyCenter,\n justify: sankeyJustify,\n} as const;\n\nfunction pickAlign(name: D3SankeyNodeAlign | undefined): (typeof NODE_ALIGN_FNS)[keyof typeof NODE_ALIGN_FNS] {\n return NODE_ALIGN_FNS[name ?? 'justify'];\n}\n\nexport class D3SankeyLayout extends Layout<GraphLayer> {\n private readonly opts: D3SankeyLayoutOptions;\n /** True while a run is active. Guards `stop()` so `end` only fires once. */\n private running = false;\n\n constructor(opts: D3SankeyLayoutOptions = {}) {\n super();\n this.opts = opts;\n }\n\n /**\n * Run the layout against `layer`. Resolves once positions and per-edge\n * hints have been written. 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 → d3-sankey inputs. Sankey expects nodes with an\n // arbitrary opaque id and links with `{source, target, value}` —\n // string ids work directly via `nodeId`.\n const ids: string[] = [];\n const nodes: SankeyNodeRef[] = [];\n for (const n of store.nodes()) {\n ids.push(n.id);\n nodes.push({ id: n.id });\n }\n if (ids.length === 0) return;\n\n const links: SankeyLinkRef[] = [];\n for (const e of store.edges()) {\n const data = e.data as { value?: unknown } | undefined;\n const value = data && typeof data.value === 'number' ? data.value : undefined;\n if (value === undefined || !Number.isFinite(value) || value <= 0) {\n throw new Error(\n `D3SankeyLayout: edge \"${e.id}\" is missing a positive numeric \\`data.value\\` — sankey needs per-link weights.`,\n );\n }\n links.push({\n id: e.id,\n source: e.source,\n target: e.target,\n value,\n });\n }\n\n // 2. Configure and run d3-sankey.\n const [w, h] = this.opts.size ?? DEFAULT_SIZE;\n const sankey = d3sankey<SankeyNodeRef, SankeyLinkRef>()\n .nodeId((d) => d.id)\n .nodeAlign(pickAlign(this.opts.nodeAlign))\n .nodeWidth(this.opts.nodeWidth ?? 24)\n .nodePadding(this.opts.nodePadding ?? 8)\n .extent([\n [0, 0],\n [w, h],\n ]);\n if (this.opts.iterations !== undefined) sankey.iterations(this.opts.iterations);\n if (this.opts.nodeSort !== undefined) sankey.nodeSort(this.opts.nodeSort);\n if (this.opts.linkSort !== undefined) sankey.linkSort(this.opts.linkSort);\n\n sankey({ nodes, links });\n\n // 3. Project results back onto the store. Centre the diagram around the\n // world origin so `fitContent` frames it naturally without the caller\n // knowing the layout viewport.\n const cx = (this.opts.center?.x ?? 0) - w / 2;\n const cy = (this.opts.center?.y ?? 0) - h / 2;\n const buffer = new Float32Array(ids.length * 2);\n const sizes = new Map<string, { width: number; height: number; cx: number; cy: number }>();\n for (let i = 0; i < nodes.length; i++) {\n const node = nodes[i]!;\n const x0 = node.x0 ?? 0;\n const x1 = node.x1 ?? 0;\n const y0 = node.y0 ?? 0;\n const y1 = node.y1 ?? 0;\n const width = x1 - x0;\n const height = y1 - y0;\n const centreX = (x0 + x1) / 2 + cx;\n const centreY = (y0 + y1) / 2 + cy;\n buffer[i * 2] = centreX;\n buffer[i * 2 + 1] = centreY;\n sizes.set(node.id, { width, height, cx: centreX, cy: centreY });\n }\n\n // 4. Mark running, fire start, write positions + per-node + per-edge\n // hints in one batch so subscribers see a single coalesced flush.\n this.running = true;\n this.events.emit('start', {});\n\n store.batch(() => {\n store.setPositionsBulk(ids, buffer);\n\n // Per-node geometry — write the discriminated `style.shape` as a rect\n // with the d3-sankey-solved `{ width, height }`. Merge over existing\n // `style` so caller-provided colour / label fields survive.\n for (const id of ids) {\n const size = sizes.get(id);\n if (!size) 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: 'rect', width: size.width, height: size.height },\n },\n });\n }\n\n // Per-edge geometry — stroke thickness ∝ flow value (d3-sankey sets\n // `link.width` from the solver), plus per-endpoint anchor opts\n // pointing at the right `edge-port`.\n //\n // `link.y0` is the absolute world-y of the link's centre at the\n // source's right face; `sourceCentreY` is the rect's centre y. The\n // `edge-port` anchor receives the delta as its `offset`.\n for (const link of links) {\n const srcId = typeof link.source === 'string' ? link.source : link.source.id;\n const tgtId = typeof link.target === 'string' ? link.target : link.target.id;\n const src = sizes.get(srcId);\n const tgt = sizes.get(tgtId);\n if (!src || !tgt) continue;\n const linkWidth = link.width ?? 1;\n const linkY0 = (link.y0 ?? 0) + cy;\n const linkY1 = (link.y1 ?? 0) + cy;\n const sourceOffset = linkY0 - src.cy;\n const targetOffset = linkY1 - tgt.cy;\n\n const existing = store.getEdge(link.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.updateEdge(link.id, {\n style: {\n ...existingStyle,\n shape: {\n pathType: 'bump-horizontal',\n sourceAnchor: 'edge-port',\n sourceAnchorOpts: { side: 'right', offset: sourceOffset },\n targetAnchor: 'edge-port',\n targetAnchorOpts: { side: 'left', offset: targetOffset },\n },\n strokeWidth: Math.max(1, linkWidth),\n },\n });\n }\n });\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 long\n * enough for this to fire, but it keeps the contract symmetric with\n * iterative layouts. */\n stop(): void {\n if (!this.running) return;\n this.running = false;\n this.events.emit('end', { reason: 'stopped' });\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@invana/graph-layout-d3-sankey",
3
+ "version": "0.0.2",
4
+ "description": "D3 Sankey 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-sankey": "^0.12.3"
15
+ },
16
+ "peerDependencies": {
17
+ "@invana/graph": "0.0.2",
18
+ "@invana/canvas": "0.0.2"
19
+ },
20
+ "devDependencies": {
21
+ "@types/d3-sankey": "^0.12.4",
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-sankey",
35
+ "sankey",
36
+ "flow"
37
+ ],
38
+ "license": "Apache-2.0",
39
+ "module": "./dist/index.js",
40
+ "exports": {
41
+ ".": {
42
+ "types": "./dist/index.d.ts",
43
+ "import": "./dist/index.js",
44
+ "default": "./dist/index.js"
45
+ }
46
+ },
47
+ "files": [
48
+ "dist"
49
+ ],
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "scripts": {
54
+ "build": "tsup",
55
+ "dev": "tsup --watch",
56
+ "lint": "eslint src/",
57
+ "check-types": "tsc --noEmit",
58
+ "clean": "rm -rf dist",
59
+ "test": "vitest run"
60
+ }
61
+ }