@rokkit/graph 1.7.0 → 1.8.1

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 (120) hide show
  1. package/README.md +67 -1
  2. package/dist/Graph.svelte +234 -259
  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 +168 -51
  7. package/dist/GraphState.svelte.js +406 -305
  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 +156 -0
  46. package/dist/diagrams/DependencyMatrix.svelte.d.ts +34 -0
  47. package/dist/diagrams/ErDiagram.svelte +18 -6
  48. package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
  49. package/dist/diagrams/LayersDiagram.svelte +124 -0
  50. package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
  51. package/dist/diagrams/Neighborhood.svelte +26 -6
  52. package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
  53. package/dist/diagrams/PolymetricTree.svelte +116 -0
  54. package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
  55. package/dist/diagrams/StructureDiagram.svelte +38 -8
  56. package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
  57. package/dist/diagrams/Sunburst.svelte +44 -8
  58. package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
  59. package/dist/diagrams/Treemap.svelte +44 -8
  60. package/dist/diagrams/Treemap.svelte.d.ts +12 -2
  61. package/dist/index.d.ts +13 -3
  62. package/dist/index.js +11 -2
  63. package/dist/layout/arcs.d.ts +6 -0
  64. package/dist/layout/arcs.js +141 -0
  65. package/dist/layout/cards.js +9 -2
  66. package/dist/layout/edges.d.ts +13 -1
  67. package/dist/layout/edges.js +35 -0
  68. package/dist/layout/extent.d.ts +17 -0
  69. package/dist/layout/extent.js +27 -0
  70. package/dist/layout/flow.js +3 -1
  71. package/dist/layout/index.d.ts +4 -1
  72. package/dist/layout/index.js +17 -2
  73. package/dist/layout/layers.d.ts +6 -0
  74. package/dist/layout/layers.js +180 -0
  75. package/dist/layout/matrix.d.ts +50 -0
  76. package/dist/layout/matrix.js +110 -0
  77. package/dist/layout/neighborhood.js +16 -7
  78. package/dist/layout/options.d.ts +1 -1
  79. package/dist/layout/options.js +16 -3
  80. package/dist/layout/percentile.d.ts +8 -0
  81. package/dist/layout/percentile.js +13 -0
  82. package/dist/layout/polymetric.d.ts +3 -0
  83. package/dist/layout/polymetric.js +221 -0
  84. package/dist/layout/radial.js +3 -1
  85. package/dist/layout/structure.js +7 -1
  86. package/dist/layout/sunburst.js +11 -4
  87. package/dist/layout/types.d.ts +83 -0
  88. package/dist/layout/world.js +11 -4
  89. package/dist/messages.d.ts +14 -0
  90. package/dist/messages.js +17 -0
  91. package/dist/model/condense.d.ts +8 -0
  92. package/dist/model/condense.js +114 -0
  93. package/dist/model/entities.d.ts +12 -0
  94. package/dist/model/entities.js +12 -0
  95. package/dist/model/normalize.d.ts +2 -6
  96. package/dist/model/normalize.js +83 -5
  97. package/dist/model/relationships.d.ts +30 -0
  98. package/dist/model/relationships.js +51 -0
  99. package/dist/model/share.d.ts +9 -0
  100. package/dist/model/share.js +43 -0
  101. package/dist/model/tree.d.ts +7 -1
  102. package/dist/model/tree.js +11 -4
  103. package/dist/preset.d.ts +18 -0
  104. package/dist/preset.js +34 -3
  105. package/dist/schema/EntitiesView.svelte +8 -6
  106. package/dist/schema/EntityView.svelte +13 -10
  107. package/dist/state/GraphConfig.svelte.d.ts +124 -0
  108. package/dist/state/GraphConfig.svelte.js +253 -0
  109. package/dist/state/GraphDrill.svelte.d.ts +52 -0
  110. package/dist/state/GraphDrill.svelte.js +115 -0
  111. package/dist/state/GraphGroups.svelte.d.ts +35 -0
  112. package/dist/state/GraphGroups.svelte.js +73 -0
  113. package/dist/state/GraphSelection.svelte.d.ts +53 -0
  114. package/dist/state/GraphSelection.svelte.js +94 -0
  115. package/dist/state/intents.d.ts +33 -0
  116. package/dist/state/intents.js +30 -0
  117. package/dist/state/text.d.ts +28 -0
  118. package/dist/state/text.js +56 -0
  119. package/dist/types.d.ts +92 -1
  120. package/package.json +5 -4
