@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,27 @@
1
+ /** Bottom-right extent of a set of boxes (a box with no size is a point), or {0,0}. */
2
+ function extentOf(boxes) {
3
+ let w = 0;
4
+ let h = 0;
5
+ for (const box of boxes) {
6
+ w = Math.max(w, box.x + (box.w ?? 0));
7
+ h = Math.max(h, box.y + (box.h ?? 0));
8
+ }
9
+ return { w, h };
10
+ }
11
+ /**
12
+ * The true content extent of a layout, as distinct from its `size`.
13
+ *
14
+ * `LayoutResult.size` adds a +60 margin on the right and bottom only, so fitting to it hugs the
15
+ * left/top edge and floats away from the right/bottom. Centring needs the real bounds: the
16
+ * clusters', or — for an ungrouped layout (neighborhood), which reports none — the cards', and
17
+ * the layout size only when both are empty.
18
+ */
19
+ export function contentExtent(clusters, cards, size) {
20
+ let { w, h } = extentOf(clusters);
21
+ if (!w || !h) {
22
+ const fromCards = extentOf(Object.values(cards));
23
+ w = Math.max(w, fromCards.w);
24
+ h = Math.max(h, fromCards.h);
25
+ }
26
+ return { w: w || size.w, h: h || size.h };
27
+ }
@@ -13,6 +13,7 @@
13
13
  * edge is still drawn straight over any box between its endpoints, because routing it around
14
14
  * needs dummy nodes on the intervening ranks and that is its own slice.
15
15
  */
16
+ import { carried } from './edges.js';
16
17
  import { buildCards } from './cards.js';
17
18
  import { rank } from './rank.js';
18
19
  import { order } from './order.js';
@@ -65,7 +66,8 @@ function identity(edge, i) {
65
66
  fromKey: edge.source,
66
67
  toKey: edge.target,
67
68
  kind: edge.kind,
68
- relation: edge.relation
69
+ relation: edge.relation,
70
+ ...carried(edge)
69
71
  };
70
72
  }
71
73
  /**
@@ -1,8 +1,11 @@
1
+ import { arcs } from './arcs.js';
1
2
  import { cluster } from './cluster.js';
2
3
  import { flow } from './flow.js';
4
+ import { layers } from './layers.js';
3
5
  import { neighborhood } from './neighborhood.js';
4
6
  import { world } from './world.js';
5
7
  import { points } from './points.js';
8
+ import { polymetric } from './polymetric.js';
6
9
  import { radial } from './radial.js';
7
10
  import { structure } from './structure.js';
8
11
  import { sunburst } from './sunburst.js';
@@ -10,6 +13,6 @@ import type { LayoutFn } from './types.js';
10
13
  /** Built-in layouts, addressable by name from `Graph`'s `layout` prop. */
11
14
  export declare const layouts: Record<string, LayoutFn>;
12
15
  export type LayoutName = keyof typeof layouts;
13
- export { cluster, flow, neighborhood, points, radial, structure, sunburst, world };
16
+ export { arcs, cluster, flow, layers, neighborhood, points, polymetric, radial, structure, sunburst, world };
14
17
  export { LAYOUT_OPTIONS, appliesTo } from './options.js';
15
18
  export * from './types.js';
@@ -1,13 +1,28 @@
1
+ import { arcs } from './arcs.js';
1
2
  import { cluster } from './cluster.js';
2
3
  import { flow } from './flow.js';
4
+ import { layers } from './layers.js';
3
5
  import { neighborhood } from './neighborhood.js';
4
6
  import { world } from './world.js';
5
7
  import { points } from './points.js';
8
+ import { polymetric } from './polymetric.js';
6
9
  import { radial } from './radial.js';
7
10
  import { structure } from './structure.js';
8
11
  import { sunburst } from './sunburst.js';
9
12
  /** Built-in layouts, addressable by name from `Graph`'s `layout` prop. */
10
- export const layouts = { cluster, flow, neighborhood, points, radial, structure, sunburst, world };
11
- export { cluster, flow, neighborhood, points, radial, structure, sunburst, world };
13
+ export const layouts = {
14
+ arcs,
15
+ cluster,
16
+ flow,
17
+ layers,
18
+ neighborhood,
19
+ points,
20
+ polymetric,
21
+ radial,
22
+ structure,
23
+ sunburst,
24
+ world
25
+ };
26
+ export { arcs, cluster, flow, layers, neighborhood, points, polymetric, radial, structure, sunburst, world };
12
27
  export { LAYOUT_OPTIONS, appliesTo } from './options.js';
13
28
  export * from './types.js';
