@rokkit/graph 1.8.0 → 1.8.2

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 (119) hide show
  1. package/README.md +59 -0
  2. package/dist/Graph.svelte +108 -134
  3. package/dist/Graph.svelte.d.ts +1 -1
  4. package/dist/GraphLegend.svelte +81 -10
  5. package/dist/GraphLegend.svelte.d.ts +15 -0
  6. package/dist/GraphState.svelte.d.ts +161 -52
  7. package/dist/GraphState.svelte.js +380 -304
  8. package/dist/actions/canvas.d.ts +18 -0
  9. package/dist/actions/canvas.js +59 -0
  10. package/dist/actions/choices.d.ts +12 -0
  11. package/dist/actions/choices.js +23 -0
  12. package/dist/actions/interactions.d.ts +20 -0
  13. package/dist/actions/interactions.js +57 -0
  14. package/dist/actions/listen.d.ts +2 -0
  15. package/dist/actions/listen.js +10 -0
  16. package/dist/canvas/frame.d.ts +41 -0
  17. package/dist/canvas/frame.js +34 -0
  18. package/dist/controls/BundleControl.svelte +11 -10
  19. package/dist/controls/BundleControl.svelte.d.ts +0 -7
  20. package/dist/controls/DensityControl.svelte +17 -9
  21. package/dist/controls/DensityControl.svelte.d.ts +0 -8
  22. package/dist/controls/DepthControl.svelte +11 -4
  23. package/dist/controls/DepthControl.svelte.d.ts +0 -9
  24. package/dist/controls/DrillBar.svelte +63 -0
  25. package/dist/controls/DrillBar.svelte.d.ts +9 -0
  26. package/dist/controls/EdgeStyleControl.svelte +10 -10
  27. package/dist/controls/EdgeStyleControl.svelte.d.ts +0 -6
  28. package/dist/controls/HiddenControl.svelte +27 -0
  29. package/dist/controls/HiddenControl.svelte.d.ts +7 -0
  30. package/dist/controls/MeasureControl.svelte +39 -0
  31. package/dist/controls/MeasureControl.svelte.d.ts +12 -0
  32. package/dist/controls/ToggleControl.svelte +33 -0
  33. package/dist/controls/ToggleControl.svelte.d.ts +13 -0
  34. package/dist/controls/ViolationsControl.svelte +27 -0
  35. package/dist/controls/ViolationsControl.svelte.d.ts +7 -0
  36. package/dist/controls/ZoomControl.svelte +21 -18
  37. package/dist/controls/zoom.d.ts +3 -0
  38. package/dist/controls/zoom.js +6 -0
  39. package/dist/diagrams/ArcDiagram.svelte +116 -0
  40. package/dist/diagrams/ArcDiagram.svelte.d.ts +31 -0
  41. package/dist/diagrams/CallTree.svelte +17 -5
  42. package/dist/diagrams/CallTree.svelte.d.ts +5 -1
  43. package/dist/diagrams/DependencyDiagram.svelte +18 -6
  44. package/dist/diagrams/DependencyDiagram.svelte.d.ts +5 -1
  45. package/dist/diagrams/DependencyMatrix.svelte +11 -32
  46. package/dist/diagrams/ErDiagram.svelte +18 -6
  47. package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
  48. package/dist/diagrams/LayersDiagram.svelte +124 -0
  49. package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
  50. package/dist/diagrams/Neighborhood.svelte +26 -6
  51. package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
  52. package/dist/diagrams/PolymetricTree.svelte +116 -0
  53. package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
  54. package/dist/diagrams/StructureDiagram.svelte +38 -8
  55. package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
  56. package/dist/diagrams/Sunburst.svelte +44 -8
  57. package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
  58. package/dist/diagrams/Treemap.svelte +44 -8
  59. package/dist/diagrams/Treemap.svelte.d.ts +12 -2
  60. package/dist/index.d.ts +10 -3
  61. package/dist/index.js +9 -2
  62. package/dist/layout/arcs.d.ts +6 -0
  63. package/dist/layout/arcs.js +141 -0
  64. package/dist/layout/cards.js +9 -2
  65. package/dist/layout/edges.d.ts +13 -1
  66. package/dist/layout/edges.js +26 -2
  67. package/dist/layout/extent.d.ts +17 -0
  68. package/dist/layout/extent.js +27 -0
  69. package/dist/layout/flow.js +3 -1
  70. package/dist/layout/index.d.ts +4 -1
  71. package/dist/layout/index.js +17 -2
  72. package/dist/layout/layers.d.ts +6 -0
  73. package/dist/layout/layers.js +180 -0
  74. package/dist/layout/matrix.d.ts +12 -0
  75. package/dist/layout/matrix.js +20 -0
  76. package/dist/layout/neighborhood.js +16 -7
  77. package/dist/layout/options.d.ts +1 -1
  78. package/dist/layout/options.js +16 -3
  79. package/dist/layout/percentile.d.ts +8 -0
  80. package/dist/layout/percentile.js +13 -0
  81. package/dist/layout/polymetric.d.ts +3 -0
  82. package/dist/layout/polymetric.js +221 -0
  83. package/dist/layout/radial.js +3 -1
  84. package/dist/layout/structure.js +7 -1
  85. package/dist/layout/sunburst.js +11 -4
  86. package/dist/layout/types.d.ts +79 -0
  87. package/dist/layout/world.js +11 -4
  88. package/dist/messages.d.ts +14 -0
  89. package/dist/messages.js +17 -0
  90. package/dist/model/condense.d.ts +8 -0
  91. package/dist/model/condense.js +114 -0
  92. package/dist/model/entities.d.ts +12 -0
  93. package/dist/model/entities.js +12 -0
  94. package/dist/model/normalize.d.ts +2 -6
  95. package/dist/model/normalize.js +64 -2
  96. package/dist/model/relationships.d.ts +30 -0
  97. package/dist/model/relationships.js +51 -0
  98. package/dist/model/share.d.ts +9 -0
  99. package/dist/model/share.js +43 -0
  100. package/dist/model/tree.d.ts +7 -1
  101. package/dist/model/tree.js +11 -4
  102. package/dist/preset.d.ts +18 -0
  103. package/dist/preset.js +34 -3
  104. package/dist/schema/EntitiesView.svelte +8 -6
  105. package/dist/schema/EntityView.svelte +13 -10
  106. package/dist/state/GraphConfig.svelte.d.ts +124 -0
  107. package/dist/state/GraphConfig.svelte.js +253 -0
  108. package/dist/state/GraphDrill.svelte.d.ts +52 -0
  109. package/dist/state/GraphDrill.svelte.js +115 -0
  110. package/dist/state/GraphGroups.svelte.d.ts +35 -0
  111. package/dist/state/GraphGroups.svelte.js +73 -0
  112. package/dist/state/GraphSelection.svelte.d.ts +53 -0
  113. package/dist/state/GraphSelection.svelte.js +94 -0
  114. package/dist/state/intents.d.ts +33 -0
  115. package/dist/state/intents.js +30 -0
  116. package/dist/state/text.d.ts +28 -0
  117. package/dist/state/text.js +56 -0
  118. package/dist/types.d.ts +72 -1
  119. package/package.json +5 -4
