@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 +21 -0
- package/README.md +48 -0
- package/package.json +54 -0
- package/src/force-graph.js +169 -0
- package/src/index.js +3 -0
- package/src/inspector.js +225 -0
- package/src/tree-graph.js +327 -0
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
|
+

|
|
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
package/src/inspector.js
ADDED
|
@@ -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
|
+
}
|