@@ -0,0 +1,6 @@
1
+ import type { LayoutFn } from './types.js';
2
+ import type { Conformance } from '../types.js';
3
+ /** How an edge sits against the layering, from the two layers the host gave. */
4
+ export declare function deriveConformance(from?: number, to?: number): Conformance | undefined;
5
+ /** Host-assigned layers as horizontal bands, layer 0 at the top. */
6
+ export declare const layers: LayoutFn;
@@ -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
+ };
@@ -0,0 +1,50 @@
1
+ import type { NodeAxis } from './types.js';
2
+ import type { GraphModel } from '../types.js';
3
+ export type MatrixCell = {
4
+ /** Index into `order` of the dependent (the edge's source). */
5
+ row: number;
6
+ /** Index into `order` of the dependency (the edge's target). */
7
+ col: number;
8
+ source: string;
9
+ target: string;
10
+ /** Edges folded into this cell — two FKs, a read and a write — each counted by its weight. */
11
+ count: number;
12
+ /** Against the grain: a cycle or a layer reaching up. */
13
+ above: boolean;
14
+ };
15
+ export type MatrixBlock = {
16
+ name: string;
17
+ start: number;
18
+ end: number;
19
+ };
20
+ export type Matrix = {
21
+ /** Node ids, providers first. Row i and column i are the same node. */
22
+ order: string[];
23
+ labels: string[];
24
+ /** Each node's group key on the grouping axis, parallel to `order` ('' when ungrouped). */
25
+ groups: string[];
26
+ cells: MatrixCell[];
27
+ /** Contiguous group runs along the diagonal. Empty when not grouping. */
28
+ blocks: MatrixBlock[];
29
+ /** Cells above the diagonal. */
30
+ above: number;
31
+ /** The largest `count`, for scaling cell intensity. */
32
+ maxCount: number;
33
+ };
34
+ export type MatrixOptions = {
35
+ /** Keep each group contiguous and outline it on the diagonal. Omit for one flat order. */
36
+ groupBy?: NodeAxis;
37
+ };
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;
@@ -0,0 +1,110 @@
1
+ /* The dependency structure matrix (DSM): every node is both a row and a column, and a cell at
2
+ * (row, col) says "row depends on col".
3
+ *
4
+ * Its strength is the ORDER. Providers come first, so in a layered architecture every
5
+ * dependency points up-and-left and the whole matrix is lower-triangular. A mark ABOVE the
6
+ * diagonal is then a dependency against the grain — a cycle, or a layer that reaches up —
7
+ * and it is visible at a glance at sizes where a node-link drawing is a hairball.
8
+ *
9
+ * The order reuses `flow`'s ranking (longest path, DFS cycle breaking), reversed: `rank` puts
10
+ * a target in a later column than its source, and a DSM wants the target FIRST. Grouping keeps
11
+ * each group contiguous, ordered by where its first member ranks, so a module's block sits on
12
+ * the diagonal and a cross-module dependency against the grain lands above it.
13
+ *
14
+ * Pure and DOM-free, like every layout.
15
+ */
16
+ import { counted, say } from '../messages.js';
17
+ import { rank } from './rank.js';
18
+ const axisOf = (node, axis) => (axis === 'kind' ? node.kind : node.group) ?? '';
19
+ /** Node ids, providers first; with `groupBy`, each group contiguous in first-ranked order. */
20
+ function orderNodes(model, groupBy) {
21
+ const { ranks } = rank(model);
22
+ // `rank` places every node, so each lookup hits; carrying the model index alongside the
23
+ // rank keeps ties in model order without a second map.
24
+ const byRank = model.nodes
25
+ .map((node, i) => ({ node, i, r: ranks.get(node.id) }))
26
+ .sort((a, b) => b.r - a.r || a.i - b.i)
27
+ .map((entry) => entry.node);
28
+ if (!groupBy)
29
+ return byRank;
30
+ const runs = new Map();
31
+ for (const node of byRank) {
32
+ const key = axisOf(node, groupBy);
33
+ const run = runs.get(key) ?? [];
34
+ run.push(node);
35
+ runs.set(key, run);
36
+ }
37
+ return [...runs.values()].flat();
38
+ }
39
+ function blocksOf(groups) {
40
+ const blocks = [];
41
+ groups.forEach((name, i) => {
42
+ const last = blocks[blocks.length - 1];
43
+ if (last && last.name === name)
44
+ last.end = i;
45
+ else
46
+ blocks.push({ name, start: i, end: i });
47
+ });
48
+ return blocks;
49
+ }
50
+ /** One cell per (dependent, dependency) pair, parallel edges folded into its count. */
51
+ function collectCells(model, index) {
52
+ const cells = new Map();
53
+ for (const edge of model.edges) {
54
+ const row = index.get(edge.source);
55
+ const col = index.get(edge.target);
56
+ if (row === undefined || col === undefined || row === col)
57
+ continue;
58
+ const key = `${row}:${col}`;
59
+ // A weighted edge already IS a count — an import list aggregated to components says
60
+ // "7 imports" once rather than repeating the edge seven times.
61
+ const n = edge.weight ?? 1;
62
+ const cell = cells.get(key);
63
+ if (cell)
64
+ cell.count += n;
65
+ else
66
+ cells.set(key, {
67
+ row,
68
+ col,
69
+ source: edge.source,
70
+ target: edge.target,
71
+ count: n,
72
+ above: col > row
73
+ });
74
+ }
75
+ return [...cells.values()].sort((a, b) => a.row - b.row || a.col - b.col);
76
+ }
77
+ export function buildMatrix(model, options = {}) {
78
+ const nodes = orderNodes(model, options.groupBy);
79
+ const index = new Map(nodes.map((n, i) => [n.id, i]));
80
+ const cells = collectCells(model, index);
81
+ const groups = nodes.map((n) => (options.groupBy ? axisOf(n, options.groupBy) : ''));
82
+ return {
83
+ order: nodes.map((n) => n.id),
84
+ labels: nodes.map((n) => n.label),
85
+ groups,
86
+ cells,
87
+ blocks: options.groupBy ? blocksOf(groups) : [],
88
+ above: cells.filter((c) => c.above).length,
89
+ maxCount: Math.max(0, ...cells.map((c) => c.count))
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;