@@ -0,0 +1,180 @@
1
+ /* The layered view (#167): modules ranked into horizontal layers, foundations at the bottom.
2
+ *
3
+ * Sugiyama-shaped, but the expensive half — assigning layers — is the HOST's: it knows the
4
+ * intended architecture, and rokkit computes no depth. What is left is placement and the
5
+ * one thing the view exists to show: edges should only point DOWN, and the ones that climb are
6
+ * violations.
7
+ *
8
+ * - one full-width band per layer, layer 0 at the top, drawn as a cluster box so it renders
9
+ * through the existing box path with its label;
10
+ * - a crowded layer wraps into rows rather than one line a thousand cards long;
11
+ * - a layer is ordered by one barycenter pass against the layer above — each node under the
12
+ * mean position of its neighbours there — which removes most of the avoidable crossings;
13
+ * - edges are vertical S-curves, and carry their conformance for the theme to colour.
14
+ */
15
+ import { buildCards } from './cards.js';
16
+ import { CARD_W } from './constants.js';
17
+ import { carried, keepsEdge } from './edges.js';
18
+ import { warnUnknownOptions } from './options.js';
19
+ import { say } from '../messages.js';
20
+ /** Cards per row before a layer wraps. */
21
+ const PER_ROW = 8;
22
+ const GAP_X = 28;
23
+ const GAP_ROW = 20;
24
+ /** Space between one band and the next — where the edges run. */
25
+ const GAP_BAND = 56;
26
+ /** Inner padding of a band, and the strip its label sits in. */
27
+ const PAD = 20;
28
+ const TITLE = 22;
29
+ /** The band a node belongs to: its layer, or none (drawn last, as Unassigned). */
30
+ const UNASSIGNED = Number.POSITIVE_INFINITY;
31
+ const layerOf = (node) => node.layer ?? UNASSIGNED;
32
+ /** How an edge sits against the layering, from the two layers the host gave. */
33
+ export function deriveConformance(from, to) {
34
+ if (from === undefined || to === undefined)
35
+ return undefined;
36
+ if (to === from)
37
+ return 'level';
38
+ if (to < from)
39
+ return 'up';
40
+ return to - from === 1 ? 'down' : 'skip';
41
+ }
42
+ /** Nodes grouped by layer, in band order (Unassigned last). */
43
+ function bandsOf(model) {
44
+ const bands = new Map();
45
+ for (const node of model.nodes) {
46
+ const key = layerOf(node);
47
+ bands.set(key, [...(bands.get(key) ?? []), node]);
48
+ }
49
+ return [...bands.entries()].sort(([a], [b]) => a - b);
50
+ }
51
+ /** Undirected neighbour ids per node — the barycenter reads both directions. */
52
+ function neighbours(model) {
53
+ const near = new Map();
54
+ const link = (a, b) => near.set(a, [...(near.get(a) ?? []), b]);
55
+ for (const e of model.edges) {
56
+ if (e.source === e.target)
57
+ continue;
58
+ link(e.source, e.target);
59
+ link(e.target, e.source);
60
+ }
61
+ return near;
62
+ }
63
+ /**
64
+ * Order each band: the first alphabetically, every later one by the mean index of its
65
+ * neighbours in the band above (alphabetical among nodes with none there).
66
+ */
67
+ function order(bands, near) {
68
+ const ordered = [];
69
+ let above = new Map();
70
+ for (const [, nodes] of bands) {
71
+ const centre = (n) => {
72
+ const hits = (near.get(n.id) ?? []).map((id) => above.get(id)).filter((i) => i !== undefined);
73
+ return hits.length > 0 ? hits.reduce((s, i) => s + i, 0) / hits.length : Number.POSITIVE_INFINITY;
74
+ };
75
+ const band = [...nodes].sort((a, b) => centre(a) - centre(b) || a.label.localeCompare(b.label));
76
+ ordered.push(band);
77
+ above = new Map(band.map((n, i) => [n.id, i]));
78
+ }
79
+ return ordered;
80
+ }
81
+ const chunk = (items, size) => Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, (i + 1) * size));
82
+ const rowWidth = (count) => count * CARD_W + Math.max(0, count - 1) * GAP_X;
83
+ /** Place one band's cards, row by row, centred in the shared width. Returns the band height. */
84
+ function placeBand(band, cards, top, width) {
85
+ let y = top + TITLE + PAD;
86
+ const rows = chunk(band, PER_ROW);
87
+ rows.forEach((row, r) => {
88
+ let x = (width - rowWidth(row.length)) / 2;
89
+ const tallest = Math.max(...row.map((n) => cards[n.id].h));
90
+ for (const node of row) {
91
+ Object.assign(cards[node.id], { x, y });
92
+ x += CARD_W + GAP_X;
93
+ }
94
+ y += tallest + (r < rows.length - 1 ? GAP_ROW : 0);
95
+ });
96
+ return y + PAD - top;
97
+ }
98
+ /** The host's name for a layer, else the locale's — "Layer 2", "Unassigned". */
99
+ const labelFor = (layer, labels) => layer === UNASSIGNED ? say('layerUnassigned') : (labels?.[layer] ?? say('layer', { n: layer }));
100
+ /** A vertical S: out of the bottom of a card above, into the top of a card below (or reverse). */
101
+ function route(edge, i, cards, conformance) {
102
+ const a = cards[edge.source];
103
+ const b = cards[edge.target];
104
+ const downward = b.y >= a.y + a.h;
105
+ const x1 = a.x + a.w / 2;
106
+ const x2 = b.x + b.w / 2;
107
+ const y1 = downward ? a.y + a.h : a.y;
108
+ const y2 = downward ? b.y : b.y + b.h;
109
+ const mid = (y1 + y2) / 2;
110
+ return {
111
+ i,
112
+ id: edge.id,
113
+ fromKey: edge.source,
114
+ toKey: edge.target,
115
+ kind: edge.kind,
116
+ relation: edge.relation,
117
+ ...carried(edge),
118
+ ...(conformance ? { conformance } : {}),
119
+ self: false,
120
+ x1,
121
+ y1,
122
+ x2,
123
+ y2,
124
+ s1: 1,
125
+ s2: 1,
126
+ path: `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`
127
+ };
128
+ }
129
+ function edgesFor(model, cards, showEdges) {
130
+ const routed = [];
131
+ model.edges.forEach((edge, i) => {
132
+ if (edge.source === edge.target || !cards[edge.source] || !cards[edge.target])
133
+ return;
134
+ const conformance = edge.conformance ??
135
+ deriveConformance(cards[edge.source].node.layer, cards[edge.target].node.layer);
136
+ const routedEdge = route(edge, i, cards, conformance);
137
+ if (keepsEdge(showEdges, routedEdge))
138
+ routed.push(routedEdge);
139
+ });
140
+ return routed;
141
+ }
142
+ /** Stack the bands top to bottom, each a full-width labelled box around its cards. */
143
+ function placeBands(ordered, layerKeys, cards, { width, labels }) {
144
+ const clusters = [];
145
+ let top = 0;
146
+ ordered.forEach((band, i) => {
147
+ const h = placeBand(band, cards, top, width);
148
+ clusters.push({
149
+ name: labelFor(layerKeys[i], labels),
150
+ caption: String(band.length),
151
+ list: band,
152
+ count: band.length,
153
+ groupIndex: i,
154
+ depth: 0,
155
+ x: 0,
156
+ y: top,
157
+ w: width,
158
+ h
159
+ });
160
+ top += h + GAP_BAND;
161
+ });
162
+ return { clusters, height: top - GAP_BAND };
163
+ }
164
+ /** Host-assigned layers as horizontal bands, layer 0 at the top. */
165
+ export const layers = (model, options) => {
166
+ warnUnknownOptions(options, 'layers');
167
+ if (model.nodes.length === 0)
168
+ return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
169
+ const cards = buildCards(model.nodes, options.density ?? 'names', { expanded: options.expanded });
170
+ const bands = bandsOf(model);
171
+ const ordered = order(bands, neighbours(model));
172
+ const width = rowWidth(Math.min(PER_ROW, Math.max(...ordered.map((b) => b.length)))) + PAD * 2;
173
+ const { clusters, height } = placeBands(ordered, bands.map(([layer]) => layer), cards, { width, labels: options.layerLabels });
174
+ return {
175
+ clusters,
176
+ cards,
177
+ edges: edgesFor(model, cards, options.showEdges ?? 'all'),
178
+ size: { w: width, h: height }
179
+ };
180
+ };
@@ -36,3 +36,15 @@ export type MatrixOptions = {
36
36
  groupBy?: NodeAxis;
37
37
  };
