@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-navigator/inspector",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "author": "Frank Elavsky",
6
6
  "license": "MIT",
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) return 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({ nodes: nodeArray, links: linkArray }, { ...graphOptions, dimensions: structure.dimensions })
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 usableWidth = width - padding.left - padding.right;
163
- const usableHeight = height - padding.top - padding.bottom;
164
-
165
- const hasLevel0 = level0.length > 0;
166
- const upperRows = (hasLevel0 ? 1 : 0) + 1 + 1; // l0?, l1, l2
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
- // Level 1 (dimensions) — evenly spaced
188
- const dimY = yFor(currentRow);
189
- const dimCount = level1.length || 1;
190
- // Map dimension nodeId to its horizontal allocation
191
- const dimAlloc = {};
192
- level1.forEach((n, i) => {
193
- const x = padding.left + (usableWidth / (dimCount + 1)) * (i + 1);
194
- n.x = x;
195
- n.y = dimY;
196
- dimAlloc[n.id] = {
197
- x,
198
- idx: i,
199
- rangeStart: padding.left + (usableWidth / dimCount) * i,
200
- rangeEnd: padding.left + (usableWidth / dimCount) * (i + 1)
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
- currentRow++;
224
-
225
- // Leaf nodes
226
- const leafY = leafYPos;
227
- if (dimOrder.length === 0 || !dimensions) {
228
- // No dimensions — flat row, centered in leaf area
229
- leafNodes.forEach((n, i) => {
230
- n.x = padding.left + (usableWidth / (leafNodes.length + 1)) * (i + 1);
231
- n.y = leafAreaCenter;
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
- } else if (dimOrder.length === 1) {
234
- // Single dimension — spread leaves under their division parent, centered in leaf area
235
- const key = dimOrder[0];
236
- const dim = dimensions[key];
237
- const divIds = Object.keys(dim.divisions || {});
238
- divIds.forEach(divId => {
239
- const div = dim.divisions[divId];
240
- const leafIds = Object.keys(div.values || {});
241
- const parentX = divAlloc[divId]?.x || width / 2;
242
- const spreadWidth = usableWidth / (divIds.length || 1);
243
- const leafSpacing = spreadWidth / (leafIds.length + 1);
244
- leafIds.forEach((leafId, i) => {
245
- const n = nodeObjById[leafId];
246
- if (n) {
247
- n.x = parentX - spreadWidth / 2 + leafSpacing * (i + 1);
248
- n.y = leafAreaCenter;
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
- } else {
253
- // 2+ dimensions — grid layout
254
- // Prefer horizontal spread: ensure cols >= rows by swapping dims if needed.
255
- // This prevents single-column layouts (e.g. when dim1 is compressed to 1 division).
256
- let dim1Key = dimOrder[0];
257
- let dim2Key = dimOrder[1];
258
- let dim1 = dimensions[dim1Key];
259
- let dim2 = dimensions[dim2Key];
260
- let div1Ids = Object.keys(dim1.divisions || {});
261
- let div2Ids = Object.keys(dim2.divisions || {});
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
- // Grid occupies the full leaf area
274
- const gridTop = leafY;
275
- const gridBottom = height - padding.bottom;
276
- const gridHeight = gridBottom - gridTop;
277
- const colSpacing = usableWidth / (cols + 1);
278
- const rowSpacingGrid = gridHeight / (rows + 1);
279
-
280
- // Group leaves by their grid cell so we can spread co-located nodes.
281
- const cellGroups = {};
282
- leafNodes.forEach(n => {
283
- const ld = leafDivisions[n.id];
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
- // Position each group, spreading nodes within shared cells.
298
- const cellPad = Math.min(colSpacing, rowSpacingGrid) * 0.6;
299
- Object.values(cellGroups).forEach(({ colIdx, rowIdx, nodes: cellNodes }) => {
300
- const cx = padding.left + colSpacing * (colIdx + 1);
301
- const cy = gridTop + rowSpacingGrid * (rowIdx + 1);
302
- if (cellNodes.length === 1) {
303
- cellNodes[0].x = cx;
304
- cellNodes[0].y = cy;
305
- } else {
306
- // Spread vertically within the cell (matches row-based dim2)
307
- const spread = cellPad;
308
- const step = spread / (cellNodes.length - 1);
309
- cellNodes.forEach((n, i) => {
310
- n.x = cx;
311
- n.y = cy - spread / 2 + step * i;
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)