@data-navigator/inspector 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Carnegie Mellon University
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # Data Navigator
2
+
3
+ ![Data Navigator provides visualization toolkits with rich, accessible navigation structures, robust input handling, and flexible, semantic rendering.](https://raw.githubusercontent.com/cmudig/data-navigator/main/assets/data_navigator.png)
4
+
5
+ Data Navigator is a JavaScript library that enables keyboard, screen reader, and multi-modal navigation of data structures and visualizations. It works with any rendering technology — SVG, Canvas, images, or WebGL — by creating a semantic, accessible HTML layer on top of your graphics.
6
+
7
+ **[Documentation](https://dig.cmu.edu/data-navigator/)** · **[Getting Started](https://dig.cmu.edu/data-navigator/getting-started/)** · **[Demo](https://dig.cmu.edu/data-navigator/demo)** · **[npm](https://www.npmjs.com/package/data-navigator)**
8
+
9
+ ## Install
10
+
11
+ ```
12
+ npm install data-navigator
13
+ ```
14
+
15
+ ```js
16
+ import dataNavigator from 'data-navigator';
17
+ ```
18
+
19
+ ## How it works
20
+
21
+ Data Navigator is organized into 3 composable modules:
22
+
23
+ 1. **Structure** — a graph of nodes and edges that defines navigation paths through your data
24
+ 2. **Input** — handles keyboard, touch, voice, gesture, and custom input modalities
25
+ 3. **Rendering** — creates semantic HTML elements overlaid on your visualization
26
+
27
+ These modules can be used together or independently. Visit [the docs](https://dig.cmu.edu/data-navigator/getting-started/) for a step-by-step guide to building your first navigable chart.
28
+
29
+ ## Contributing
30
+
31
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup instructions and development workflow.
32
+
33
+ ## Credit
34
+
35
+ Data Navigator was developed at CMU's [Data Interaction Group](https://dig.cmu.edu/) (CMU DIG), primarily by [Frank Elavsky](https://frank.computer).
36
+
37
+ ## Citing Data Navigator
38
+
39
+ ```bib
40
+ @article{2023-data-navigator,
41
+ title = {{Data Navigator}: An Accessibility-Centered Data Navigation Toolkit},
42
+ publisher = {{IEEE}},
43
+ author = {Frank Elavsky and Lucas Nadolskis and Dominik Moritz},
44
+ journal = {{IEEE} Transactions on Visualization and Computer Graphics},
45
+ year = {2023},
46
+ url = {http://dig.cmu.edu/data-navigator/}
47
+ }
48
+ ```
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@data-navigator/inspector",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "author": "Frank Elavsky",
6
+ "license": "MIT",
7
+ "description": "Optional inspector companion for data-navigator.",
8
+ "main": "./src/index.js",
9
+ "module": "./src/index.js",
10
+ "files": [
11
+ "src/**/*",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "import": "./src/index.js",
18
+ "default": "./src/index.js"
19
+ }
20
+ },
21
+ "keywords": [
22
+ "visualization",
23
+ "accessibility",
24
+ "data-navigator",
25
+ "inspector",
26
+ "d3"
27
+ ],
28
+ "scripts": {
29
+ "dev": "vitepress dev docs",
30
+ "build:docs": "vitepress build docs",
31
+ "preview": "vitepress preview docs",
32
+ "prepublishOnly": "cp ../../README.md ../../LICENSE ."
33
+ },
34
+ "peerDependencies": {
35
+ "d3-array": "^3.0.0",
36
+ "d3-drag": "^3.0.0",
37
+ "d3-force": "^3.0.0",
38
+ "d3-scale": "^4.0.0",
39
+ "d3-scale-chromatic": "^3.0.0",
40
+ "d3-selection": "^3.0.0",
41
+ "data-navigator": ">=2.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "d3-array": "^3.0.0",
45
+ "d3-drag": "^3.0.0",
46
+ "d3-force": "^3.0.0",
47
+ "d3-scale": "^4.0.0",
48
+ "d3-scale-chromatic": "^3.0.0",
49
+ "d3-selection": "^3.0.0",
50
+ "data-navigator": "*",
51
+ "vitepress": "^1.4.0",
52
+ "vue": "^3.5.0"
53
+ }
54
+ }
@@ -0,0 +1,169 @@
1
+ // Copyright 2021-2024 Observable, Inc.
2
+ // Released under the ISC license.
3
+ // https://observablehq.com/@d3/force-directed-graph
4
+ // Adapted for data-navigator-inspector with modular D3 imports.
5
+
6
+ import { map, sort } from 'd3-array';
7
+ import { drag } from 'd3-drag';
8
+ import { forceSimulation, forceManyBody, forceLink, forceCenter } from 'd3-force';
9
+ import { scaleOrdinal } from 'd3-scale';
10
+ import { schemeTableau10 } from 'd3-scale-chromatic';
11
+ import { create } from 'd3-selection';
12
+
13
+ export function ForceGraph(
14
+ {
15
+ nodes, // an iterable of node objects (typically [{id}, …])
16
+ links // an iterable of link objects (typically [{source, target}, …])
17
+ },
18
+ {
19
+ nodeId = d => d.id,
20
+ nodeGroup,
21
+ nodeGroups,
22
+ nodeTitle,
23
+ nodeFill = 'currentColor',
24
+ nodeStroke = '#fff',
25
+ nodeStrokeWidth = 1.5,
26
+ nodeStrokeOpacity = 1,
27
+ nodeRadius = 5,
28
+ nodeStrength,
29
+ linkSource = ({ source }) => source,
30
+ linkTarget = ({ target }) => target,
31
+ linkStroke = '#999',
32
+ linkStrokeOpacity = 0.6,
33
+ linkStrokeWidth = 1.5,
34
+ linkStrokeLinecap = 'round',
35
+ linkStrength,
36
+ colors = schemeTableau10,
37
+ width = 640,
38
+ height = 400,
39
+ invalidation,
40
+ description,
41
+ hide
42
+ } = {}
43
+ ) {
44
+ // Compute values.
45
+ const N = map(nodes, nodeId).map(intern);
46
+ const R = typeof nodeRadius !== 'function' ? null : map(nodes, nodeRadius);
47
+ const LS = map(links, linkSource).map(intern);
48
+ const LT = map(links, linkTarget).map(intern);
49
+ if (nodeTitle === undefined) nodeTitle = (_, i) => N[i];
50
+ const T = nodeTitle == null ? null : map(nodes, nodeTitle);
51
+ const G = nodeGroup == null ? null : map(nodes, nodeGroup).map(intern);
52
+ const W = typeof linkStrokeWidth !== 'function' ? null : map(links, linkStrokeWidth);
53
+ const L = typeof linkStroke !== 'function' ? null : map(links, linkStroke);
54
+
55
+ // Replace the input nodes and links with mutable objects for the simulation.
56
+ nodes = map(nodes, (_, i) => ({ id: N[i] }));
57
+ links = map(links, (_, i) => ({ source: LS[i], target: LT[i] }));
58
+
59
+ description =
60
+ (typeof description === 'function' ? description(nodes, links) : description) ||
61
+ `Node-link graph. Contains ${nodes.length} node${nodes.length !== 1 ? 's' : ''} and ${links.length} link${
62
+ links.length !== 1 ? 's' : ''
63
+ }.`;
64
+
65
+ // Compute default domains.
66
+ if (G && nodeGroups === undefined) nodeGroups = sort(G);
67
+
68
+ // Construct the scales.
69
+ const color = nodeGroup == null ? null : scaleOrdinal(nodeGroups, colors);
70
+
71
+ // Construct the forces.
72
+ const forceNode = forceManyBody();
73
+ const simForceLink = forceLink(links).id(({ index: i }) => N[i]);
74
+ if (nodeStrength !== undefined) forceNode.strength(nodeStrength);
75
+ if (linkStrength !== undefined) simForceLink.strength(linkStrength);
76
+
77
+ const simulation = forceSimulation(nodes)
78
+ .force('link', simForceLink)
79
+ .force('charge', forceNode)
80
+ .force('center', forceCenter())
81
+ .on('tick', ticked);
82
+
83
+ const svg = create('svg')
84
+ .attr('width', width)
85
+ .attr('height', height)
86
+ .attr('viewBox', [-width / 2, -height / 2, width, height])
87
+ .attr('role', hide ? 'presentation' : 'img')
88
+ .attr('aria-label', hide ? null : description)
89
+ .attr('style', 'max-width: 100%; height: auto; height: intrinsic;');
90
+
91
+ const link = svg
92
+ .append('g')
93
+ .attr('stroke', typeof linkStroke !== 'function' ? linkStroke : null)
94
+ .attr('stroke-opacity', linkStrokeOpacity)
95
+ .attr('stroke-width', typeof linkStrokeWidth !== 'function' ? linkStrokeWidth : null)
96
+ .attr('stroke-linecap', linkStrokeLinecap)
97
+ .selectAll('line')
98
+ .data(links)
99
+ .join('line');
100
+
101
+ const node = svg
102
+ .append('g')
103
+ .attr('fill', nodeFill)
104
+ .attr('stroke', nodeStroke)
105
+ .attr('stroke-opacity', nodeStrokeOpacity)
106
+ .attr('stroke-width', nodeStrokeWidth)
107
+ .selectAll('circle')
108
+ .data(nodes)
109
+ .join('circle')
110
+ .attr('r', nodeRadius)
111
+ .attr('role', 'presentation')
112
+ .call(makeDrag(simulation));
113
+
114
+ if (W) link.attr('stroke-width', ({ index: i }) => W[i]);
115
+ if (L) link.attr('stroke', ({ index: i }) => L[i]);
116
+ if (G) node.attr('fill', ({ index: i }) => color(G[i]));
117
+ if (R) node.attr('r', ({ index: i }) => R[i]);
118
+ if (T) node.append('title').text(({ index: i }) => T[i]);
119
+ if (invalidation != null) invalidation.then(() => simulation.stop());
120
+
121
+ function intern(value) {
122
+ return value !== null && typeof value === 'object' ? value.valueOf() : value;
123
+ }
124
+
125
+ function ticked() {
126
+ link.attr('x1', d => d.source.x)
127
+ .attr('y1', d => d.source.y)
128
+ .attr('x2', d => d.target.x)
129
+ .attr('y2', d => d.target.y);
130
+
131
+ node.attr('cx', d => {
132
+ // Keep nodes within the svg bounds
133
+ let limit = width / 2 - nodeRadius;
134
+ if (d.x > limit || d.x < -limit) {
135
+ return limit;
136
+ }
137
+ return d.x;
138
+ }).attr('cy', d => {
139
+ let limit = height / 2 - nodeRadius;
140
+ if (d.y > limit || d.y < -limit) {
141
+ return limit;
142
+ }
143
+ return d.y;
144
+ });
145
+ }
146
+
147
+ function makeDrag(simulation) {
148
+ function dragstarted(event) {
149
+ if (!event.active) simulation.alphaTarget(0.3).restart();
150
+ event.subject.fx = event.subject.x;
151
+ event.subject.fy = event.subject.y;
152
+ }
153
+
154
+ function dragged(event) {
155
+ event.subject.fx = event.x;
156
+ event.subject.fy = event.y;
157
+ }
158
+
159
+ function dragended(event) {
160
+ if (!event.active) simulation.alphaTarget(0);
161
+ event.subject.fx = null;
162
+ event.subject.fy = null;
163
+ }
164
+
165
+ return drag().on('start', dragstarted).on('drag', dragged).on('end', dragended);
166
+ }
167
+
168
+ return Object.assign(svg.node(), { scales: { color } });
169
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { ForceGraph } from './force-graph.js';
2
+ export { TreeGraph } from './tree-graph.js';
3
+ export { Inspector, buildLabel } from './inspector.js';
@@ -0,0 +1,225 @@
1
+ import { ForceGraph } from './force-graph.js';
2
+ import { TreeGraph } from './tree-graph.js';
3
+
4
+ /**
5
+ * Converts a structure's nodes or edges object into an array for D3.
6
+ * Optionally includes extra node IDs or excludes certain edge IDs.
7
+ */
8
+ const convertToArray = (obj, include, exclude) => {
9
+ let arr = [];
10
+ if (include) {
11
+ include.forEach(id => {
12
+ arr.push({ id });
13
+ });
14
+ }
15
+ Object.keys(obj).forEach(k => {
16
+ if (exclude && exclude.indexOf(k) !== -1) {
17
+ return;
18
+ }
19
+ arr.push(obj[k]);
20
+ });
21
+ return arr;
22
+ };
23
+
24
+ /**
25
+ * Builds a tooltip label for a structure node.
26
+ */
27
+ export const buildLabel = (node, colorBy) => {
28
+ if (node.semantics?.label) return node.semantics.label;
29
+ if (!node.derivedNode) {
30
+ if (node.data) {
31
+ return (
32
+ Object.keys(node.data)
33
+ .map(key => `${key}: ${node.data[key]}`)
34
+ .join('. ') + '. Data point.'
35
+ );
36
+ }
37
+ return node.id;
38
+ }
39
+ if (node.data?.dimensionKey) {
40
+ let count = 0;
41
+ let divisions = Object.keys(node.data.divisions || {});
42
+ divisions.forEach(div => {
43
+ count += Object.keys(node.data.divisions[div].values || {}).length;
44
+ });
45
+ let label = `${node.derivedNode}.`;
46
+ label +=
47
+ divisions.length && count
48
+ ? ` Contains ${divisions.length} division${
49
+ divisions.length > 1 ? 's' : ''
50
+ } which contain ${count} datapoint${count > 1 ? 's' : ''} total.`
51
+ : ' Contains no child data points.';
52
+ label += ` ${node.data.type} dimension.`;
53
+ return label;
54
+ }
55
+ return `${node.derivedNode}: ${node.data?.[node.derivedNode]}. Contains ${
56
+ Object.keys(node.data?.values || {}).length
57
+ } child data point${Object.keys(node.data?.values || {}).length > 1 ? 's' : ''}. Division of ${
58
+ node.derivedNode
59
+ } dimension.`;
60
+ };
61
+
62
+ /**
63
+ * Shows the tooltip next to the force graph.
64
+ */
65
+ const showTooltip = (node, tooltipEl, graphWidth, graphHeight, colorBy) => {
66
+ tooltipEl.classList.remove('dn-inspector-hidden');
67
+ tooltipEl.innerText =
68
+ buildLabel(node, colorBy) ||
69
+ `${node.id}${node.data?.[colorBy] ? ', ' + node.data[colorBy] : ''} (generic node, edges hidden).`;
70
+ const bbox = tooltipEl.getBoundingClientRect();
71
+ const yOffset = bbox.height / 2;
72
+ tooltipEl.style.textAlign = 'left';
73
+ tooltipEl.style.transform = `translate(${graphWidth}px,${graphHeight / 2 - yOffset}px)`;
74
+ };
75
+
76
+ /**
77
+ * Hides the tooltip and optionally the focus indicator.
78
+ */
79
+ const hideTooltip = (tooltipEl, indicatorEl) => {
80
+ tooltipEl.classList.add('dn-inspector-hidden');
81
+ if (indicatorEl) {
82
+ indicatorEl.classList.add('dn-inspector-hidden');
83
+ }
84
+ };
85
+
86
+ /**
87
+ * Moves the SVG focus indicator circle to the target node.
88
+ */
89
+ const highlightNode = (nodeId, svgEl, indicatorEl) => {
90
+ let target = svgEl.querySelector('#svg' + nodeId);
91
+ if (!target || !indicatorEl) return;
92
+ indicatorEl.setAttribute('cx', target.getAttribute('cx'));
93
+ indicatorEl.setAttribute('cy', target.getAttribute('cy'));
94
+ indicatorEl.classList.remove('dn-inspector-hidden');
95
+ };
96
+
97
+ /**
98
+ * Creates and returns a focus indicator circle element inside the SVG.
99
+ */
100
+ const createFocusIndicator = (svgEl, id) => {
101
+ let indicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
102
+ indicator.id = id + '-focus-indicator';
103
+ indicator.setAttribute('class', 'dn-inspector-focus-indicator dn-inspector-hidden');
104
+ indicator.setAttribute('cx', 0);
105
+ indicator.setAttribute('cy', 0);
106
+ indicator.setAttribute('r', 6.5);
107
+ indicator.setAttribute('fill', 'none');
108
+ indicator.setAttribute('stroke', '#000');
109
+ indicator.setAttribute('stroke-width', '2');
110
+ svgEl.appendChild(indicator);
111
+ return indicator;
112
+ };
113
+
114
+ /**
115
+ * Inspector: creates a passive force-directed graph visualization of a
116
+ * data-navigator structure. Does not drive navigation — call highlight()
117
+ * and clear() from your own navigation lifecycle to keep the graph in sync.
118
+ *
119
+ * @param {Object} options
120
+ * @param {Object} options.structure - A data-navigator structure object (nodes, edges, navigationRules)
121
+ * @param {string|HTMLElement} options.container - DOM element or ID to mount in
122
+ * @param {number} [options.size=300] - Width and height of the force graph
123
+ * @param {string} [options.colorBy='dimensionLevel'] - Field to color nodes by
124
+ * @param {number} [options.nodeRadius=5] - Radius of node circles
125
+ * @param {string[]} [options.edgeExclusions=[]] - Edge IDs to exclude from the graph
126
+ * @param {string[]} [options.nodeInclusions=[]] - Extra pseudo-node IDs to include
127
+ * @param {'force'|'tree'} [options.mode='force'] - Visualization mode: 'force' for force-directed, 'tree' for hierarchy layout
128
+ *
129
+ * @returns {{ svg: SVGElement, highlight: Function, clear: Function, destroy: Function }}
130
+ */
131
+ export function Inspector({
132
+ structure,
133
+ container,
134
+ size = 300,
135
+ colorBy = 'dimensionLevel',
136
+ nodeRadius = 5,
137
+ edgeExclusions = [],
138
+ nodeInclusions = [],
139
+ mode = 'force'
140
+ }) {
141
+ const rootEl = typeof container === 'string' ? document.getElementById(container) : container;
142
+ const rootId = rootEl.id || 'dn-inspector-' + Math.random().toString(36).slice(2, 8);
143
+ if (!rootEl.id) rootEl.id = rootId;
144
+
145
+ // Build the graph SVG
146
+ const graphWidth = mode === 'tree' ? size * 2 : size;
147
+ const graphHeight = mode === 'tree' ? Math.round(size * 1.5) : size;
148
+ const nodeArray = convertToArray(structure.nodes, nodeInclusions);
149
+ const linkArray = convertToArray(structure.edges, [], edgeExclusions);
150
+ const graphOptions = {
151
+ nodeId: d => d.id,
152
+ nodeGroup: d => (colorBy === 'dimensionLevel' ? d.dimensionLevel : d.data?.[colorBy]),
153
+ width: graphWidth,
154
+ height: graphHeight,
155
+ nodeRadius,
156
+ hide: true
157
+ };
158
+
159
+ const graph = mode === 'tree'
160
+ ? TreeGraph(
161
+ { nodes: nodeArray, links: linkArray },
162
+ { ...graphOptions, dimensions: structure.dimensions }
163
+ )
164
+ : ForceGraph(
165
+ { nodes: nodeArray, links: linkArray },
166
+ graphOptions
167
+ );
168
+
169
+ // Create wrapper structure
170
+ const wrapperEl = document.createElement('div');
171
+ wrapperEl.className = 'dn-inspector-wrapper';
172
+ wrapperEl.style.position = 'relative';
173
+
174
+ const graphContainer = document.createElement('div');
175
+ graphContainer.className = 'dn-inspector-graph';
176
+ graphContainer.appendChild(graph);
177
+ wrapperEl.appendChild(graphContainer);
178
+
179
+ // Create tooltip
180
+ const tooltipEl = document.createElement('div');
181
+ tooltipEl.id = rootId + '-tooltip';
182
+ tooltipEl.className = 'dn-inspector-tooltip dn-inspector-hidden';
183
+ tooltipEl.setAttribute('role', 'presentation');
184
+ tooltipEl.setAttribute('focusable', 'false');
185
+ wrapperEl.appendChild(tooltipEl);
186
+
187
+ rootEl.appendChild(wrapperEl);
188
+
189
+ // Assign IDs to SVG circles for targeting
190
+ const svgEl = graphContainer.querySelector('svg');
191
+ graphContainer.querySelectorAll('circle').forEach(c => {
192
+ if (c.__data__?.id) {
193
+ c.id = 'svg' + c.__data__.id;
194
+ }
195
+ c.addEventListener('mousemove', e => {
196
+ if (e.target?.__data__?.id) {
197
+ let d = e.target.__data__;
198
+ showTooltip(structure.nodes[d.id] || d, tooltipEl, graphWidth, graphHeight, colorBy);
199
+ }
200
+ });
201
+ c.addEventListener('mouseleave', () => {
202
+ hideTooltip(tooltipEl);
203
+ });
204
+ });
205
+
206
+ // Create focus indicator
207
+ const indicatorEl = createFocusIndicator(svgEl, rootId);
208
+
209
+ // Public API
210
+ return {
211
+ svg: svgEl,
212
+ highlight(nodeId) {
213
+ highlightNode(nodeId, svgEl, indicatorEl);
214
+ if (structure.nodes[nodeId]) {
215
+ showTooltip(structure.nodes[nodeId], tooltipEl, graphWidth, graphHeight, colorBy);
216
+ }
217
+ },
218
+ clear() {
219
+ hideTooltip(tooltipEl, indicatorEl);
220
+ },
221
+ destroy() {
222
+ rootEl.removeChild(wrapperEl);
223
+ }
224
+ };
225
+ }
@@ -0,0 +1,327 @@
1
+ // Tree layout visualization for data-navigator structures.
2
+ // Arranges dimension, division, and leaf nodes in a deterministic hierarchy.
3
+ // For 2+ dimensions, leaf nodes are placed in a grid layout.
4
+
5
+ import { map, sort } from 'd3-array';
6
+ import { scaleOrdinal } from 'd3-scale';
7
+ import { schemeTableau10 } from 'd3-scale-chromatic';
8
+ import { create } from 'd3-selection';
9
+
10
+ export function TreeGraph(
11
+ {
12
+ nodes,
13
+ links
14
+ },
15
+ {
16
+ nodeId = d => d.id,
17
+ nodeGroup,
18
+ nodeGroups,
19
+ nodeRadius = 5,
20
+ colors = schemeTableau10,
21
+ width = 640,
22
+ height = 400,
23
+ dimensions,
24
+ description,
25
+ hide
26
+ } = {}
27
+ ) {
28
+ // Compute values (same pattern as ForceGraph).
29
+ const N = map(nodes, nodeId).map(intern);
30
+ const G = nodeGroup == null ? null : map(nodes, nodeGroup).map(intern);
31
+
32
+ // Build a lookup from node id to original node data.
33
+ const nodeById = {};
34
+ nodes.forEach((n, i) => { nodeById[N[i]] = n; });
35
+
36
+ // Replace input nodes with positioned objects.
37
+ nodes = map(nodes, (_, i) => ({ id: N[i], x: 0, y: 0 }));
38
+ const nodeObjById = {};
39
+ nodes.forEach(n => { nodeObjById[n.id] = n; });
40
+
41
+ // Map links to source/target ids.
42
+ const linkData = map(links, (l) => ({
43
+ source: intern(l.source),
44
+ target: intern(l.target),
45
+ navigationRules: l.navigationRules || []
46
+ }));
47
+
48
+ description =
49
+ (typeof description === 'function' ? description(nodes, linkData) : description) ||
50
+ `Tree layout. Contains ${nodes.length} node${nodes.length !== 1 ? 's' : ''} and ${linkData.length} link${
51
+ linkData.length !== 1 ? 's' : ''
52
+ }.`;
53
+
54
+ // Compute default domains and color scale.
55
+ if (G && nodeGroups === undefined) nodeGroups = sort(G);
56
+ const color = nodeGroup == null ? null : scaleOrdinal(nodeGroups, colors);
57
+
58
+ // Build set of parent-child navigation rule names.
59
+ const parentChildRules = new Set(['parent', 'child']);
60
+ if (dimensions) {
61
+ Object.values(dimensions).forEach(dim => {
62
+ (dim.navigationRules?.parent_child || []).forEach(r => parentChildRules.add(r));
63
+ });
64
+ }
65
+
66
+ // Classify links.
67
+ const parentChildLinks = [];
68
+ const siblingLinks = [];
69
+ linkData.forEach(l => {
70
+ const rules = l.navigationRules;
71
+ if (!rules || rules.length === 0) return;
72
+ // Skip exit/undo edges
73
+ if (rules.includes('exit') || rules.includes('undo')) return;
74
+ const isParentChild = rules.some(r => parentChildRules.has(r));
75
+ if (isParentChild) {
76
+ parentChildLinks.push(l);
77
+ } else {
78
+ siblingLinks.push(l);
79
+ }
80
+ });
81
+
82
+ // Classify nodes by level.
83
+ const level0 = [];
84
+ const level1 = [];
85
+ const level2 = [];
86
+ const leafNodes = [];
87
+ const otherNodes = [];
88
+
89
+ nodes.forEach(n => {
90
+ const orig = nodeById[n.id];
91
+ if (!orig) { otherNodes.push(n); return; }
92
+ const dl = orig.dimensionLevel;
93
+ const dn = orig.derivedNode;
94
+ if (dl === 0) level0.push(n);
95
+ else if (dl === 1) level1.push(n);
96
+ else if (dl === 2) level2.push(n);
97
+ else if (dl === undefined && dn === undefined && orig.data) leafNodes.push(n);
98
+ else otherNodes.push(n);
99
+ });
100
+
101
+ // Build leaf-to-division mapping from dimensions.
102
+ // leafDivisions[leafId] = { dimensionKey: divisionId, ... }
103
+ const leafDivisions = {};
104
+ const dimKeys = dimensions ? Object.keys(dimensions) : [];
105
+ const dimOrder = []; // ordered list of dimension keys
106
+ if (dimensions) {
107
+ dimKeys.forEach(key => {
108
+ dimOrder.push(key);
109
+ const dim = dimensions[key];
110
+ const divIds = Object.keys(dim.divisions || {});
111
+ // Skip the "single division" case where divId === dim.nodeId
112
+ const isSingleDiv = divIds.length === 1 && divIds[0] === dim.nodeId;
113
+ divIds.forEach(divId => {
114
+ const div = dim.divisions[divId];
115
+ Object.keys(div.values || {}).forEach(leafId => {
116
+ if (!leafDivisions[leafId]) leafDivisions[leafId] = {};
117
+ leafDivisions[leafId][key] = isSingleDiv ? dim.nodeId : divId;
118
+ });
119
+ });
120
+ });
121
+ }
122
+
123
+ // Organize level2 nodes by their parent dimension.
124
+ const divisionsByDim = {};
125
+ dimOrder.forEach(key => { divisionsByDim[key] = []; });
126
+ level2.forEach(n => {
127
+ const orig = nodeById[n.id];
128
+ if (orig && orig.derivedNode && divisionsByDim[orig.derivedNode]) {
129
+ divisionsByDim[orig.derivedNode].push(n);
130
+ }
131
+ });
132
+
133
+ // Compute layout positions.
134
+ const padding = { top: 30, bottom: 20, left: 30, right: 30 };
135
+ const usableWidth = width - padding.left - padding.right;
136
+ const usableHeight = height - padding.top - padding.bottom;
137
+
138
+ // Determine rows: level0 (optional), level1, level2, leaves
139
+ const hasLevel0 = level0.length > 0;
140
+ const rowCount = (hasLevel0 ? 1 : 0) + 1 + 1 + 1; // l0?, l1, l2, leaves
141
+ const rowSpacing = usableHeight / (rowCount - 1 || 1);
142
+
143
+ let currentRow = 0;
144
+ const yFor = (row) => padding.top + row * rowSpacing;
145
+
146
+ // Level 0
147
+ if (hasLevel0) {
148
+ const y = yFor(currentRow);
149
+ level0.forEach((n, i) => {
150
+ n.x = padding.left + usableWidth / 2;
151
+ n.y = y;
152
+ });
153
+ currentRow++;
154
+ }
155
+
156
+ // Level 1 (dimensions) — evenly spaced
157
+ const dimY = yFor(currentRow);
158
+ const dimCount = level1.length || 1;
159
+ // Map dimension nodeId to its horizontal allocation
160
+ const dimAlloc = {};
161
+ level1.forEach((n, i) => {
162
+ const x = padding.left + (usableWidth / (dimCount + 1)) * (i + 1);
163
+ n.x = x;
164
+ n.y = dimY;
165
+ dimAlloc[n.id] = {
166
+ x,
167
+ idx: i,
168
+ rangeStart: padding.left + (usableWidth / dimCount) * i,
169
+ rangeEnd: padding.left + (usableWidth / dimCount) * (i + 1)
170
+ };
171
+ });
172
+ currentRow++;
173
+
174
+ // Level 2 (divisions) — grouped under their parent dimension
175
+ const divY = yFor(currentRow);
176
+ const divAlloc = {}; // divisionId -> { x, dimKey }
177
+ dimOrder.forEach(key => {
178
+ const dim = dimensions ? dimensions[key] : null;
179
+ if (!dim) return;
180
+ const alloc = dimAlloc[dim.nodeId];
181
+ if (!alloc) return;
182
+ const divs = divisionsByDim[key] || [];
183
+ if (divs.length === 0) return;
184
+ const range = alloc.rangeEnd - alloc.rangeStart;
185
+ const divSpacing = range / (divs.length + 1);
186
+ divs.forEach((n, i) => {
187
+ n.x = alloc.rangeStart + divSpacing * (i + 1);
188
+ n.y = divY;
189
+ divAlloc[n.id] = { x: n.x, dimKey: key, idx: i };
190
+ });
191
+ });
192
+ currentRow++;
193
+
194
+ // Leaf nodes
195
+ const leafY = yFor(currentRow);
196
+ if (dimOrder.length === 0 || !dimensions) {
197
+ // No dimensions — flat row
198
+ leafNodes.forEach((n, i) => {
199
+ n.x = padding.left + (usableWidth / (leafNodes.length + 1)) * (i + 1);
200
+ n.y = leafY;
201
+ });
202
+ } else if (dimOrder.length === 1) {
203
+ // Single dimension — spread leaves under their division parent
204
+ const key = dimOrder[0];
205
+ const dim = dimensions[key];
206
+ const divIds = Object.keys(dim.divisions || {});
207
+ divIds.forEach(divId => {
208
+ const div = dim.divisions[divId];
209
+ const leafIds = Object.keys(div.values || {});
210
+ const parentX = divAlloc[divId]?.x || width / 2;
211
+ const spreadWidth = usableWidth / (divIds.length || 1);
212
+ const leafSpacing = spreadWidth / (leafIds.length + 1);
213
+ leafIds.forEach((leafId, i) => {
214
+ const n = nodeObjById[leafId];
215
+ if (n) {
216
+ n.x = parentX - spreadWidth / 2 + leafSpacing * (i + 1);
217
+ n.y = leafY;
218
+ }
219
+ });
220
+ });
221
+ } else {
222
+ // 2+ dimensions — grid layout
223
+ // First dimension controls columns, second controls rows
224
+ const dim1Key = dimOrder[0];
225
+ const dim2Key = dimOrder[1];
226
+ const dim1 = dimensions[dim1Key];
227
+ const dim2 = dimensions[dim2Key];
228
+ const div1Ids = Object.keys(dim1.divisions || {});
229
+ const div2Ids = Object.keys(dim2.divisions || {});
230
+
231
+ const cols = div1Ids.length || 1;
232
+ const rows = div2Ids.length || 1;
233
+
234
+ // Grid occupies the leaf area but may expand vertically
235
+ const gridTop = leafY - rowSpacing * 0.3;
236
+ const gridBottom = height - padding.bottom;
237
+ const gridHeight = gridBottom - gridTop;
238
+ const colSpacing = usableWidth / (cols + 1);
239
+ const rowSpacingGrid = gridHeight / (rows + 1);
240
+
241
+ leafNodes.forEach(n => {
242
+ const ld = leafDivisions[n.id];
243
+ if (!ld) {
244
+ // Unaffiliated leaf — place to the right
245
+ n.x = width - padding.right;
246
+ n.y = leafY;
247
+ return;
248
+ }
249
+ const colDivId = ld[dim1Key];
250
+ const rowDivId = ld[dim2Key];
251
+ const colIdx = div1Ids.indexOf(colDivId);
252
+ const rowIdx = div2Ids.indexOf(rowDivId);
253
+ n.x = padding.left + colSpacing * ((colIdx >= 0 ? colIdx : 0) + 1);
254
+ n.y = gridTop + rowSpacingGrid * ((rowIdx >= 0 ? rowIdx : 0) + 1);
255
+ });
256
+ }
257
+
258
+ // Other nodes (exit, etc.) — placed bottom-right
259
+ otherNodes.forEach((n, i) => {
260
+ n.x = width - padding.right - (i * nodeRadius * 3);
261
+ n.y = height - padding.bottom;
262
+ });
263
+
264
+ // Build the SVG.
265
+ const svg = create('svg')
266
+ .attr('width', width)
267
+ .attr('height', height)
268
+ .attr('viewBox', [0, 0, width, height])
269
+ .attr('role', hide ? 'presentation' : 'img')
270
+ .attr('aria-label', hide ? null : description)
271
+ .attr('style', 'max-width: 100%; height: auto; height: intrinsic;');
272
+
273
+ // Draw sibling links (dashed, behind parent-child links).
274
+ svg.append('g')
275
+ .attr('class', 'tree-sibling-links')
276
+ .selectAll('line')
277
+ .data(siblingLinks)
278
+ .join('line')
279
+ .attr('x1', d => nodeObjById[d.source]?.x || 0)
280
+ .attr('y1', d => nodeObjById[d.source]?.y || 0)
281
+ .attr('x2', d => nodeObjById[d.target]?.x || 0)
282
+ .attr('y2', d => nodeObjById[d.target]?.y || 0)
283
+ .attr('stroke', '#bbb')
284
+ .attr('stroke-opacity', 0.25)
285
+ .attr('stroke-width', 1)
286
+ .attr('stroke-dasharray', '3,2');
287
+
288
+ // Draw parent-child links (solid).
289
+ svg.append('g')
290
+ .attr('class', 'tree-parent-child-links')
291
+ .selectAll('line')
292
+ .data(parentChildLinks)
293
+ .join('line')
294
+ .attr('x1', d => nodeObjById[d.source]?.x || 0)
295
+ .attr('y1', d => nodeObjById[d.source]?.y || 0)
296
+ .attr('x2', d => nodeObjById[d.target]?.x || 0)
297
+ .attr('y2', d => nodeObjById[d.target]?.y || 0)
298
+ .attr('stroke', '#666')
299
+ .attr('stroke-opacity', 0.5)
300
+ .attr('stroke-width', 1.5);
301
+
302
+ // Draw nodes.
303
+ const node = svg
304
+ .append('g')
305
+ .attr('fill', 'currentColor')
306
+ .attr('stroke', '#fff')
307
+ .attr('stroke-opacity', 1)
308
+ .attr('stroke-width', 1.5)
309
+ .selectAll('circle')
310
+ .data(nodes)
311
+ .join('circle')
312
+ .attr('cx', d => d.x)
313
+ .attr('cy', d => d.y)
314
+ .attr('r', nodeRadius)
315
+ .attr('role', 'presentation');
316
+
317
+ if (G) node.attr('fill', ({ id }) => {
318
+ const idx = N.indexOf(id);
319
+ return idx >= 0 ? color(G[idx]) : '#999';
320
+ });
321
+
322
+ function intern(value) {
323
+ return value !== null && typeof value === 'object' ? value.valueOf() : value;
324
+ }
325
+
326
+ return Object.assign(svg.node(), { scales: { color } });
327
+ }