38
38
  export declare function buildMatrix(model: GraphModel, options?: MatrixOptions): Matrix;
39
+ /** The matrix's frame: a label gutter sized by the longest name (by length, not the DOM —
40
+ * deterministic, like every layout here), and the canvas around `n` cells of `cell` px. */
41
+ export declare function matrixFrame(matrix: Pick<Matrix, 'labels' | 'order'>, cell: number): {
42
+ labelW: number;
43
+ n: number;
44
+ width: number;
45
+ height: number;
46
+ };
47
+ /** A cell on the selected node's row or column is highlighted; nothing is without a selection. */
48
+ export declare const cellState: (selected: string | null, source: string, target: string) => "highlight" | undefined;
49
+ /** The matrix's accessible name: what it holds, from the locale. */
50
+ export declare function matrixLabel(nodes: number, dependencies: number, above: number): string;
@@ -13,6 +13,7 @@
13
13
  *
14
14
  * Pure and DOM-free, like every layout.
15
15
  */
16
+ import { counted, say } from '../messages.js';
16
17
  import { rank } from './rank.js';
17
18
  const axisOf = (node, axis) => (axis === 'kind' ? node.kind : node.group) ?? '';
18
19
  /** Node ids, providers first; with `groupBy`, each group contiguous in first-ranked order. */