@@ -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 } };
@@ -14,6 +14,7 @@
14
14
  * exact radians.
15
15
  */
16
16
  import { buildTree, findNode } from '../model/tree.js';
17
+ import { shadeFields, shares } from '../model/share.js';
17
18
  import { warnUnknownOptions } from './options.js';
18
19
  /** Outer radius of the deepest materialised ring. The canvas is derived from it. */
19
20
  const RADIUS = 380;
@@ -70,7 +71,7 @@ function split(node, span, ctx) {
70
71
  a += slice;
71
72
  });
72
73
  }
73
- function wedgeOf(entry, groupIndex, ring) {
74
+ function wedgeOf(entry, groupIndex, ring, shaded) {
74
75
  const leaf = entry.tree.children.length === 0 ? entry.tree.node : undefined;
75
76
  return {
76
77
  name: entry.tree.label,
@@ -80,6 +81,9 @@ function wedgeOf(entry, groupIndex, ring) {
80
81
  ramp: entry.ramp,
81
82
  nodeId: leaf?.id,
82
83
  kind: leaf?.kind,
84
+ path: entry.tree.address,
85
+ leaf: entry.tree.children.length === 0,
86
+ declared: entry.tree.node?.id,
83
87
  list: entry.tree.children.map((c) => c.node).filter((n) => n !== undefined),
84
88
  count: entry.tree.children.length,
85
89
  groupIndex,
@@ -94,7 +98,8 @@ function wedgeOf(entry, groupIndex, ring) {
94
98
  x: RADIUS + PAD + Math.cos((entry.a0 + entry.a1) / 2) * ((entry.depth + 0.5) * ring),
95
99
  y: RADIUS + PAD + Math.sin((entry.a0 + entry.a1) / 2) * ((entry.depth + 0.5) * ring),
96
100
  w: 0,
97
- h: 0
101
+ h: 0,
102
+ ...shadeFields(shaded, entry.tree.id)
98
103
  };
99
104
  }
100
105
  /**
@@ -105,18 +110,20 @@ function wedgeOf(entry, groupIndex, ring) {
105
110
  */
106
111
  export const sunburst = (model, options) => {
107
112
  warnUnknownOptions(options, 'sunburst');
108
- const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
113
+ const tree = buildTree(model, { measure: options.sizeBy ?? 'weight', keep: options.focusPath });
109
114
  const root = findNode(tree, options.focusPath ?? []);
110
115
  if (!root || root.children.length === 0) {
111
116
  return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
112
117
  }
113
118
  const levels = options.levels ?? 2;
119
+ // #164: as in `world` — a share per wedge when a measure is named for it.
120
+ const shaded = options.shadeBy ? shares(root, options.shadeBy) : undefined;
114
121
  const placed = [];
115
122
  split(root, { a0: 0, a1: Math.PI * 2 }, { levels, out: placed, origin: root.path.length });
116
123
  const ring = RADIUS / Math.max(1, levels);
117
124
  const extent = (RADIUS + PAD) * 2;
118
125
  return {
119
- clusters: placed.map((entry, i) => wedgeOf(entry, i, ring)),
126
+ clusters: placed.map((entry, i) => wedgeOf(entry, i, ring, shaded)),
120
127
  cards: {},
121
128
  // Edges are absent for the same reason as the treemap: containment IS the relationship,
122
129
  // and an adjacency graph cannot be drawn at this scale.
@@ -103,6 +103,30 @@ export type Cluster = {
103
103
  a0: number;
104
104
  a1: number;
105
105
  };
106
+ /**
107
+ * A containment box's full path from the tree root, outermost first — its ADDRESS.
108
+ *
109
+ * The name repeats across branches (a `table` box under `public` and under `billing`); the
110
+ * path cannot. Drilling into a box sets `focusPath` to exactly this, and it stays absolute
111
+ * when the canvas is itself scoped by `focusPath`. Set by the containment layouts only.
112
+ */
113
+ path?: string[];
114
+ /** Whether nothing lies below this box in the data — so only a host loader can go deeper. */
115
+ leaf?: boolean;
116
+ /** `polymetric` (#168) — the colour measure as 0..1 of its cap; absent when not bound or missing. */
117
+ shade?: number;
118
+ /** `polymetric` — the channels whose measure this box lacks, drawn at the minimum and marked. */
119
+ missing?: PolymetricChannel[];
120
+ /** `polymetric` — the channels whose value exceeded the cap and was clamped to the maximum. */
121
+ clamped?: PolymetricChannel[];
122
+ /**
123
+ * The id of the node this box IS, when one is declared — a leaf OR a container.
124
+ *
125
+ * `nodeId` is set only on leaves, because only a leaf is selectable; a container that is
126
+ * also a real node (a module that holds files) needs its identity for drilling, which is
127
+ * not selection. Undefined on a container nothing declared.
128
+ */
129
+ declared?: string;
106
130
  };
107
131
  /**
108
132
  * Which node property a layout groups on. Both already exist on `GraphNode`, so nesting
@@ -126,6 +150,26 @@ export type RoutedEdge = {
126
150
  * than the reader wondering why one link behaves differently.
127
151
  */
128
152
  back?: boolean;
153
+ /** GraphEdge.overlay — routed after the layout ran, over a picture it did not shape. */
154
+ overlay?: boolean;
155
+ /** GraphEdge.weight, raw. `GraphState.edgeWeight` normalises it for drawing. */
156
+ weight?: number;
157
+ /** GraphEdge.weakest — the cheapest link to cut in a cycle (#166). */
158
+ weakest?: boolean;
159
+ /** GraphEdge.hidden — coupling no import explains (#169). */
160
+ hidden?: boolean;
161
+ /** `arcs` — which side of the axis the arc is drawn on (#169). */
162
+ side?: 'above' | 'below';
163
+ /**
164
+ * 0..1 against its OWN set's 95th-percentile weight, clamped past it, when a layout normalises
165
+ * per set (`arcs`, #169): imports and shared commits are different units, and one outlying
166
+ * pair should not flatten the rest. Preferred over the global weight scale.
167
+ */
168
+ strength?: number;
169
+ /** GraphEdge.count — how many model edges a condensed edge stands for. */
170
+ count?: number;
171
+ /** How the edge sits against the layering (#167) — given by the host or derived by `layers`. */
172
+ conformance?: import('../types.js').Conformance;
129
173
  x1: number;
130
174
  y1: number;
131
175
  x2: number;
@@ -239,8 +283,47 @@ export type LayoutOptions = {
239
283
  * highlight it would silently re-root the diagram.
240
284
  */
241
285
  root?: string | null;
286
+ /**
287
+ * Which edges to draw. `layers`: `violations` keeps just the edges that climb (#167).
288
+ * `arcs`: `hidden` keeps just the hidden coupling (#169). On a real codebase the ordinary
289
+ * edges are the vast majority and drown the signal.
290
+ */
291
+ showEdges?: ShowEdges;
292
+ /** `layers` only — band names by layer index. Unnamed layers read "Layer N". */
293
+ layerLabels?: string[];
294
+ /** `polymetric` only — the measure box WIDTH encodes. A `measures` key, `weight` or `degree`. */
295
+ widthBy?: string;
296
+ /** `polymetric` only — the measure box HEIGHT encodes. */
297
+ heightBy?: string;
298
+ /** `polymetric` only — the measure box SHADE encodes. Omit for no colour channel. */
299
+ colorBy?: string;
300
+ /**
301
+ * `arcs` only — the relation drawn on the right of the axis (#169). Omitted, overlays go
302
+ * right and structural edges left: what the history reveals against what the code declares.
303
+ */
304
+ above?: string;
305
+ /**
306
+ * `world` / `sunburst` — the measure each box is SHADED by, a share in 0..1 (#164). A
307
+ * container takes the size-weighted mean of what it holds unless it declares its own.
308
+ */
309
+ shadeBy?: string;
310
+ };
311
+ /** Every edge, or only the ones a view is about. */
312
+ export type ShowEdges = 'all' | 'violations' | 'hidden';
313
+ /** The three encodings of the polymetric view (#168). */
314
+ export type PolymetricChannel = 'width' | 'height' | 'color';
315
+ /** Which measure a channel carries, and the value its full extent stands for. */
316
+ export type ChannelScale = {
317
+ measure: string;
318
+ cap: number;
242
319
  };
243
320
  export type LayoutResult = {
321
+ /** `polymetric` — what each channel encodes, for the legend. */
322
+ channels?: {
323
+ width: ChannelScale;
324
+ height: ChannelScale;
325
+ color?: ChannelScale;
326
+ };
244
327
  clusters: Cluster[];
245
328
  cards: Cards;
246
329
  edges: RoutedEdge[];
@@ -17,6 +17,7 @@
17
17
  * have to be able to read what you are about to click.
18
18
  */
19
19
  import { buildTree, findNode } from '../model/tree.js';
20
+ import { shadeFields, shares } from '../model/share.js';
20
21
  import { squarify } from './squarify.js';
21
22
  import { warnUnknownOptions } from './options.js';
22
23
  /** Canvas the layout lays out into. Fit-to-container scales it; the ratios are what matter. */
@@ -89,7 +90,7 @@ function compact(value) {
89
90
  * A childless box keeps its node's id and kind, so uniform structure costs no identity: the
90
91
  * box is still selectable and still colours by what it is.
91
92
  */
92
- function clusterOf(entry, groupIndex) {
93
+ function clusterOf(entry, groupIndex, shaded) {
93
94
  const leaf = entry.tree.children.length === 0 ? entry.tree.node : undefined;
94
95
  return {
95
96
  name: entry.tree.label,
@@ -101,13 +102,17 @@ function clusterOf(entry, groupIndex) {
101
102
  ramp: entry.ramp,
102
103
  nodeId: leaf?.id,
103
104
  kind: leaf?.kind,
105
+ path: entry.tree.address,
106
+ leaf: entry.tree.children.length === 0,
107
+ declared: entry.tree.node?.id,
104
108
  list: entry.tree.children.map((c) => c.node).filter((n) => n !== undefined),
105
109
  count: entry.tree.children.length,
106
110
  groupIndex,
107
111
  x: entry.rect.x,
108
112
  y: entry.rect.y,
109
113
  w: entry.rect.w,
110
- h: entry.rect.h
114
+ h: entry.rect.h,
115
+ ...shadeFields(shaded, entry.tree.id)
111
116
  };
112
117
  }
113
118
  /**
@@ -119,11 +124,13 @@ function clusterOf(entry, groupIndex) {
119
124
  */
120
125
  export const world = (model, options) => {
121
126
  warnUnknownOptions(options, 'world');
122
- const tree = buildTree(model, { measure: options.sizeBy ?? 'weight' });
127
+ const tree = buildTree(model, { measure: options.sizeBy ?? 'weight', keep: options.focusPath });
123
128
  const root = findNode(tree, options.focusPath ?? []);
124
129
  if (!root || root.children.length === 0) {
125
130
  return { clusters: [], cards: {}, edges: [], size: { w: 0, h: 0 } };
126
131
  }
132
+ // #164: a share per box when a measure is named for it; the layout's geometry is unchanged.
133
+ const shaded = options.shadeBy ? shares(root, options.shadeBy) : undefined;
127
134
  const placed = [];
128
135
  place(root, { x: 0, y: 0, w: CANVAS.w, h: CANVAS.h }, {
129
136
  levels: options.levels ?? 2,
@@ -136,7 +143,7 @@ export const world = (model, options) => {
136
143
  // `cards` is empty by construction rather than by filtering — there is no box in this
137
144
  // layout that a card could represent.
138
145
  return {
139
- clusters: placed.map(clusterOf),
146
+ clusters: placed.map((entry, i) => clusterOf(entry, i, shaded)),
140
147
  cards: {},
141
148
  edges: [],
142
149
  size: CANVAS
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Graph's words live in `messages.graph` (`@rokkit/states`), so a locale can replace any of
3
+ * them. Templates name their values in braces — `Open {name}` — and `fill` puts them in.
4
+ */
5
+ import { messages } from '@rokkit/states';
6
+ /** The graph namespace of the active locale. Reactive: read it where the text is shown. */
7
+ export type GraphMessages = typeof messages.graph;
8
+ export type GraphMessageKey = keyof GraphMessages;
9
+ /** `template` with each `{token}` replaced; a token with no value stays as written. */
10
+ export declare function fill(template: string, values: Record<string, string | number>): string;
11
+ /** `{n} node` or `{n} nodes`, from the active locale. */
12
+ export declare function counted(n: number, one: GraphMessageKey, many: GraphMessageKey): string;
13
+ /** One graph string, filled. */
14
+ export declare function say(key: GraphMessageKey, values?: Record<string, string | number>): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Graph's words live in `messages.graph` (`@rokkit/states`), so a locale can replace any of
3
+ * them. Templates name their values in braces — `Open {name}` — and `fill` puts them in.
4
+ */
5
+ import { messages } from '@rokkit/states';
6
+ /** `template` with each `{token}` replaced; a token with no value stays as written. */
7
+ export function fill(template, values) {
8
+ return template.replace(/\{(\w+)\}/g, (whole, token) => token in values ? String(values[token]) : whole);
9
+ }
10
+ /** `{n} node` or `{n} nodes`, from the active locale. */
11
+ export function counted(n, one, many) {
12
+ return fill(messages.graph[n === 1 ? one : many], { n });
13
+ }
14
+ /** One graph string, filled. */
15
+ export function say(key, values = {}) {
16
+ return fill(messages.graph[key], values);
17
+ }
@@ -0,0 +1,8 @@
1
+ import type { GraphModel } from '../types.js';
2
+ /** Every group node, mapped to its members that exist in the model, in the order listed. */
3
+ export declare function groupsOf(model: GraphModel): Map<string, string[]>;
4
+ /**
5
+ * The model with each group in `collapsed` standing in for its members, and every other group
6
+ * expanded into them. The model itself when it has no groups.
7
+ */
8
+ export declare function condense(model: GraphModel, collapsed: Set<string>): GraphModel;
@@ -0,0 +1,114 @@
1
+ import { buildNeighbors } from './normalize.js';
2
+ /** Every group node, mapped to its members that exist in the model, in the order listed. */
3
+ export function groupsOf(model) {
4
+ const groups = new Map();
5
+ for (const node of model.nodes) {
6
+ if (!node.members || node.members.length === 0)
7
+ continue;
8
+ groups.set(node.id, node.members.filter((id) => model.byId.has(id)));
9
+ }
10
+ return groups;
11
+ }
12
+ /** A collapsed group takes its members' place. */
13
+ function collapseInto(group, members, { into, hidden }) {
14
+ for (const member of members) {
15
+ // A member listed in two groups belongs to the first; nesting is not modelled.
16
+ if (!into.has(member))
17
+ into.set(member, group);
18
+ hidden.add(member);
19
+ }
20
+ }
21
+ function routingFor(groups, collapsed) {
22
+ const routing = { into: new Map(), hidden: new Set() };
23
+ for (const [group, members] of groups) {
24
+ if (collapsed.has(group))
25
+ collapseInto(group, members, routing);
26
+ // Expanded, and its members are here: the group gives way to them. With no member in
27
+ // the model yet it stays — there is nothing to show in its place.
28
+ else if (members.length > 0)
29
+ routing.hidden.add(group);
30
+ }
31
+ return routing;
32
+ }
33
+ /**
34
+ * An edge as the condensed view draws it, or null when it has no place there: inside a
35
+ * collapsed group, or touching something hidden (a pre-aggregated edge to a group that is now
36
+ * expanded into its members).
37
+ */
38
+ /** Where an endpoint is drawn: its collapsed group, or itself. */
39
+ const shownAs = (id, into) => into.get(id) ?? id;
40
+ function reroute(edge, { into, hidden }) {
41
+ const source = shownAs(edge.source, into);
42
+ const target = shownAs(edge.target, into);
43
+ if (hidden.has(source) || hidden.has(target))
44
+ return null;
45
+ const moved = source !== edge.source || target !== edge.target;
46
+ if (!moved)
47
+ return edge;
48
+ if (source === target)
49
+ return null;
50
+ // Row anchors named a member's rows; the group card has none.
51
+ const { sourceRow: _s, targetRow: _t, ...rest } = edge;
52
+ return { ...rest, source, target };
53
+ }
54
+ const keyOf = (e) => `${e.source}>${e.target}>${e.kind}>${e.relation ?? ''}`;
55
+ /** Summed weight, or undefined when neither side carries one. */
56
+ const sumWeights = (a, b) => a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
57
+ /** Two parallel edges as one: it stands for both, and is no single edge — so not the weakest. */
58
+ function merge(seen, edge, key) {
59
+ const { weakest: _w, ...rest } = seen;
60
+ const merged = { ...rest, id: `condensed:${key}`, count: (seen.count ?? 1) + 1 };
61
+ const weight = sumWeights(seen.weight, edge.weight);
62
+ if (weight !== undefined)
63
+ merged.weight = weight;
64
+ return merged;
65
+ }
66
+ /**
67
+ * Parallel edges that rerouting created, folded into one: weights summed (when any carries
68
+ * one), `count` = how many model edges it stands for. A folded edge is no longer any single
69
+ * edge, so it is not the weakest one either.
70
+ */
71
+ function fold(edges, original) {
72
+ const byKey = new Map();
73
+ edges.forEach((edge, i) => {
74
+ const key = keyOf(edge);
75
+ const seen = byKey.get(key);
76
+ if (seen)
77
+ byKey.set(key, merge(seen, edge, key));
78
+ // A rerouted edge is no longer the model edge it came from, so it takes a new id.
79
+ else
80
+ byKey.set(key, edge === original[i] ? edge : { ...edge, id: `condensed:${key}` });
81
+ });
82
+ return [...byKey.values()];
83
+ }
84
+ function condenseEdges(edges, routing) {
85
+ const kept = [];
86
+ const originals = [];
87
+ for (const edge of edges) {
88
+ const routed = reroute(edge, routing);
89
+ if (!routed)
90
+ continue;
91
+ kept.push(routed);
92
+ originals.push(edge);
93
+ }
94
+ return fold(kept, originals);
95
+ }
96
+ /**
97
+ * The model with each group in `collapsed` standing in for its members, and every other group
98
+ * expanded into them. The model itself when it has no groups.
99
+ */
100
+ export function condense(model, collapsed) {
101
+ const groups = groupsOf(model);
102
+ if (groups.size === 0)
103
+ return model;
104
+ const routing = routingFor(groups, collapsed);
105
+ const nodes = model.nodes.filter((node) => !routing.hidden.has(node.id));
106
+ const edges = condenseEdges(model.edges, routing);
107
+ return {
108
+ nodes,
109
+ edges,
110
+ overlays: condenseEdges(model.overlays, routing),
111
+ byId: new Map(nodes.map((node) => [node.id, node])),
112
+ neighbors: buildNeighbors(edges)
113
+ };
114
+ }
@@ -0,0 +1,12 @@
1
+ import type { GraphModel } from '../types.js';
2
+ export type EntityRow = {
3
+ id: string;
4
+ label: string;
5
+ group?: string;
6
+ kind?: string;
7
+ rowCount: number;
8
+ refCount: number;
9
+ note?: string;
10
+ };
11
+ /** One row per node for an entity list: its rows and the structural edges touching it. */
12
+ export declare function entityRows(model: GraphModel): EntityRow[];