@data-navigator/inspector 1.0.1 → 1.0.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tree-graph.js +98 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-navigator/inspector",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "author": "Frank Elavsky",
6
6
  "license": "MIT",
package/src/tree-graph.js CHANGED
@@ -55,15 +55,24 @@ export function TreeGraph(
55
55
  if (G && nodeGroups === undefined) nodeGroups = sort(G);
56
56
  const color = nodeGroup == null ? null : scaleOrdinal(nodeGroups, colors);
57
57
 
58
- // Build set of parent-child navigation rule names.
58
+ // Build set of parent-child and sibling navigation rule names.
59
59
  const parentChildRules = new Set(['parent', 'child']);
60
+ const siblingRulePairs = [['left', 'right']]; // default pair
60
61
  if (dimensions) {
61
62
  Object.values(dimensions).forEach(dim => {
62
63
  (dim.navigationRules?.parent_child || []).forEach(r => parentChildRules.add(r));
64
+ const ss = dim.navigationRules?.sibling_sibling;
65
+ if (ss && ss.length === 2) siblingRulePairs.push(ss);
63
66
  });
64
67
  }
68
+ // Map each sibling rule name to its pair for splitting merged edges.
69
+ const ruleToPair = {};
70
+ siblingRulePairs.forEach(pair => {
71
+ pair.forEach(r => { ruleToPair[r] = pair.join(','); });
72
+ });
65
73
 
66
- // Classify links.
74
+ // Classify links. Sibling links with merged rules (multiple pairs)
75
+ // are split into one entry per distinct pair so each gets its own arc.
67
76
  const parentChildLinks = [];
68
77
  const siblingLinks = [];
69
78
  linkData.forEach(l => {
@@ -75,7 +84,15 @@ export function TreeGraph(
75
84
  if (isParentChild) {
76
85
  parentChildLinks.push(l);
77
86
  } else {
78
- siblingLinks.push(l);
87
+ // Split by distinct sibling rule pairs.
88
+ const seenPairs = new Set();
89
+ rules.forEach(r => {
90
+ const pairKey = ruleToPair[r] || r;
91
+ if (!seenPairs.has(pairKey)) {
92
+ seenPairs.add(pairKey);
93
+ siblingLinks.push({ source: l.source, target: l.target, navigationRules: [r] });
94
+ }
95
+ });
79
96
  }
80
97
  });
81
98
 
@@ -131,17 +148,21 @@ export function TreeGraph(
131
148
  });
132
149
 
133
150
  // Compute layout positions.
151
+ // Upper levels (dimensions, divisions) are compressed; leaves get more room.
134
152
  const padding = { top: 30, bottom: 20, left: 30, right: 30 };
135
153
  const usableWidth = width - padding.left - padding.right;
136
154
  const usableHeight = height - padding.top - padding.bottom;
137
155
 
138
- // Determine rows: level0 (optional), level1, level2, leaves
139
156
  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);
157
+ const upperRows = (hasLevel0 ? 1 : 0) + 1 + 1; // l0?, l1, l2
158
+ // Upper hierarchy gets 25% of height, leaves get the rest after a gap
159
+ const upperHeight = usableHeight * 0.25;
160
+ const upperSpacing = upperRows > 1 ? upperHeight / (upperRows - 1) : upperHeight;
161
+ const leafGap = usableHeight * 0.05; // small gap between hierarchy and leaves
162
+ const leafYPos = padding.top + upperHeight + leafGap;
142
163
 
143
164
  let currentRow = 0;
144
- const yFor = (row) => padding.top + row * rowSpacing;
165
+ const yFor = (row) => padding.top + row * upperSpacing;
145
166
 
146
167
  // Level 0