@@ -88,3 +89,22 @@ export function buildMatrix(model, options = {}) {
88
89
  maxCount: Math.max(0, ...cells.map((c) => c.count))
89
90
  };
90
91
  }
92
+ /** The matrix's frame: a label gutter sized by the longest name (by length, not the DOM —
93
+ * deterministic, like every layout here), and the canvas around `n` cells of `cell` px. */
94
+ export function matrixFrame(matrix, cell) {
95
+ const longest = Math.max(0, ...matrix.labels.map((label) => label.length));
96
+ const labelW = Math.min(220, Math.max(48, longest * 6.5 + 14));
97
+ const n = matrix.order.length;
98
+ const side = labelW + n * cell + 8;
99
+ return { labelW, n, width: side, height: side };
100
+ }
101
+ /** A cell on the selected node's row or column is highlighted; nothing is without a selection. */
102
+ export const cellState = (selected, source, target) => selected && (source === selected || target === selected) ? 'highlight' : undefined;
103
+ /** The matrix's accessible name: what it holds, from the locale. */
104
+ export function matrixLabel(nodes, dependencies, above) {
105
+ return say('matrix', {
106
+ nodes: counted(nodes, 'nodeOne', 'nodeMany'),
107
+ dependencies: counted(dependencies, 'dependencyOne', 'dependencyMany'),
108
+ above
109
+ });
110
+ }
@@ -115,12 +115,17 @@ function buildNeighbourCards(model, neighbours, expanded) {
115
115
  * Place every ring, size the canvas, and name the columns.
116
116
  *
117
117
  * An EMPTY ring costs no width: a depth-2 request on a graph with nothing at depth 2 must not
118
- * leave a gap where that column would have been, so the width is measured from the rightmost
119
- * column that actually exists rather than from the depth asked for.
118
+ * leave a gap where that column would have been, so the width is measured from the deepest
119
+ * column that actually exists rather than from the depth asked for. An empty SIDE is
120
+ * different (#170): the focus is centred, so the shallower side reserves the deeper side's
121
+ * columns — otherwise "centred" would only hold for a node with neighbours both ways.
120
122
  */
121
123
  function arrange(built, cards, ctx) {
122
124
  const occupied = built.filter((r) => r.ids.length > 0);
123
- const { focusX, xFor } = geometry(occupied);
125
+ // The same edge room on BOTH sides — a self-loop's bow plus a hairline — so the canvas
126
+ // stays symmetric about the focus.
127
+ const edge = (ctx.hasLoop ? LOOP_W : 0) + 2;
128
+ const { focusX, xFor } = geometry(occupied, edge);
124
129
  const focusCard = cards[ctx.focus.id];
125
130
  const height = placeRings(occupied, cards, { focusCard, focusX, xFor });
126
131
  const columns = buildColumns(occupied, {
@@ -129,7 +134,9 @@ function arrange(built, cards, ctx) {
129
134
  xFor,
130
135
  dependency: ctx.dependency
131
136
  });
132
- const width = xFor('out', furthest(occupied, 'out')) + CARD_W + (ctx.hasLoop ? LOOP_W : 0) + 4;
137
+ // Symmetric about the focus (#170): as much room right of it as left, so the focus card's
138
+ // centre IS the canvas's centre whichever side is empty or deeper.
139
+ const width = focusX * 2 + CARD_W;
133
140
  return { columns, size: { w: width, h: height } };
134
141
  }
135
142
  /**
@@ -161,11 +168,13 @@ function drawableIn(model, cards) {
161
168
  }
162
169
  /**
163
170
  * Where each ring sits. Ring d on the `in` side is d steps left of the focus; on the `out`
164
- * side, d steps right. The focus is pushed right by however deep the inbound side goes.
171
+ * side, d steps right. The focus sits past `edge` and as many columns as the DEEPER side
172
+ * needs, so both sides get the same room and the focus is the centre.
165
173
  */
166
- function geometry(occupied) {
174
+ function geometry(occupied, edge) {
167
175
  const columnStep = CARD_W + COL_GAP;
168
- const focusX = furthest(occupied, 'in') * columnStep;
176
+ const reach = Math.max(furthest(occupied, 'in'), furthest(occupied, 'out'));
177
+ const focusX = edge + reach * columnStep;
169
178
  return {
170
179
  focusX,
171
180
  xFor: (side, d) => (side === 'in' ? focusX - d * columnStep : focusX + d * columnStep)
@@ -4,7 +4,7 @@ import type { LayoutOptions } from './types.js';
4
4
  * missed here would warn about a legitimate option, which is worse than the silence this
5
5
  * exists to fix. `spec/options.spec.ts` compares the two.
6
6
  */
7
- export declare const LAYOUT_OPTION_KEYS: readonly ["density", "arrange", "edgeStyle", "focus", "expanded", "groupBy", "nestBy", "sizeBy", "sizeScale", "depth", "focusPath", "levels", "radialMode", "root", "bundleTension"];
7
+ export declare const LAYOUT_OPTION_KEYS: readonly ["density", "arrange", "edgeStyle", "focus", "expanded", "groupBy", "nestBy", "sizeBy", "sizeScale", "depth", "focusPath", "levels", "radialMode", "root", "bundleTension", "showEdges", "layerLabels", "widthBy", "heightBy", "colorBy", "above", "shadeBy"];
8
8
  /**
9
9
  * Which options have an EFFECT under each built-in layout.
10
10
  *
@@ -18,7 +18,14 @@ export const LAYOUT_OPTION_KEYS = [
18
18
  'levels',
19
19
  'radialMode',
20
20
  'root',
21
- 'bundleTension'
21
+ 'bundleTension',
22
+ 'showEdges',
23
+ 'layerLabels',
24
+ 'widthBy',
25
+ 'heightBy',
26
+ 'colorBy',
27
+ 'above',
28
+ 'shadeBy'
22
29
  ];
23
30
  /**
24
31
  * Which options have an EFFECT under each built-in layout.
@@ -39,14 +46,20 @@ export const LAYOUT_OPTION_KEYS = [
39
46
  * its own purpose.
40
47
  */
41
48
  export const LAYOUT_OPTIONS = {
49
+ // No edgeStyle: an arc is the only shape that keeps one axis readable.
50
+ arcs: ['showEdges', 'above'],
42
51
  cluster: ['density', 'arrange', 'edgeStyle', 'expanded', 'groupBy', 'nestBy'],
43
52
  flow: ['density', 'edgeStyle', 'expanded'],
53
+ // No edgeStyle: a layered edge is always a vertical S, built by the layout itself.
54
+ layers: ['density', 'expanded', 'showEdges', 'layerLabels'],
44
55
  neighborhood: ['edgeStyle', 'expanded', 'focus', 'depth'],
45
56
  points: ['edgeStyle', 'sizeBy', 'sizeScale'],
57
+ // No edgeStyle: the tree's links are elbows the layout draws itself.
58
+ polymetric: ['widthBy', 'heightBy', 'colorBy'],
46
59
  radial: ['edgeStyle', 'sizeBy', 'sizeScale', 'radialMode', 'levels', 'root'],
47
- sunburst: ['sizeBy', 'focusPath', 'levels'],
60
+ sunburst: ['sizeBy', 'shadeBy', 'focusPath', 'levels'],
48
61
  structure: ['sizeBy', 'sizeScale', 'focusPath', 'levels', 'bundleTension'],
49
- world: ['sizeBy', 'focusPath', 'levels']
62
+ world: ['sizeBy', 'shadeBy', 'focusPath', 'levels']
50
63
  };
51
64
  /**
52
65
  * Layouts that draw a node as a DOT rather than a card.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The 95th-percentile cap of the present, positive values — 1 when there are none.
3
+ *
4
+ * Code metrics are long-tailed: one file with ten times the functions, one import pair with
5
+ * fifty times the count. Scaling to the maximum shrinks everything else to the minimum, so a
6
+ * channel scales to this instead and a value past it clamps (#168, #169).
7
+ */
8
+ export declare function capOf(values: (number | undefined)[]): number;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The 95th-percentile cap of the present, positive values — 1 when there are none.
3
+ *
4
+ * Code metrics are long-tailed: one file with ten times the functions, one import pair with
5
+ * fifty times the count. Scaling to the maximum shrinks everything else to the minimum, so a
6
+ * channel scales to this instead and a value past it clamps (#168, #169).
7
+ */
8
+ export function capOf(values) {
9
+ const sorted = values.filter((v) => v !== undefined && v > 0).sort((a, b) => a - b);
10
+ if (sorted.length === 0)
11
+ return 1;
12
+ return sorted[Math.min(sorted.length - 1, Math.ceil(0.95 * sorted.length) - 1)];
13
+ }
@@ -0,0 +1,3 @@
1
+ import type { LayoutFn } from './types.js';
2
+ /** The containment tree with each leaf's width, height and shade bound to three measures. */
3
+ export declare const polymetric: LayoutFn;
@@ -0,0 +1,221 @@
1
+ /* The polymetric view (#168): Lanza & Marinescu's System Complexity.
2
+ *
3
+ * A containment tree, drawn top-down, where every LEAF is a box whose width, height and shade
4
+ * carry three independent measures — the three-at-once is the point: narrow-tall-dark reads
5
+ * differently from wide-short-pale, and neither is expressible as area (that is `world`).
6
+ *
7
+ * Degenerate data is the normal case for code metrics, so it is designed for:
8
+ * - each channel scales to the 95th percentile of its values, so one file with 10x the
9
+ * functions of the rest does not shrink every other box to the minimum; a value past the
10
+ * cap is clamped to the maximum and the box says so (`clamped`);
11
+ * - a zero is a value and draws at the minimum; a MISSING measure also draws at the minimum
12
+ * but is marked (`missing`), never passed off as a zero.
13
+ * Every box at one depth is topped on one line, so heights compare at a glance.
14
+ */
15
+ import { buildTree } from '../model/tree.js';
16
+ import { degreeOf } from './sizing.js';
17
+ import { warnUnknownOptions } from './options.js';
18
+ import { capOf } from './percentile.js';
19
+ const MIN = 10;
20
+ const MAX = { width: 140, height: 140, color: 1 };
21
+ /** A container is a label, not a measured thing. */
22
+ const CONTAINER = { w: 132, h: 26 };
23
+ const GAP_X = 18;
24
+ /** Between depth rows — room for the leaf labels under the boxes and the links. */
25
+ const GAP_Y = 46;
26
+ const PAD = 16;
27
+ /** One encoding: the measure it reads, and the value its full extent stands for. */
28
+ class Channel {
29
+ name;
30
+ measure;
31
+ cap;
32
+ #degree;
33
+ constructor(name, measure, leaves, degree) {
34
+ this.name = name;
35
+ this.measure = measure;
36
+ this.#degree = degree;
37
+ this.cap = capOf(leaves.map((l) => this.valueOf(l.node)));
38
+ }
39
+ /** The node's value, or undefined when it has none — distinct from zero. */
40
+ valueOf(node) {
41
+ if (this.measure === 'degree')
42
+ return this.#degree.get(node.id) ?? 0;
43
+ if (this.measure === 'weight')
44
+ return node.weight;
45
+ return node.measures?.[this.measure];
46
+ }
47
+ /** 0..1 of the cap (undefined when missing), and whether the value passed the cap. */
48
+ read(node) {
49
+ const value = this.valueOf(node);
50
+ if (value === undefined)
51
+ return { clamped: false };
52
+ return { t: Math.min(1, Math.max(0, value / this.cap)), clamped: value > this.cap };
53
+ }
54
+ get scale() {
55
+ return { measure: this.measure, cap: this.cap };
56
+ }
57
+ }
58
+ /** One channel's reading onto the box: a size between MIN and the max, or a shade. */
59
+ function apply(out, name, t) {
60
+ if (name === 'color') {
61
+ if (t !== undefined)
62
+ out.shade = t;
63
+ return;
64
+ }
65
+ out[name === 'width' ? 'w' : 'h'] = MIN + (t ?? 0) * (MAX[name] - MIN);
66
+ }
67
+ /** A leaf's width, height and shade, with what was missing or clamped. */
68
+ function encode(node, channels) {
69
+ const out = {};
70
+ const missing = [];
71
+ const clamped = [];
72
+ for (const channel of channels) {
73
+ const { t, clamped: over } = channel.read(node);
74
+ if (t === undefined)
75
+ missing.push(channel.name);
76
+ if (over)
77
+ clamped.push(channel.name);
78
+ apply(out, channel.name, t);
79
+ }
80
+ if (missing.length)
81
+ out.missing = missing;
82
+ if (clamped.length)
83
+ out.clamped = clamped;
84
+ return out;
85
+ }
86
+ const rowSpan = (boxes) => boxes.reduce((s, b) => s + b.span, 0) + GAP_X * Math.max(0, boxes.length - 1);
87
+ function boxFor(tree, encoding, kids) {
88
+ // From the TREE, not from `kids`: a container's kids are attached after it is created.
89
+ const leaf = tree.children.length === 0;
90
+ return {
91
+ name: tree.label,
92
+ list: [],
93
+ count: tree.children.length,
94
+ groupIndex: 0,
95
+ x: 0,
96
+ y: 0,
97
+ depth: tree.depth - 1,
98
+ path: tree.address,
99
+ declared: tree.node?.id,
100
+ ...(leaf ? { nodeId: tree.node?.id, kind: tree.node?.kind } : {}),
101
+ ...encoding,
102
+ tree,
103
+ kids,
104
+ span: 0
105
+ };
106
+ }
107
+ /** Every box in the subtree, sized; `span` is the subtree's width for the tidy layout. */
108
+ function size(tree, channels, out) {
109
+ if (tree.children.length === 0) {
110
+ // A leaf is always a declared node: a synthesised box exists only because something
111
+ // lies below it.
112
+ const leaf = boxFor(tree, encode(tree.node, channels), []);
113
+ leaf.span = leaf.w;
114
+ out.push(leaf);
115
+ return leaf;
116
+ }
117
+ const box = boxFor(tree, { ...CONTAINER }, []);
118
+ out.push(box);
119
+ box.kids = tree.children.map((child) => size(child, channels, out));
120
+ box.span = Math.max(CONTAINER.w, rowSpan(box.kids));
121
+ return box;
122
+ }
123
+ /** Lay the subtree out from `left`: children side by side, the parent centred over them. */
124
+ function place(box, left, rowTop) {
125
+ box.y = rowTop[box.depth ?? 0];
126
+ box.x = left + (box.span - box.w) / 2;
127
+ let x = left + (box.span - rowSpan(box.kids)) / 2;
128
+ for (const kid of box.kids) {
129
+ place(kid, x, rowTop);
130
+ x += kid.span + GAP_X;
131
+ }
132
+ }
133
+ /** The top of each depth row: every box at a depth is topped on one line. */
134
+ function rowTops(boxes) {
135
+ const tallest = [];
136
+ for (const b of boxes)
137
+ tallest[b.depth ?? 0] = Math.max(tallest[b.depth ?? 0] ?? 0, b.h);
138
+ const tops = [];
139
+ let y = PAD;
140
+ for (const h of tallest) {
141
+ tops.push(y);
142
+ y += h + GAP_Y;
143
+ }
144
+ return tops;
145
+ }
146
+ const keyOf = (b) => b.declared ?? b.tree.id;
147
+ /** An elbow from the bottom of a container to the top of what it holds. */
148
+ function link(parent, child, i) {
149
+ const x1 = parent.x + parent.w / 2;
150
+ const y1 = parent.y + parent.h;
151
+ const x2 = child.x + child.w / 2;
152
+ const y2 = child.y;
153
+ return {
154
+ i,
155
+ id: `contains:${keyOf(parent)}>${keyOf(child)}`,
156
+ fromKey: keyOf(parent),
157
+ toKey: keyOf(child),
158
+ kind: 'containment',
159
+ self: false,
160
+ x1,
161
+ y1,
162
+ x2,
163
+ y2,
164
+ s1: 1,
165
+ s2: 1,
166
+ path: `M ${x1} ${y1} V ${(y1 + y2) / 2} H ${x2} V ${y2}`
167
+ };
168
+ }
169
+ function linksOf(roots) {
170
+ const edges = [];
171
+ const walk = (b) => {
172
+ for (const kid of b.kids) {
173
+ edges.push(link(b, kid, edges.length));
174
+ walk(kid);
175
+ }
176
+ };
177
+ roots.forEach(walk);
178
+ return edges;
179
+ }
180
+ /** The channels the options bind; colour only when a measure is named for it. */
181
+ function channelsFor(options, leaves, degree) {
182
+ const channels = [
183
+ new Channel('width', options.widthBy ?? 'weight', leaves, degree),
184
+ new Channel('height', options.heightBy ?? 'weight', leaves, degree)
185
+ ];
186
+ if (options.colorBy)
187
+ channels.push(new Channel('color', options.colorBy, leaves, degree));
188
+ return channels;
189
+ }
190
+ /** Size and place the forest under the tree root; returns the boxes and the canvas width. */
191
+ function lay(roots, channels) {
192
+ const boxes = [];
193
+ const tops = roots.map((root) => size(root, channels, boxes));
194
+ const rowTop = rowTops(boxes);
195
+ let left = PAD;
196
+ for (const top of tops) {
197
+ place(top, left, rowTop);
198
+ left += top.span + GAP_X * 2;
199
+ }
200
+ return { boxes, tops, width: left - GAP_X * 2 + PAD };
201
+ }
202
+ /** The containment tree with each leaf's width, height and shade bound to three measures. */
203
+ export const polymetric = (model, options) => {
204
+ warnUnknownOptions(options, 'polymetric');
205
+ const tree = buildTree(model);
206
+ if (tree.children.length === 0)
207
+ return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
208
+ const leaves = (function all(n) {
209
+ return n.children.length === 0 ? [n] : n.children.flatMap(all);
210
+ })(tree);
211
+ const channels = channelsFor(options, leaves, degreeOf(model));
212
+ const { boxes, tops, width } = lay(tree.children, channels);
213
+ const [w, h, color] = channels;
214
+ return {
215
+ clusters: boxes.map(({ tree: _t, span: _s, kids: _k, ...cluster }) => cluster),
216
+ cards: {},
217
+ edges: linksOf(tops),
218
+ size: { w: width, h: Math.max(...boxes.map((b) => b.y + b.h)) + PAD + GAP_Y / 2 },
219
+ channels: { width: w.scale, height: h.scale, ...(color ? { color: color.scale } : {}) }
220
+ };
221
+ };
@@ -14,6 +14,7 @@
14
14
  * edges it could not use are still drawn, marked `back`, because "b also calls this" is
15
15
  * exactly what a reader is looking for.
16
16
  */
17
+ import { carried } from './edges.js';
17
18
  import { childrenIn, hierarchy } from './hierarchy.js';
18
19
  import { nodeSizes } from './sizing.js';
19
20
  import { warnUnknownOptions } from './options.js';
@@ -143,7 +144,8 @@ function identity(edge, i) {
143
144
  fromKey: edge.source,
144
145
  toKey: edge.target,
145
146
  kind: edge.kind,
146
- relation: edge.relation
147
+ relation: edge.relation,
148
+ ...carried(edge)
147
149
  };
148
150
  }
149
151
  /**
@@ -20,6 +20,7 @@
20
20
  * Modelled on Sensei's Structure board (docs/mockups/Sensei, "Project window · Diagrams ·
21
21
  * Structure"), which is d3.cluster + lineRadial + curveBundle over the same hierarchy.
22
22
  */
23
+ import { carried } from './edges.js';
23
24
  import { buildTree, findNode } from '../model/tree.js';
24
25
  import { bundlePath, commonDepth } from './bundle.js';
25
26
  import { nodeSizes } from './sizing.js';
@@ -153,6 +154,10 @@ function band(node, ctx, index) {
153
154
  // no band appears at all rather than one looking wrong.
154
155
  parent: node.path.slice(0, -1).join('/'),
155
156
  ramp: node.path[ctx.origin],
157
+ // A band is a container by construction — its leaves are the node cards on the rim.
158
+ path: node.address,
159
+ leaf: false,
160
+ declared: node.node?.id,
156
161
  count: node.children.length,
157
162
  list: node.children.map((c) => c.node).filter((n) => n !== undefined),
158
163
  groupIndex: index,
@@ -261,6 +266,7 @@ function routeAll(model, root, ctx, tension) {
261
266
  toKey: edge.target,
262
267
  kind: edge.kind,
263
268
  relation: edge.relation ?? reach(a, b),
269
+ ...carried(edge),
264
270
  self: false,
265
271
  x1: from.x,
266
272
  y1: from.y,
@@ -340,7 +346,7 @@ function dots(leaves, ring, ctx) {
340
346
  */
341
347
  export const structure = (model, options) => {
342
348
  warnUnknownOptions(options, 'structure');
343
- const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
349
+ const tree = buildTree(model, { measure: options.sizeBy ?? 'weight', keep: options.focusPath });
344
350
  const root = findNode(tree, options.focusPath ?? []);
345
351
  if (!root || root.children.length === 0) {
346
352
  return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };