@data-navigator/inspector 1.2.1 → 1.3.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/package.json +1 -1
- package/src/inspector.js +14 -3
- package/src/tree-graph.js +280 -146
package/package.json
CHANGED
package/src/inspector.js
CHANGED
|
@@ -26,7 +26,9 @@ const convertToArray = (obj, include, exclude) => {
|
|
|
26
26
|
* Builds a tooltip label for a structure node.
|
|
27
27
|
*/
|
|
28
28
|
export const buildLabel = (node, colorBy) => {
|
|
29
|
-
if (node.semantics?.label)
|
|
29
|
+
if (node.semantics?.label) {
|
|
30
|
+
return typeof node.semantics.label === 'function' ? node.semantics.label() : node.semantics.label;
|
|
31
|
+
}
|
|
30
32
|
if (!node.derivedNode) {
|
|
31
33
|
if (node.data) {
|
|
32
34
|
return (
|
|
@@ -161,7 +163,7 @@ export function Inspector({
|
|
|
161
163
|
const idPrefix = rootId + '-';
|
|
162
164
|
const graphOptions = {
|
|
163
165
|
nodeId: d => d.id,
|
|
164
|
-
nodeGroup: d => (colorBy === 'dimensionLevel' ? d.dimensionLevel : d.data?.[colorBy]),
|
|
166
|
+
nodeGroup: d => (colorBy === 'dimensionLevel' ? (d.dimensionLevel ?? d.depth) : d.data?.[colorBy]),
|
|
165
167
|
width: graphWidth,
|
|
166
168
|
height: graphHeight,
|
|
167
169
|
nodeRadius,
|
|
@@ -171,7 +173,16 @@ export function Inspector({
|
|
|
171
173
|
|
|
172
174
|
const graph =
|
|
173
175
|
mode === 'tree'
|
|
174
|
-
? TreeGraph(
|
|
176
|
+
? TreeGraph(
|
|
177
|
+
{ nodes: nodeArray, links: linkArray },
|
|
178
|
+
{
|
|
179
|
+
...graphOptions,
|
|
180
|
+
dimensions: structure.dimensions,
|
|
181
|
+
frame: structure.frame,
|
|
182
|
+
spatialGrid: structure.spatialGrid,
|
|
183
|
+
hierarchy: structure.hierarchy
|
|
184
|
+
}
|
|
185
|
+
)
|
|
175
186
|
: ForceGraph({ nodes: nodeArray, links: linkArray }, graphOptions);
|
|
176
187
|
|
|
177
188
|
// When explicit pixel dimensions are given, remove the auto-scaling inline style so the
|
package/src/tree-graph.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Tree layout visualization for data-navigator structures.
|
|
2
2
|
// Arranges dimension, division, and leaf nodes in a deterministic hierarchy.
|
|
3
3
|
// For 2+ dimensions, leaf nodes are placed in a grid layout.
|
|
4
|
+
// Frames, spatial grids, and hierarchies use a layered layout built from each node's parent.
|
|
4
5
|
|
|
5
6
|
import { map, sort } from 'd3-array';
|
|
6
7
|
import { scaleOrdinal } from 'd3-scale';
|
|
@@ -18,6 +19,9 @@ export function TreeGraph(
|
|
|
18
19
|
width = 640,
|
|
19
20
|
height = 400,
|
|
20
21
|
dimensions,
|
|
22
|
+
frame,
|
|
23
|
+
spatialGrid,
|
|
24
|
+
hierarchy,
|
|
21
25
|
description,
|
|
22
26
|
hide,
|
|
23
27
|
idPrefix = ''
|
|
@@ -67,6 +71,9 @@ export function TreeGraph(
|
|
|
67
71
|
if (ss && ss.length === 2) siblingRulePairs.push(ss);
|
|
68
72
|
});
|
|
69
73
|
}
|
|
74
|
+
[frame?.parentRules, spatialGrid?.parentRules, hierarchy?.parentRules].forEach(pair => {
|
|
75
|
+
(pair || []).forEach(r => parentChildRules.add(r));
|
|
76
|
+
});
|
|
70
77
|
// Map each sibling rule name to its pair for splitting merged edges.
|
|
71
78
|
const ruleToPair = {};
|
|
72
79
|
siblingRulePairs.forEach(pair => {
|
|
@@ -159,167 +166,294 @@ export function TreeGraph(
|
|
|
159
166
|
// Compute layout positions.
|
|
160
167
|
// Upper levels (dimensions, divisions) are compressed; leaves get more room.
|
|
161
168
|
const padding = { top: 30, bottom: 20, left: 30, right: 30 };
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// Upper hierarchy gets 25% of height, leaves get the rest after a gap
|
|
168
|
-
const upperHeight = usableHeight * 0.25;
|
|
169
|
-
const upperSpacing = upperRows > 1 ? upperHeight / (upperRows - 1) : upperHeight;
|
|
170
|
-
const leafGap = usableHeight * 0.05; // small gap between hierarchy and leaves
|
|
171
|
-
const leafYPos = padding.top + upperHeight + leafGap;
|
|
172
|
-
const leafAreaCenter = (leafYPos + height - padding.bottom) / 2;
|
|
173
|
-
|
|
174
|
-
let currentRow = 0;
|
|
175
|
-
const yFor = row => padding.top + row * upperSpacing;
|
|
176
|
-
|
|
177
|
-
// Level 0
|
|
178
|
-
if (hasLevel0) {
|
|
179
|
-
const y = yFor(currentRow);
|
|
180
|
-
level0.forEach((n, i) => {
|
|
181
|
-
n.x = padding.left + usableWidth / 2;
|
|
182
|
-
n.y = y;
|
|
183
|
-
});
|
|
184
|
-
currentRow++;
|
|
169
|
+
const useLayered = !!(spatialGrid || hierarchy) || (!!frame && dimOrder.length < 2);
|
|
170
|
+
const frameTop = padding.top;
|
|
171
|
+
if (frame && !useLayered) {
|
|
172
|
+
// leave a row at the top for the frame root
|
|
173
|
+
padding.top += Math.max(30, (height - padding.top - padding.bottom) * 0.12);
|
|
185
174
|
}
|
|
175
|
+
if (useLayered) {
|
|
176
|
+
layoutLayered();
|
|
177
|
+
} else {
|
|
178
|
+
const usableWidth = width - padding.left - padding.right;
|
|
179
|
+
const usableHeight = height - padding.top - padding.bottom;
|
|
180
|
+
|
|
181
|
+
const hasLevel0 = level0.length > 0;
|
|
182
|
+
const upperRows = (hasLevel0 ? 1 : 0) + 1 + 1; // l0?, l1, l2
|
|
183
|
+
// Upper hierarchy gets 25% of height, leaves get the rest after a gap
|
|
184
|
+
const upperHeight = usableHeight * 0.25;
|
|
185
|
+
const upperSpacing = upperRows > 1 ? upperHeight / (upperRows - 1) : upperHeight;
|
|
186
|
+
const leafGap = usableHeight * 0.05; // small gap between hierarchy and leaves
|
|
187
|
+
const leafYPos = padding.top + upperHeight + leafGap;
|
|
188
|
+
const leafAreaCenter = (leafYPos + height - padding.bottom) / 2;
|
|
189
|
+
|
|
190
|
+
let currentRow = 0;
|
|
191
|
+
const yFor = row => padding.top + row * upperSpacing;
|
|
192
|
+
|
|
193
|
+
// Level 0
|
|
194
|
+
if (hasLevel0) {
|
|
195
|
+
const y = yFor(currentRow);
|
|
196
|
+
level0.forEach((n, i) => {
|
|
197
|
+
n.x = padding.left + usableWidth / 2;
|
|
198
|
+
n.y = y;
|
|
199
|
+
});
|
|
200
|
+
currentRow++;
|
|
201
|
+
}
|
|
186
202
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
});
|
|
203
|
-
currentRow++;
|
|
204
|
-
|
|
205
|
-
// Level 2 (divisions) — grouped under their parent dimension
|
|
206
|
-
const divY = yFor(currentRow);
|
|
207
|
-
const divAlloc = {}; // divisionId -> { x, dimKey }
|
|
208
|
-
dimOrder.forEach(key => {
|
|
209
|
-
const dim = dimensions ? dimensions[key] : null;
|
|
210
|
-
if (!dim) return;
|
|
211
|
-
const alloc = dimAlloc[dim.nodeId];
|
|
212
|
-
if (!alloc) return;
|
|
213
|
-
const divs = divisionsByDim[key] || [];
|
|
214
|
-
if (divs.length === 0) return;
|
|
215
|
-
const range = alloc.rangeEnd - alloc.rangeStart;
|
|
216
|
-
const divSpacing = range / (divs.length + 1);
|
|
217
|
-
divs.forEach((n, i) => {
|
|
218
|
-
n.x = alloc.rangeStart + divSpacing * (i + 1);
|
|
219
|
-
n.y = divY;
|
|
220
|
-
divAlloc[n.id] = { x: n.x, dimKey: key, idx: i };
|
|
203
|
+
// Level 1 (dimensions) — evenly spaced
|
|
204
|
+
const dimY = yFor(currentRow);
|
|
205
|
+
const dimCount = level1.length || 1;
|
|
206
|
+
// Map dimension nodeId to its horizontal allocation
|
|
207
|
+
const dimAlloc = {};
|
|
208
|
+
level1.forEach((n, i) => {
|
|
209
|
+
const x = padding.left + (usableWidth / (dimCount + 1)) * (i + 1);
|
|
210
|
+
n.x = x;
|
|
211
|
+
n.y = dimY;
|
|
212
|
+
dimAlloc[n.id] = {
|
|
213
|
+
x,
|
|
214
|
+
idx: i,
|
|
215
|
+
rangeStart: padding.left + (usableWidth / dimCount) * i,
|
|
216
|
+
rangeEnd: padding.left + (usableWidth / dimCount) * (i + 1)
|
|
217
|
+
};
|
|
221
218
|
});
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
219
|
+
currentRow++;
|
|
220
|
+
|
|
221
|
+
// Level 2 (divisions) — grouped under their parent dimension
|
|
222
|
+
const divY = yFor(currentRow);
|
|
223
|
+
const divAlloc = {}; // divisionId -> { x, dimKey }
|
|
224
|
+
dimOrder.forEach(key => {
|
|
225
|
+
const dim = dimensions ? dimensions[key] : null;
|
|
226
|
+
if (!dim) return;
|
|
227
|
+
const alloc = dimAlloc[dim.nodeId];
|
|
228
|
+
if (!alloc) return;
|
|
229
|
+
const divs = divisionsByDim[key] || [];
|
|
230
|
+
if (divs.length === 0) return;
|
|
231
|
+
const range = alloc.rangeEnd - alloc.rangeStart;
|
|
232
|
+
const divSpacing = range / (divs.length + 1);
|
|
233
|
+
divs.forEach((n, i) => {
|
|
234
|
+
n.x = alloc.rangeStart + divSpacing * (i + 1);
|
|
235
|
+
n.y = divY;
|
|
236
|
+
divAlloc[n.id] = { x: n.x, dimKey: key, idx: i };
|
|
237
|
+
});
|
|
232
238
|
});
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
239
|
+
currentRow++;
|
|
240
|
+
|
|
241
|
+
// Leaf nodes
|
|
242
|
+
const leafY = leafYPos;
|
|
243
|
+
if (dimOrder.length === 0 || !dimensions) {
|
|
244
|
+
// No dimensions — flat row, centered in leaf area
|
|
245
|
+
leafNodes.forEach((n, i) => {
|
|
246
|
+
n.x = padding.left + (usableWidth / (leafNodes.length + 1)) * (i + 1);
|
|
247
|
+
n.y = leafAreaCenter;
|
|
248
|
+
});
|
|
249
|
+
} else if (dimOrder.length === 1) {
|
|
250
|
+
// Single dimension — spread leaves under their division parent, centered in leaf area
|
|
251
|
+
const key = dimOrder[0];
|
|
252
|
+
const dim = dimensions[key];
|
|
253
|
+
const divIds = Object.keys(dim.divisions || {});
|
|
254
|
+
divIds.forEach(divId => {
|
|
255
|
+
const div = dim.divisions[divId];
|
|
256
|
+
const leafIds = Object.keys(div.values || {});
|
|
257
|
+
const parentX = divAlloc[divId]?.x || width / 2;
|
|
258
|
+
const spreadWidth = usableWidth / (divIds.length || 1);
|
|
259
|
+
const leafSpacing = spreadWidth / (leafIds.length + 1);
|
|
260
|
+
leafIds.forEach((leafId, i) => {
|
|
261
|
+
const n = nodeObjById[leafId];
|
|
262
|
+
if (n) {
|
|
263
|
+
n.x = parentX - spreadWidth / 2 + leafSpacing * (i + 1);
|
|
264
|
+
n.y = leafAreaCenter;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
} else {
|
|
269
|
+
// 2+ dimensions — grid layout
|
|
270
|
+
// Prefer horizontal spread: ensure cols >= rows by swapping dims if needed.
|
|
271
|
+
// This prevents single-column layouts (e.g. when dim1 is compressed to 1 division).
|
|
272
|
+
let dim1Key = dimOrder[0];
|
|
273
|
+
let dim2Key = dimOrder[1];
|
|
274
|
+
let dim1 = dimensions[dim1Key];
|
|
275
|
+
let dim2 = dimensions[dim2Key];
|
|
276
|
+
let div1Ids = Object.keys(dim1.divisions || {});
|
|
277
|
+
let div2Ids = Object.keys(dim2.divisions || {});
|
|
278
|
+
|
|
279
|
+
let cols = div1Ids.length || 1;
|
|
280
|
+
let rows = div2Ids.length || 1;
|
|
281
|
+
|
|
282
|
+
if (cols < rows) {
|
|
283
|
+
[dim1Key, dim2Key] = [dim2Key, dim1Key];
|
|
284
|
+
[dim1, dim2] = [dim2, dim1];
|
|
285
|
+
[div1Ids, div2Ids] = [div2Ids, div1Ids];
|
|
286
|
+
[cols, rows] = [rows, cols];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Grid occupies the full leaf area
|
|
290
|
+
const gridTop = leafY;
|
|
291
|
+
const gridBottom = height - padding.bottom;
|
|
292
|
+
const gridHeight = gridBottom - gridTop;
|
|
293
|
+
const colSpacing = usableWidth / (cols + 1);
|
|
294
|
+
const rowSpacingGrid = gridHeight / (rows + 1);
|
|
295
|
+
|
|
296
|
+
// Group leaves by their grid cell so we can spread co-located nodes.
|
|
297
|
+
const cellGroups = {};
|
|
298
|
+
leafNodes.forEach(n => {
|
|
299
|
+
const ld = leafDivisions[n.id];
|
|
300
|
+
if (!ld) {
|
|
301
|
+
n.x = width - padding.right;
|
|
302
|
+
n.y = leafY;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const colDivId = ld[dim1Key];
|
|
306
|
+
const rowDivId = ld[dim2Key];
|
|
307
|
+
const colIdx = div1Ids.indexOf(colDivId);
|
|
308
|
+
const rowIdx = div2Ids.indexOf(rowDivId);
|
|
309
|
+
const cellKey = `${colIdx},${rowIdx}`;
|
|
310
|
+
if (!cellGroups[cellKey]) cellGroups[cellKey] = { colIdx, rowIdx, nodes: [] };
|
|
311
|
+
cellGroups[cellKey].nodes.push(n);
|
|
312
|
+
});
|
|
313
|
+
// Position each group, spreading nodes within shared cells.
|
|
314
|
+
const cellPad = Math.min(colSpacing, rowSpacingGrid) * 0.6;
|
|
315
|
+
Object.values(cellGroups).forEach(({ colIdx, rowIdx, nodes: cellNodes }) => {
|
|
316
|
+
const cx = padding.left + colSpacing * (colIdx + 1);
|
|
317
|
+
const cy = gridTop + rowSpacingGrid * (rowIdx + 1);
|
|
318
|
+
if (cellNodes.length === 1) {
|
|
319
|
+
cellNodes[0].x = cx;
|
|
320
|
+
cellNodes[0].y = cy;
|
|
321
|
+
} else {
|
|
322
|
+
// Spread vertically within the cell (matches row-based dim2)
|
|
323
|
+
const spread = cellPad;
|
|
324
|
+
const step = spread / (cellNodes.length - 1);
|
|
325
|
+
cellNodes.forEach((n, i) => {
|
|
326
|
+
n.x = cx;
|
|
327
|
+
n.y = cy - spread / 2 + step * i;
|
|
328
|
+
});
|
|
249
329
|
}
|
|
250
330
|
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Other nodes (exit, etc.) — placed bottom-right
|
|
334
|
+
otherNodes.forEach((n, i) => {
|
|
335
|
+
n.x = width - padding.right - i * nodeRadius * 3;
|
|
336
|
+
n.y = height - padding.bottom;
|
|
251
337
|
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
let cols = div1Ids.length || 1;
|
|
264
|
-
let rows = div2Ids.length || 1;
|
|
265
|
-
|
|
266
|
-
if (cols < rows) {
|
|
267
|
-
[dim1Key, dim2Key] = [dim2Key, dim1Key];
|
|
268
|
-
[dim1, dim2] = [dim2, dim1];
|
|
269
|
-
[div1Ids, div2Ids] = [div2Ids, div1Ids];
|
|
270
|
-
[cols, rows] = [rows, cols];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Frame over 2+ dimensions: root on top, then title, axes, legends, and data share the level 0 row
|
|
341
|
+
if (frame && !useLayered) {
|
|
342
|
+
const rootObj = nodeObjById[frame.rootId];
|
|
343
|
+
const dataObj = nodeObjById[frame.dataId];
|
|
344
|
+
const rowY = dataObj ? dataObj.y : padding.top;
|
|
345
|
+
if (rootObj) {
|
|
346
|
+
rootObj.x = padding.left + (width - padding.left - padding.right) / 2;
|
|
347
|
+
rootObj.y = frameTop;
|
|
271
348
|
}
|
|
349
|
+
const row = [...frame.elementIds, frame.dataId];
|
|
350
|
+
const span = width - padding.left - padding.right;
|
|
351
|
+
row.forEach((id, i) => {
|
|
352
|
+
const n = nodeObjById[id];
|
|
353
|
+
if (!n) return;
|
|
354
|
+
n.x = padding.left + (span / (row.length + 1)) * (i + 1);
|
|
355
|
+
n.y = rowY;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
272
358
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (!ld) {
|
|
285
|
-
n.x = width - padding.right;
|
|
286
|
-
n.y = leafY;
|
|
287
|
-
return;
|
|
359
|
+
// Layered layout: each node sits under its parent, leaves spread across the bottom.
|
|
360
|
+
function layoutLayered() {
|
|
361
|
+
const parentOf = {};
|
|
362
|
+
const level0Id = level0[0]?.id;
|
|
363
|
+
nodes.forEach(n => {
|
|
364
|
+
const o = nodeById[n.id];
|
|
365
|
+
if (!o) return;
|
|
366
|
+
if (o.parentId) parentOf[n.id] = o.parentId;
|
|
367
|
+
else if (o.dimensionLevel === 1 && level0Id) parentOf[n.id] = level0Id;
|
|
368
|
+
else if (o.dimensionLevel === 2 && dimensions?.[o.derivedNode]) {
|
|
369
|
+
parentOf[n.id] = dimensions[o.derivedNode].nodeId;
|
|
288
370
|
}
|
|
289
|
-
const colDivId = ld[dim1Key];
|
|
290
|
-
const rowDivId = ld[dim2Key];
|
|
291
|
-
const colIdx = div1Ids.indexOf(colDivId);
|
|
292
|
-
const rowIdx = div2Ids.indexOf(rowDivId);
|
|
293
|
-
const cellKey = `${colIdx},${rowIdx}`;
|
|
294
|
-
if (!cellGroups[cellKey]) cellGroups[cellKey] = { colIdx, rowIdx, nodes: [] };
|
|
295
|
-
cellGroups[cellKey].nodes.push(n);
|
|
296
371
|
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
});
|
|
372
|
+
Object.keys(leafDivisions).forEach(leafId => {
|
|
373
|
+
if (!parentOf[leafId]) {
|
|
374
|
+
const firstKey = Object.keys(leafDivisions[leafId])[0];
|
|
375
|
+
parentOf[leafId] = leafDivisions[leafId][firstKey];
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
const kids = {};
|
|
379
|
+
nodes.forEach(n => {
|
|
380
|
+
const p = parentOf[n.id];
|
|
381
|
+
if (p && nodeObjById[p]) (kids[p] = kids[p] || []).push(n.id);
|
|
382
|
+
});
|
|
383
|
+
const order = (parentId, list) => {
|
|
384
|
+
if (frame && parentId === frame.rootId) {
|
|
385
|
+
const want = [...frame.elementIds, frame.dataId];
|
|
386
|
+
return [...list].sort((a, b) => want.indexOf(a) - want.indexOf(b));
|
|
313
387
|
}
|
|
388
|
+
const gridRoot = spatialGrid ? nodeById[spatialGrid.rootId] : null;
|
|
389
|
+
return [...list].sort((a, b) => {
|
|
390
|
+
const da = nodeById[a]?.data || {};
|
|
391
|
+
const db = nodeById[b]?.data || {};
|
|
392
|
+
if (da.cellsPerAxis !== undefined && db.cellsPerAxis !== undefined) {
|
|
393
|
+
return db.row - da.row || da.col - db.col;
|
|
394
|
+
}
|
|
395
|
+
const xKey = gridRoot?.data?.xKey;
|
|
396
|
+
if (xKey && typeof da[xKey] === 'number' && typeof db[xKey] === 'number') {
|
|
397
|
+
return da[xKey] - db[xKey];
|
|
398
|
+
}
|
|
399
|
+
return 0;
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
const roots = nodes.filter(n => !parentOf[n.id] && kids[n.id]);
|
|
403
|
+
const depthOf = {};
|
|
404
|
+
const leafOrder = [];
|
|
405
|
+
let maxDepth = 0;
|
|
406
|
+
const walk = (id, depth) => {
|
|
407
|
+
depthOf[id] = depth;
|
|
408
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
409
|
+
const list = order(id, kids[id] || []);
|
|
410
|
+
kids[id] = list;
|
|
411
|
+
if (!list.length) leafOrder.push(id);
|
|
412
|
+
list.forEach(k => walk(k, depth + 1));
|
|
413
|
+
};
|
|
414
|
+
roots.forEach(r => walk(r.id, 0));
|
|
415
|
+
|
|
416
|
+
const usableW = width - padding.left - padding.right;
|
|
417
|
+
const usableH = height - padding.top - padding.bottom;
|
|
418
|
+
const rowH = maxDepth ? usableH / maxDepth : 0;
|
|
419
|
+
// leaves above the deepest row (like frame items) get wider slots
|
|
420
|
+
const weightOf = id => (depthOf[id] < maxDepth ? 4 : 1);
|
|
421
|
+
const totalWeight = leafOrder.reduce((sum, id) => sum + weightOf(id), 0) || 1;
|
|
422
|
+
const unit = usableW / totalWeight;
|
|
423
|
+
const stagger = Math.min(4, Math.max(1, Math.ceil((nodeRadius * 2.5) / unit)));
|
|
424
|
+
let cursor = padding.left;
|
|
425
|
+
let deepIndex = 0;
|
|
426
|
+
leafOrder.forEach(id => {
|
|
427
|
+
const n = nodeObjById[id];
|
|
428
|
+
const w = weightOf(id) * unit;
|
|
429
|
+
n.x = cursor + w / 2;
|
|
430
|
+
cursor += w;
|
|
431
|
+
const deepest = depthOf[id] === maxDepth;
|
|
432
|
+
n.y =
|
|
433
|
+
padding.top +
|
|
434
|
+
depthOf[id] * rowH -
|
|
435
|
+
(deepest && stagger > 1 ? (deepIndex % stagger) * nodeRadius * 2.2 : 0);
|
|
436
|
+
if (deepest) deepIndex++;
|
|
437
|
+
});
|
|
438
|
+
const place = id => {
|
|
439
|
+
const list = kids[id] || [];
|
|
440
|
+
if (!list.length) return;
|
|
441
|
+
list.forEach(place);
|
|
442
|
+
const n = nodeObjById[id];
|
|
443
|
+
n.x = (nodeObjById[list[0]].x + nodeObjById[list[list.length - 1]].x) / 2;
|
|
444
|
+
n.y = padding.top + depthOf[id] * rowH;
|
|
445
|
+
};
|
|
446
|
+
roots.forEach(r => place(r.id));
|
|
447
|
+
otherNodes.length = 0;
|
|
448
|
+
nodes.forEach(n => {
|
|
449
|
+
if (depthOf[n.id] === undefined) otherNodes.push(n);
|
|
450
|
+
});
|
|
451
|
+
otherNodes.forEach((n, i) => {
|
|
452
|
+
n.x = width - padding.right - i * nodeRadius * 3;
|
|
453
|
+
n.y = height - padding.bottom;
|
|
314
454
|
});
|
|
315
455
|
}
|
|
316
456
|
|
|
317
|
-
// Other nodes (exit, etc.) — placed bottom-right
|
|
318
|
-
otherNodes.forEach((n, i) => {
|
|
319
|
-
n.x = width - padding.right - i * nodeRadius * 3;
|
|
320
|
-
n.y = height - padding.bottom;
|
|
321
|
-
});
|
|
322
|
-
|
|
323
457
|
// Build the SVG.
|
|
324
458
|
const svg = create('svg')
|
|
325
459
|
.attr('width', width)
|