147
168
  if (hasLevel0) {
@@ -192,7 +213,7 @@ export function TreeGraph(
192
213
  currentRow++;
193
214
 
194
215
  // Leaf nodes
195
- const leafY = yFor(currentRow);
216
+ const leafY = leafYPos;
196
217
  if (dimOrder.length === 0 || !dimensions) {
197
218
  // No dimensions — flat row
198
219
  leafNodes.forEach((n, i) => {
@@ -231,17 +252,18 @@ export function TreeGraph(
231
252
  const cols = div1Ids.length || 1;
232
253
  const rows = div2Ids.length || 1;
233
254
 
234
- // Grid occupies the leaf area but may expand vertically
235
- const gridTop = leafY - rowSpacing * 0.3;
255
+ // Grid occupies the full leaf area
256
+ const gridTop = leafY;
236
257
  const gridBottom = height - padding.bottom;
237
258
  const gridHeight = gridBottom - gridTop;
238
259
  const colSpacing = usableWidth / (cols + 1);
239
260
  const rowSpacingGrid = gridHeight / (rows + 1);
240
261
 
262
+ // Group leaves by their grid cell so we can spread co-located nodes.
263
+ const cellGroups = {};
241
264
  leafNodes.forEach(n => {
242
265
  const ld = leafDivisions[n.id];
243
266
  if (!ld) {
244
- // Unaffiliated leaf — place to the right
245
267
  n.x = width - padding.right;
246
268
  n.y = leafY;
247
269
  return;
@@ -250,8 +272,27 @@ export function TreeGraph(
250
272
  const rowDivId = ld[dim2Key];
251
273
  const colIdx = div1Ids.indexOf(colDivId);
252
274
  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);
275
+ const cellKey = `${colIdx},${rowIdx}`;
276
+ if (!cellGroups[cellKey]) cellGroups[cellKey] = { colIdx, rowIdx, nodes: [] };
277
+ cellGroups[cellKey].nodes.push(n);
278
+ });
279
+ // Position each group, spreading nodes within shared cells.
280
+ const cellPad = Math.min(colSpacing, rowSpacingGrid) * 0.6;
281
+ Object.values(cellGroups).forEach(({ colIdx, rowIdx, nodes: cellNodes }) => {
282
+ const cx = padding.left + colSpacing * (colIdx + 1);
283
+ const cy = gridTop + rowSpacingGrid * (rowIdx + 1);
284
+ if (cellNodes.length === 1) {
285
+ cellNodes[0].x = cx;
286
+ cellNodes[0].y = cy;
287
+ } else {
288
+ // Spread vertically within the cell (matches row-based dim2)
289
+ const spread = cellPad;
290
+ const step = spread / (cellNodes.length - 1);
291
+ cellNodes.forEach((n, i) => {
292
+ n.x = cx;
293
+ n.y = cy - spread / 2 + step * i;
294
+ });
295
+ }
255
296
  });
256
297
  }
257
298
 
@@ -270,20 +311,54 @@ export function TreeGraph(
270
311
  .attr('aria-label', hide ? null : description)
271
312
  .attr('style', 'max-width: 100%; height: auto; height: intrinsic;');
272
313
 
273
- // Draw sibling links (dashed, behind parent-child links).
314
+ // Draw sibling links as arced paths (dashed, behind parent-child links).
315
+ // Regular siblings arc above (horizontal) or left (vertical).
316
+ // Wrap-around (circular) siblings arc below/right with a larger curve.
317
+ // When multiple edges share the same node pair, each gets a larger arc.
318
+ const edgePairCounts = {};
319
+ const siblingArc = (d) => {
320
+ const s = nodeObjById[d.source];
321
+ const t = nodeObjById[d.target];
322
+ if (!s || !t) return '';
323
+ const sx = s.x, sy = s.y, tx = t.x, ty = t.y;
324
+ const dx = tx - sx, dy = ty - sy;
325
+ const dist = Math.sqrt(dx * dx + dy * dy);
326
+ const isHorizontal = Math.abs(dx) > Math.abs(dy);
327
+ // Detect wrap-around: source is to the right of target (horizontal)
328
+ // or below target (vertical) — these are circular edges.
329
+ const isWrap = isHorizontal ? (sx > tx) : (sy > ty);
330
+ // Track regular and wrap arcs separately — they curve in opposite
331
+ // directions so only same-direction arcs need stacking.
332
+ const pairKey = [d.source, d.target].sort().join('::') + (isWrap ? '::wrap' : '::reg');
333
+ const edgeIndex = edgePairCounts[pairKey] || 0;
334
+ edgePairCounts[pairKey] = edgeIndex + 1;
335
+ // Arc offset scales with distance; increase by 20% for each additional same-direction edge
336
+ const arcFraction = 0.3 + edgeIndex * 0.2;
337
+ const arcAmount = Math.min(dist * arcFraction, 40 + edgeIndex * 25);
338
+ const midX = (sx + tx) / 2;
339
+ const midY = (sy + ty) / 2;
340
+ let cx, cy;
341
+ if (isHorizontal) {
342
+ cx = midX;
343
+ cy = isWrap ? midY + arcAmount : midY - arcAmount;
344
+ } else {
345
+ cx = isWrap ? midX + arcAmount : midX - arcAmount;
346
+ cy = midY;
347
+ }
348
+ return `M ${sx} ${sy} Q ${cx} ${cy} ${tx} ${ty}`;
349
+ };
350
+
274
351
  svg.append('g')
275
352
  .attr('class', 'tree-sibling-links')
276
- .selectAll('line')
353
+ .selectAll('path')
277
354
  .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)
355
+ .join('path')
356
+ .attr('d', siblingArc)
357
+ .attr('fill', 'none')
358
+ .attr('stroke', '#888')
359
+ .attr('stroke-opacity', 0.6)
285
360
  .attr('stroke-width', 1)
286
- .attr('stroke-dasharray', '3,2');
361
+ .attr('stroke-dasharray', '4,3');
287
362
 
288
363
  // Draw parent-child links (solid).
289
364
  svg.append('g')