@graview/primitives 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/LICENSE +96 -0
  2. package/README.md +25 -0
  3. package/dist/arrange-bar.d.ts +49 -0
  4. package/dist/arrange-bar.d.ts.map +1 -0
  5. package/dist/arrange-bar.js +264 -0
  6. package/dist/arrange-bar.js.map +1 -0
  7. package/dist/chat.d.ts +46 -0
  8. package/dist/chat.d.ts.map +1 -0
  9. package/dist/chat.js +343 -0
  10. package/dist/chat.js.map +1 -0
  11. package/dist/companion.d.ts +58 -0
  12. package/dist/companion.d.ts.map +1 -0
  13. package/dist/companion.js +433 -0
  14. package/dist/companion.js.map +1 -0
  15. package/dist/connections.d.ts +23 -0
  16. package/dist/connections.d.ts.map +1 -0
  17. package/dist/connections.js +100 -0
  18. package/dist/connections.js.map +1 -0
  19. package/dist/default-views.d.ts +35 -0
  20. package/dist/default-views.d.ts.map +1 -0
  21. package/dist/default-views.js +605 -0
  22. package/dist/default-views.js.map +1 -0
  23. package/dist/editable.d.ts +71 -0
  24. package/dist/editable.d.ts.map +1 -0
  25. package/dist/editable.js +225 -0
  26. package/dist/editable.js.map +1 -0
  27. package/dist/figure.d.ts +30 -0
  28. package/dist/figure.d.ts.map +1 -0
  29. package/dist/figure.js +66 -0
  30. package/dist/figure.js.map +1 -0
  31. package/dist/find.d.ts +5 -0
  32. package/dist/find.d.ts.map +1 -0
  33. package/dist/find.js +252 -0
  34. package/dist/find.js.map +1 -0
  35. package/dist/index.d.ts +51 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +44 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/installation.d.ts +17 -0
  40. package/dist/installation.d.ts.map +1 -0
  41. package/dist/installation.js +37 -0
  42. package/dist/installation.js.map +1 -0
  43. package/dist/ladder.d.ts +2 -0
  44. package/dist/ladder.d.ts.map +1 -0
  45. package/dist/ladder.js +53 -0
  46. package/dist/ladder.js.map +1 -0
  47. package/dist/lens/arranging.d.ts +41 -0
  48. package/dist/lens/arranging.d.ts.map +1 -0
  49. package/dist/lens/arranging.js +38 -0
  50. package/dist/lens/arranging.js.map +1 -0
  51. package/dist/lens/board.d.ts +146 -0
  52. package/dist/lens/board.d.ts.map +1 -0
  53. package/dist/lens/board.js +658 -0
  54. package/dist/lens/board.js.map +1 -0
  55. package/dist/lens/calendar-dates.d.ts +19 -0
  56. package/dist/lens/calendar-dates.d.ts.map +1 -0
  57. package/dist/lens/calendar-dates.js +55 -0
  58. package/dist/lens/calendar-dates.js.map +1 -0
  59. package/dist/lens/calendar-drawing.d.ts +59 -0
  60. package/dist/lens/calendar-drawing.d.ts.map +1 -0
  61. package/dist/lens/calendar-drawing.js +229 -0
  62. package/dist/lens/calendar-drawing.js.map +1 -0
  63. package/dist/lens/calendar-options.d.ts +107 -0
  64. package/dist/lens/calendar-options.d.ts.map +1 -0
  65. package/dist/lens/calendar-options.js +19 -0
  66. package/dist/lens/calendar-options.js.map +1 -0
  67. package/dist/lens/calendar-placing.d.ts +35 -0
  68. package/dist/lens/calendar-placing.d.ts.map +1 -0
  69. package/dist/lens/calendar-placing.js +113 -0
  70. package/dist/lens/calendar-placing.js.map +1 -0
  71. package/dist/lens/calendar-spans.d.ts +48 -0
  72. package/dist/lens/calendar-spans.d.ts.map +1 -0
  73. package/dist/lens/calendar-spans.js +146 -0
  74. package/dist/lens/calendar-spans.js.map +1 -0
  75. package/dist/lens/calendar-view.d.ts +34 -0
  76. package/dist/lens/calendar-view.d.ts.map +1 -0
  77. package/dist/lens/calendar-view.js +228 -0
  78. package/dist/lens/calendar-view.js.map +1 -0
  79. package/dist/lens/calendar.d.ts +43 -0
  80. package/dist/lens/calendar.d.ts.map +1 -0
  81. package/dist/lens/calendar.js +43 -0
  82. package/dist/lens/calendar.js.map +1 -0
  83. package/dist/lens/coverage.d.ts +183 -0
  84. package/dist/lens/coverage.d.ts.map +1 -0
  85. package/dist/lens/coverage.js +732 -0
  86. package/dist/lens/coverage.js.map +1 -0
  87. package/dist/lens/horizon.d.ts +14 -0
  88. package/dist/lens/horizon.d.ts.map +1 -0
  89. package/dist/lens/horizon.js +20 -0
  90. package/dist/lens/horizon.js.map +1 -0
  91. package/dist/lens/more.d.ts +16 -0
  92. package/dist/lens/more.d.ts.map +1 -0
  93. package/dist/lens/more.js +17 -0
  94. package/dist/lens/more.js.map +1 -0
  95. package/dist/lens/plan-lens.d.ts +17 -0
  96. package/dist/lens/plan-lens.d.ts.map +1 -0
  97. package/dist/lens/plan-lens.js +24 -0
  98. package/dist/lens/plan-lens.js.map +1 -0
  99. package/dist/lens/plan-state.d.ts +122 -0
  100. package/dist/lens/plan-state.d.ts.map +1 -0
  101. package/dist/lens/plan-state.js +152 -0
  102. package/dist/lens/plan-state.js.map +1 -0
  103. package/dist/lens/plan-view.d.ts +10 -0
  104. package/dist/lens/plan-view.d.ts.map +1 -0
  105. package/dist/lens/plan-view.js +576 -0
  106. package/dist/lens/plan-view.js.map +1 -0
  107. package/dist/lens/plan.d.ts +25 -0
  108. package/dist/lens/plan.d.ts.map +1 -0
  109. package/dist/lens/plan.js +25 -0
  110. package/dist/lens/plan.js.map +1 -0
  111. package/dist/lens/reach.d.ts +47 -0
  112. package/dist/lens/reach.d.ts.map +1 -0
  113. package/dist/lens/reach.js +67 -0
  114. package/dist/lens/reach.js.map +1 -0
  115. package/dist/lens/timeline.d.ts +137 -0
  116. package/dist/lens/timeline.d.ts.map +1 -0
  117. package/dist/lens/timeline.js +426 -0
  118. package/dist/lens/timeline.js.map +1 -0
  119. package/dist/markup.d.ts +23 -0
  120. package/dist/markup.d.ts.map +1 -0
  121. package/dist/markup.js +24 -0
  122. package/dist/markup.js.map +1 -0
  123. package/dist/places.d.ts +24 -0
  124. package/dist/places.d.ts.map +1 -0
  125. package/dist/places.js +227 -0
  126. package/dist/places.js.map +1 -0
  127. package/dist/popover.d.ts +28 -0
  128. package/dist/popover.d.ts.map +1 -0
  129. package/dist/popover.js +59 -0
  130. package/dist/popover.js.map +1 -0
  131. package/dist/primitives/index.d.ts +214 -0
  132. package/dist/primitives/index.d.ts.map +1 -0
  133. package/dist/primitives/index.js +381 -0
  134. package/dist/primitives/index.js.map +1 -0
  135. package/dist/profile.d.ts +65 -0
  136. package/dist/profile.d.ts.map +1 -0
  137. package/dist/profile.js +233 -0
  138. package/dist/profile.js.map +1 -0
  139. package/dist/quick-relations.d.ts +16 -0
  140. package/dist/quick-relations.d.ts.map +1 -0
  141. package/dist/quick-relations.js +183 -0
  142. package/dist/quick-relations.js.map +1 -0
  143. package/dist/relation-key.d.ts +44 -0
  144. package/dist/relation-key.d.ts.map +1 -0
  145. package/dist/relation-key.js +196 -0
  146. package/dist/relation-key.js.map +1 -0
  147. package/dist/seat.d.ts +137 -0
  148. package/dist/seat.d.ts.map +1 -0
  149. package/dist/seat.js +162 -0
  150. package/dist/seat.js.map +1 -0
  151. package/dist/seats.d.ts +21 -0
  152. package/dist/seats.d.ts.map +1 -0
  153. package/dist/seats.js +62 -0
  154. package/dist/seats.js.map +1 -0
  155. package/dist/seeding.d.ts +150 -0
  156. package/dist/seeding.d.ts.map +1 -0
  157. package/dist/seeding.js +538 -0
  158. package/dist/seeding.js.map +1 -0
  159. package/dist/shell.d.ts +74 -0
  160. package/dist/shell.d.ts.map +1 -0
  161. package/dist/shell.js +86 -0
  162. package/dist/shell.js.map +1 -0
  163. package/dist/theme.d.ts +50 -0
  164. package/dist/theme.d.ts.map +1 -0
  165. package/dist/theme.js +1692 -0
  166. package/dist/theme.js.map +1 -0
  167. package/dist/wordmark.d.ts +15 -0
  168. package/dist/wordmark.d.ts.map +1 -0
  169. package/dist/wordmark.js +55 -0
  170. package/dist/wordmark.js.map +1 -0
  171. package/dist/workbench/activity.d.ts +75 -0
  172. package/dist/workbench/activity.d.ts.map +1 -0
  173. package/dist/workbench/activity.js +259 -0
  174. package/dist/workbench/activity.js.map +1 -0
  175. package/dist/workbench/agent-seat.d.ts +51 -0
  176. package/dist/workbench/agent-seat.d.ts.map +1 -0
  177. package/dist/workbench/agent-seat.js +112 -0
  178. package/dist/workbench/agent-seat.js.map +1 -0
  179. package/dist/workbench/answer-args.d.ts +31 -0
  180. package/dist/workbench/answer-args.d.ts.map +1 -0
  181. package/dist/workbench/answer-args.js +257 -0
  182. package/dist/workbench/answer-args.js.map +1 -0
  183. package/dist/workbench/back-out.d.ts +71 -0
  184. package/dist/workbench/back-out.d.ts.map +1 -0
  185. package/dist/workbench/back-out.js +238 -0
  186. package/dist/workbench/back-out.js.map +1 -0
  187. package/dist/workbench/following.d.ts +7 -0
  188. package/dist/workbench/following.d.ts.map +1 -0
  189. package/dist/workbench/following.js +26 -0
  190. package/dist/workbench/following.js.map +1 -0
  191. package/dist/workbench/index.d.ts +23 -0
  192. package/dist/workbench/index.d.ts.map +1 -0
  193. package/dist/workbench/index.js +23 -0
  194. package/dist/workbench/index.js.map +1 -0
  195. package/dist/workbench/inspector.d.ts +27 -0
  196. package/dist/workbench/inspector.d.ts.map +1 -0
  197. package/dist/workbench/inspector.js +745 -0
  198. package/dist/workbench/inspector.js.map +1 -0
  199. package/dist/workbench/standing.d.ts +16 -0
  200. package/dist/workbench/standing.d.ts.map +1 -0
  201. package/dist/workbench/standing.js +102 -0
  202. package/dist/workbench/standing.js.map +1 -0
  203. package/dist/workbench/trail.d.ts +35 -0
  204. package/dist/workbench/trail.d.ts.map +1 -0
  205. package/dist/workbench/trail.js +150 -0
  206. package/dist/workbench/trail.js.map +1 -0
  207. package/package.json +62 -0
@@ -0,0 +1,732 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { labelOf } from "@graview/core";
3
+ import { useGraview } from "@graview/react";
4
+ import { onTheHorizon } from "./horizon.js";
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { hueFor } from "../default-views.js";
7
+ import { Chip, Panel, Roster, useWidth } from "../primitives/index.js";
8
+ /**
9
+ * The coverage lens: does everything on one side have something on the other?
10
+ *
11
+ * The timeline answers "when". This answers "did we miss anything", which is
12
+ * a different question with a different natural picture — a bipartite mapping
13
+ * rather than an interval. Requirements against deliverables, tests against
14
+ * behaviours, controls against risks, skills against drills: the shape recurs
15
+ * everywhere two sets of things are supposed to correspond, and the failure
16
+ * is always the same two pictures.
17
+ *
18
+ * **An empty row** is something that was asked for and answered by nothing.
19
+ * **An empty column** is something you are doing that nobody asked for.
20
+ *
21
+ * Both are absences of a RELATIONSHIP, which is exactly what prose review is
22
+ * worst at and a graph is best at: reading a document top to bottom will not
23
+ * reveal the paragraph that was never written. Reading down a column of
24
+ * empty cells takes a second.
25
+ *
26
+ * Built from the same public primitives an app has, like the timeline, so it
27
+ * stays a worked example of the authoring API rather than a privileged
28
+ * insider.
29
+ */
30
+ export const COVERAGE_REQUIRED_ROLES = ["rows", "columns", "link"];
31
+ export class CoverageBindingError extends Error {
32
+ hint;
33
+ constructor(message, hint) {
34
+ super(`${message}\n ${hint}`);
35
+ this.hint = hint;
36
+ this.name = "CoverageBindingError";
37
+ }
38
+ }
39
+ const text = (node, field) => field && node[field] !== undefined ? String(node[field]) : "";
40
+ /**
41
+ * Reads the grid out of the graph, in the app's own field names.
42
+ *
43
+ * Pure and exported, so an app can assert on the same numbers the picture is
44
+ * drawn from rather than on the rendering of them.
45
+ */
46
+ /**
47
+ * A MATRIX THE ROOM CAN HOLD, and the DOM can.
48
+ *
49
+ * Every row times every column is a cell, and a real graph is not a
50
+ * fixture: a discography's "who worked with whom" is 568 artists by 568,
51
+ * a third of a million cells, and the page never came back from drawing
52
+ * them. Past the limit the picture keeps the rows and columns with the
53
+ * most ties — in their own order — and says how many it left out. What is
54
+ * missing (`gaps`, `unasked`) is still counted over all of it.
55
+ *
56
+ * Forty by twenty-four: at eighty by forty-eight, "who worked with whom"
57
+ * drew 3,840 cells — twelve thousand elements and a quarter-second task —
58
+ * and every row repeated the same forty-eight names past the point anyone
59
+ * reads them. The search and the row's "only…" reach the rest.
60
+ */
61
+ export const COVERAGE_MAX_ROWS = 40;
62
+ export const COVERAGE_MAX_COLUMNS = 24;
63
+ export function capCoverage(grid, limits = {}) {
64
+ const maxRows = limits.rows ?? COVERAGE_MAX_ROWS;
65
+ const maxColumns = limits.columns ?? COVERAGE_MAX_COLUMNS;
66
+ if (grid.rows.length <= maxRows && grid.columns.length <= maxColumns)
67
+ return grid;
68
+ const ties = new Map();
69
+ for (const cell of grid.cells) {
70
+ ties.set(cell.rowId, (ties.get(cell.rowId) ?? 0) + 1);
71
+ ties.set(`col:${cell.columnId}`, (ties.get(`col:${cell.columnId}`) ?? 0) + 1);
72
+ }
73
+ // The most-tied, kept in the order the grid already drew them.
74
+ const keep = (items, max, key) => {
75
+ if (items.length <= max)
76
+ return items;
77
+ const chosen = new Set(items
78
+ .map((item, index) => ({ item, index, count: ties.get(key(item)) ?? 0 }))
79
+ .sort((a, b) => b.count - a.count || a.index - b.index)
80
+ .slice(0, max)
81
+ .map((entry) => entry.item.id));
82
+ return items.filter((item) => chosen.has(item.id));
83
+ };
84
+ const rows = keep(grid.rows, maxRows, (row) => row.id);
85
+ const columns = keep(grid.columns, maxColumns, (column) => `col:${column.id}`);
86
+ const shownRows = new Set(rows.map((row) => row.id));
87
+ const shownColumns = new Set(columns.map((column) => column.id));
88
+ return {
89
+ ...grid,
90
+ rows,
91
+ columns,
92
+ cells: grid.cells.filter((cell) => shownRows.has(cell.rowId) && shownColumns.has(cell.columnId)),
93
+ of: { rows: grid.rows.length, columns: grid.columns.length },
94
+ };
95
+ }
96
+ export function buildCoverage(nodes, edges, options, schema) {
97
+ const asRecord = (node) => node;
98
+ const label = (node) => schema ? labelOf(schema.tryDefinition(node.kind), node) : String(asRecord(node)["label"] ?? node.id);
99
+ /*
100
+ * AN EMPTY GRAPH IS NOT A MISBINDING.
101
+ *
102
+ * This used to throw whenever there were no rows and no columns, on the
103
+ * reasoning that the binding was probably wrong — which is true of a kind
104
+ * nobody declared and false of a blank app, where every kind is empty and
105
+ * the lens's title is in the bar from the first paint. Pressing it there
106
+ * took the whole scene down.
107
+ *
108
+ * The declaration answers the question the node count was guessing at: a
109
+ * role naming a kind that does not exist is a binding error whatever is in
110
+ * the graph, and a declared kind with nothing in it is an empty picture.
111
+ */
112
+ const undeclared = schema
113
+ ? [options.rows, options.columns].filter((kind) => schema.tryDefinition(kind) === undefined)
114
+ : [];
115
+ if (undeclared.length > 0) {
116
+ throw new CoverageBindingError(`No kind is declared for ${undeclared.map((kind) => `"${kind}"`).join(" or ")}.`, `Check the lens bindings: { rows: "<kind>", columns: "<kind>", link: "<edge kind>" }`);
117
+ }
118
+ const rowNodes = nodes.filter((node) => node.kind === options.rows);
119
+ const columnNodes = nodes.filter((node) => node.kind === options.columns);
120
+ if (!schema && rowNodes.length === 0 && columnNodes.length === 0) {
121
+ // Called headlessly with no schema to ask: the node count is the only
122
+ // evidence there is, and it is still better than an empty picture with
123
+ // no explanation.
124
+ throw new CoverageBindingError(`Nothing to lay out: no "${options.rows}" and no "${options.columns}" nodes.`, `Check the lens bindings: { rows: "<kind>", columns: "<kind>", link: "<edge kind>" }`);
125
+ }
126
+ const order = options.groupOrder ?? [];
127
+ const groupRank = (value) => {
128
+ const index = order.indexOf(value);
129
+ return index === -1 ? order.length : index;
130
+ };
131
+ const badgeEdges = options.badge
132
+ ? edges.filter((edge) => edge.kind === options.badge.edge)
133
+ : [];
134
+ const rowIds = new Set(rowNodes.map((node) => node.id));
135
+ const columnIds = new Set(columnNodes.map((node) => node.id));
136
+ const cells = [];
137
+ if (typeof options.link === "string") {
138
+ const link = options.link;
139
+ const linked = edges.filter((edge) => edge.kind === link);
140
+ /*
141
+ * WHICH WAY THE EDGE RUNS IS SOMETHING THE SCHEMA ALREADY SAYS.
142
+ *
143
+ * This assumed `edge.from` was the column and `edge.to` the row, and the
144
+ * comment beside it claimed anything else was "a binding mistake rather
145
+ * than an empty grid, and saying so beats drawing nothing" — which the
146
+ * code did not do: it dropped the edge in silence. Half of all domains
147
+ * declare the relation the other way round ("an item is kept by an
148
+ * owner" reads row → column; "a control mitigates a risk" reads column →
149
+ * row), and those got a grid of entirely empty cells, every row flagged,
150
+ * and the lens reporting that nothing was covered when everything was.
151
+ * `graview check` passed, because the binding was not wrong — the
152
+ * assumption was.
153
+ *
154
+ * Guessing per edge would be worse: a hand-built edge running the
155
+ * illegal way would then fill a cell and the picture would lie about who
156
+ * covers what. The declaration settles it without guessing — whichever
157
+ * kind declares `link`, and what it points at, IS the direction — and an
158
+ * edge that does not fit that orientation is still ignored.
159
+ */
160
+ const declares = (kind) => schema?.tryDefinition?.(kind)?.edges?.[link]?.to;
161
+ const columnPointsAtRow = (declares(options.columns) ?? []).some((target) => target === options.rows || target === "*");
162
+ const rowPointsAtColumn = (declares(options.rows) ?? []).some((target) => target === options.columns || target === "*");
163
+ // Undeclared either way: keep the reading this lens has always had.
164
+ const fromIsColumn = columnPointsAtRow || !rowPointsAtColumn;
165
+ for (const edge of linked) {
166
+ const rowId = fromIsColumn ? edge.to : edge.from;
167
+ const columnId = fromIsColumn ? edge.from : edge.to;
168
+ if (rowIds.has(rowId) && columnIds.has(columnId))
169
+ cells.push({ rowId, columnId });
170
+ }
171
+ }
172
+ else {
173
+ /*
174
+ * A RELATIONSHIP THAT RUNS THROUGH A NODE.
175
+ *
176
+ * The walk is named by edge kind, column end first, and each step
177
+ * follows an edge of that kind IN EITHER DIRECTION — an edge has two
178
+ * readings, and which one a domain happened to declare is not the
179
+ * picture's business. What comes back with the cell is the nodes passed
180
+ * through, because the routine in the middle is the thing worth pressing.
181
+ *
182
+ * A path is walked per column node rather than joined over the whole
183
+ * graph: it keeps the intermediates for THIS cell, and a domain with
184
+ * hundreds of routines still only ever walks the edges of one kind at a
185
+ * time.
186
+ */
187
+ /*
188
+ * Each step's edges as an adjacency, both readings, read once. Scanning
189
+ * every edge of the kind for every node on every column's frontier was
190
+ * 568 artists × a thousand songs × a thousand credits for "who worked
191
+ * with whom" — a seventh of a second before anything was drawn.
192
+ */
193
+ const onwardOf = new Map();
194
+ for (const step of new Set(options.link.path))
195
+ onwardOf.set(step, new Map());
196
+ for (const edge of edges) {
197
+ const adjacency = onwardOf.get(edge.kind);
198
+ if (!adjacency)
199
+ continue;
200
+ const out = adjacency.get(edge.from);
201
+ if (out)
202
+ out.push(edge.to);
203
+ else
204
+ adjacency.set(edge.from, [edge.to]);
205
+ const back = adjacency.get(edge.to);
206
+ if (back)
207
+ back.push(edge.from);
208
+ else
209
+ adjacency.set(edge.to, [edge.from]);
210
+ }
211
+ const seen = new Set();
212
+ for (const column of columnNodes) {
213
+ /* Each reachable node, with the trail that got there. */
214
+ let frontier = [{ id: column.id, via: [] }];
215
+ for (const step of options.link.path) {
216
+ const next = [];
217
+ const here = new Set();
218
+ const adjacency = onwardOf.get(step);
219
+ for (const at of frontier) {
220
+ for (const onward of adjacency.get(at.id) ?? []) {
221
+ if (here.has(`${at.id}|${onward}`))
222
+ continue;
223
+ here.add(`${at.id}|${onward}`);
224
+ next.push({ id: onward, via: [...at.via, at.id] });
225
+ }
226
+ }
227
+ frontier = next;
228
+ }
229
+ for (const reached of frontier) {
230
+ if (!rowIds.has(reached.id))
231
+ continue;
232
+ const key = `${reached.id}|${column.id}`;
233
+ if (seen.has(key))
234
+ continue;
235
+ seen.add(key);
236
+ /* The trail starts at the column itself; the hinges are what is after it. */
237
+ cells.push({ rowId: reached.id, columnId: column.id, via: reached.via.slice(1) });
238
+ }
239
+ }
240
+ }
241
+ const required = new Set(options.requiredGroups ?? []);
242
+ const demanded = options.requiredVia
243
+ ? new Set(edges
244
+ .filter((edge) => edge.kind === options.requiredVia.edge)
245
+ .map((edge) => edge.to))
246
+ : null;
247
+ // Sets, not a scan of every cell per row and per column.
248
+ const coveredRows = new Set(cells.map((cell) => cell.rowId));
249
+ const usedColumns = new Set(cells.map((cell) => cell.columnId));
250
+ const badged = new Set(badgeEdges.map((edge) => edge.to));
251
+ const rows = rowNodes
252
+ .map((node) => {
253
+ const record = asRecord(node);
254
+ const group = text(record, options.rowGroup);
255
+ return {
256
+ id: node.id,
257
+ ref: text(record, options.rowRef),
258
+ label: String(record["label"] ?? label(node)),
259
+ group,
260
+ required: demanded
261
+ ? demanded.has(node.id)
262
+ : required.size === 0 || required.has(group),
263
+ covered: coveredRows.has(node.id),
264
+ badged: badged.has(node.id),
265
+ };
266
+ })
267
+ .sort((a, b) => groupRank(a.group) - groupRank(b.group) || (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
268
+ const columns = columnNodes
269
+ .map((node) => ({
270
+ id: node.id,
271
+ label: label(node),
272
+ used: usedColumns.has(node.id),
273
+ group: text(asRecord(node), options.columnGroup),
274
+ }))
275
+ .sort((a, b) => (a.group < b.group ? -1 : a.group > b.group ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
276
+ .map(({ group: _group, ...column }) => column);
277
+ return {
278
+ rows,
279
+ columns,
280
+ cells,
281
+ gaps: rows.filter((row) => row.required && !row.covered).map((row) => row.id),
282
+ unasked: columns.filter((column) => !column.used).map((column) => column.id),
283
+ };
284
+ }
285
+ /*
286
+ * Wide enough for a requirement's own wording.
287
+ *
288
+ * At 250 the bid desk's rows read "Booking flow conforms to WCAG 2…" and
289
+ * "Testing with assistive technology u…" — six of eight rows cut, in a panel
290
+ * with three hundred spare pixels to its right. A matrix you cannot read the
291
+ * rows of is a matrix that answers nothing.
292
+ */
293
+ const ROW_LABEL_WIDTH = 316;
294
+ /**
295
+ * The header's geometry, DERIVED from the angle rather than guessed alongside
296
+ * it.
297
+ *
298
+ * A rotated label is a right triangle: a 138-pixel label at 58° rises 117 and
299
+ * runs 73. The height was 116 and the rise is 117, so every column header was
300
+ * clipped by exactly one pixel at the top — enough to shave the ascenders off
301
+ * a row of headings and make the whole strip look cut off, and invisible in
302
+ * any test that was not looking at it.
303
+ *
304
+ * Three constants that have to agree, written as one that cannot disagree.
305
+ */
306
+ const HEADER_ANGLE = 58;
307
+ /*
308
+ * And long enough for a column's own wording, for the same reason: "Design
309
+ * system contributions" and "Prescription journey remediation" were both
310
+ * ellipsised, which turns the header strip into a row of guesses.
311
+ */
312
+ const HEADER_MAX = 176;
313
+ const RISE = Math.sin((HEADER_ANGLE * Math.PI) / 180);
314
+ const RUN = Math.cos((HEADER_ANGLE * Math.PI) / 180);
315
+ /**
316
+ * The band is as tall as THIS matrix's longest column name needs, never
317
+ * taller.
318
+ *
319
+ * A cap sized for the longest label anyone might write is dead space for
320
+ * everyone who did not write it: the desk's columns are "The household example", "bid
321
+ * desk" and "The coaching example", and a band cut for "Prescription journey
322
+ * remediation" left a hundred empty pixels above them. Same geometry, asked
323
+ * of the labels that are actually there.
324
+ */
325
+ const headerHeightFor = (labels) => {
326
+ const longest = labels.reduce((most, label) => Math.max(most, label.length), 0);
327
+ // 5.4px per character at 10.5px, and never below a two-line-ish minimum so
328
+ // a matrix of one-word columns still has a header strip rather than a seam.
329
+ const run = Math.min(HEADER_MAX, Math.max(46, longest * 5.4 + 12));
330
+ return Math.ceil(run * RISE) + 8;
331
+ };
332
+ /**
333
+ * Room for the last column's header to ascend into.
334
+ *
335
+ * Rotated labels always run up and to the right of the column they name, so
336
+ * the right-most one overflows unless the grid reserves the space. Reserving
337
+ * it on the shared container keeps the header strip and the rows aligned;
338
+ * padding one and not the other is how a matrix ends up one column out.
339
+ */
340
+ const HEADER_OVERHANG = Math.ceil(HEADER_MAX * RUN);
341
+ /**
342
+ * The lens's aggregate view.
343
+ *
344
+ * Full fidelity draws the matrix. Summary drops it entirely for a coverage
345
+ * bar and the names of what is missing — fidelity SWITCHING rather than
346
+ * scaling, the same discipline the timeline keeps, because a matrix shrunk to
347
+ * a third of its size is a grey rectangle and a list of two names is not.
348
+ */
349
+ /*
350
+ * WHAT IS MISSING, IN THE DECLARATION'S WORDS. The header said "3
351
+ * unanswered · 2 unasked" in every domain — a tender's vocabulary, where a
352
+ * requirement is answered by a section. Over songs and themes it is "songs
353
+ * with no theme"; over artists and the artists they worked with, rows and
354
+ * columns of one kind, it says which way the gap runs.
355
+ */
356
+ const pluralWords = (schema, kind) => (schema.tryDefinition(kind)?.plural ?? `${kind}s`).toLowerCase();
357
+ const kindWords = (kind) => kind.replace(/-/g, " ");
358
+ export function gapWords(count, options, schema) {
359
+ if (options.rows === options.columns)
360
+ return `${count} with none across`;
361
+ const rows = count === 1 ? kindWords(options.rows) : pluralWords(schema, options.rows);
362
+ return `${count} ${rows} with no ${kindWords(options.columns)}`;
363
+ }
364
+ export function unaskedWords(count, options, schema) {
365
+ if (options.rows === options.columns)
366
+ return `${count} with none down`;
367
+ const columns = count === 1 ? kindWords(options.columns) : pluralWords(schema, options.columns);
368
+ return `${count} ${columns} on no ${kindWords(options.rows)}`;
369
+ }
370
+ export function CoverageView({ nodes, label, fidelity, mode, options, schema, implicated = [], flagged = [], budget, }) {
371
+ const { store } = useGraview();
372
+ const under = useColumnsUnderTheNames();
373
+ const box = useRef(null);
374
+ const width = useWidth(box);
375
+ /*
376
+ * The whole graph's rows and columns, not the aggregate's members.
377
+ *
378
+ * A grid over gardeners and plots registered on the gardeners drew rows
379
+ * with no columns, because the group it was handed held gardeners only;
380
+ * every app then wrote the same wrapper to fetch the other kind. The lens
381
+ * knows both kinds it is bound to, so it reads them itself. The aggregate
382
+ * still decides what is focused; it does not decide what can be looked up
383
+ * — but for its own kind it is what the horizon left (see horizon.ts).
384
+ */
385
+ const whole = buildCoverage(onTheHorizon(store.graph, store.schema, nodes)
386
+ .filter((node) => node.kind === options.rows || node.kind === options.columns), store.graph.allEdges(), options, schema);
387
+ // The glyph and the summary count; only the full picture draws cells, and only as many as it can.
388
+ // A budget (a thumbnail's) holds rows and columns to it; otherwise the page's own limits.
389
+ const grid = fidelity === "full" ? capCoverage(whole, budget !== undefined ? { rows: budget, columns: budget } : {}) : whole;
390
+ if (fidelity === "glyph") {
391
+ return (_jsx(Chip, { label: `${label ?? "Coverage"} · ${grid.gaps.length === 0 ? "complete" : `${grid.gaps.length} missing`}`, hue: hueFor("coverage") }));
392
+ }
393
+ if (fidelity === "summary") {
394
+ const missing = grid.rows.filter((row) => grid.gaps.includes(row.id));
395
+ return (_jsxs(Panel, { title: label ?? "Coverage", meta: `${grid.rows.length - grid.gaps.length}/${grid.rows.length}`, tone: grid.gaps.length > 0 ? "warning" : "muted", fit: true, children: [_jsx(CoverageBar, { grid: grid }), missing.length > 0 ? (_jsx(Roster, { pick: true, max: 4, items: missing.map((row) => ({ id: row.id, label: row.ref || row.label })) })) : null] }));
396
+ }
397
+ const lit = new Set(implicated);
398
+ const broken = new Set(flagged);
399
+ // Lifted out, the matrix is the PAGE — the same argument the board makes:
400
+ // a picture whose content is a two-dimensional arrangement is exactly the
401
+ // thing a card-sized box was cramping.
402
+ const page = mode === "fullscreen";
403
+ const headerHeight = headerHeightFor(grid.columns.map((column) => column.label));
404
+ /*
405
+ * THE SHAPE FOLLOWS THE ROOM. A 316px label column in a 300px card put
406
+ * every column past the edge, behind a sideways scroll nothing announced
407
+ * — a matrix with names and no cells. The names take a share of the
408
+ * width rather than a fixed run; and where the columns still would not
409
+ * fit at a fingertip each, the matrix stacks: each row is its name and
410
+ * then its cells as labelled marks that wrap, which is a different
411
+ * drawing of the same facts rather than a smaller one.
412
+ */
413
+ const labelWidth = width === null ? ROW_LABEL_WIDTH : Math.max(96, Math.min(ROW_LABEL_WIDTH, Math.round(width * 0.36)));
414
+ const stacked = width !== null && (width < 420 || labelWidth + grid.columns.length * 28 + HEADER_OVERHANG > width);
415
+ return (_jsx(Panel, { title: label ?? "Coverage", ...(page ? { style: { flex: "1 1 auto", minHeight: 0, height: "100%" } } : {}), meta: grid.gaps.length === 0 && grid.unasked.length === 0
416
+ ? "complete"
417
+ : [
418
+ grid.gaps.length > 0 ? gapWords(grid.gaps.length, options, store.schema) : "",
419
+ grid.unasked.length > 0 ? unaskedWords(grid.unasked.length, options, store.schema) : "",
420
+ ]
421
+ .filter(Boolean)
422
+ .join(" · "), selected: false, fit: true, children: _jsxs("div", { ref: box, "data-coverage-shape": stacked ? "stacked" : "matrix", style: {
423
+ display: "flex",
424
+ flexDirection: "column",
425
+ flex: "1 1 auto",
426
+ minHeight: 0,
427
+ paddingRight: stacked ? 0 : HEADER_OVERHANG,
428
+ }, children: [stacked ? null : (_jsxs("div", { style: {
429
+ display: "flex",
430
+ alignItems: "flex-end",
431
+ flex: "0 0 auto",
432
+ position: "sticky",
433
+ top: 0,
434
+ zIndex: 2,
435
+ background: "var(--graview-panel)",
436
+ /* The names lean up out of their own boxes; without this the
437
+ panel's edge takes the top off the longest of them. */
438
+ paddingTop: 6,
439
+ }, children: [_jsx("div", { ref: under.corner, style: {
440
+ width: labelWidth,
441
+ flex: "0 0 auto",
442
+ alignSelf: "stretch",
443
+ position: "sticky",
444
+ left: 0,
445
+ zIndex: 1,
446
+ background: "var(--graview-panel)",
447
+ } }), _jsx("div", { ref: under.heads, style: { display: "flex", flex: 1, minWidth: 0, height: headerHeight }, children: grid.columns.map((column) => (_jsx("div", { "data-graview-pick": column.id, "data-graview-emphasis": lit.size === 0 ? "plain" : lit.has(column.id) ? "lit" : "dimmed", title: column.label, "data-graview-column": column.id, style: {
448
+ flex: 1,
449
+ // A column is a target: a fingertip wide at the least, and the
450
+ // grid scrolls rather than crushing its columns to nothing.
451
+ minWidth: 28,
452
+ // The same box as the cells below it, border and all.
453
+ boxSizing: "border-box",
454
+ borderLeft: "1px solid transparent",
455
+ /*
456
+ * THE NAME IS THE TARGET, NOT THE BOX IT STANDS IN. The
457
+ * label leans up and to the right, across the boxes of
458
+ * the columns after it, and those were painted over it:
459
+ * a press on a name picked a neighbour more often than
460
+ * not. The box lets presses through; the label takes them.
461
+ */
462
+ pointerEvents: "none",
463
+ position: "relative",
464
+ display: "flex",
465
+ alignItems: "flex-end",
466
+ justifyContent: "center",
467
+ }, children: _jsxs("span", { style: {
468
+ position: "absolute",
469
+ // A little taller than the words, so the name is a target a pointer can find.
470
+ bottom: 3,
471
+ padding: "3px 0",
472
+ pointerEvents: "auto",
473
+ cursor: "pointer",
474
+ left: "50%",
475
+ /*
476
+ * A COLUMN SCROLLED UNDER THE NAMES TAKES ITS NAME WITH IT.
477
+ * Its cells go under the sticky names; its label did not,
478
+ * and leaning up to the right it hung over the columns
479
+ * still in view — so a grid scrolled four columns along
480
+ * read as every label four columns out.
481
+ */
482
+ visibility: under.hidden.has(column.id) ? "hidden" : "visible",
483
+ transformOrigin: "left bottom",
484
+ transform: `rotate(-${HEADER_ANGLE}deg)`,
485
+ whiteSpace: "nowrap",
486
+ maxWidth: HEADER_MAX,
487
+ overflow: "hidden",
488
+ textOverflow: "ellipsis",
489
+ fontSize: "0.75rem",
490
+ letterSpacing: "0.005em",
491
+ color: column.used
492
+ ? lit.size > 0 && !lit.has(column.id)
493
+ ? "var(--graview-ink-faint)"
494
+ : "var(--graview-ink)"
495
+ : "var(--graview-warn)",
496
+ }, children: [column.used ? "" : "⚠ ", column.label] }) }, column.id))) })] })), grid.of ? (_jsxs("p", { "data-testid": "coverage-more", style: { margin: "4px 0 6px", fontSize: "0.8125rem", color: "var(--graview-ink-muted)" }, children: [[
497
+ grid.of.rows > grid.rows.length ? `the ${grid.rows.length} most connected of ${grid.of.rows} ${pluralWords(store.schema, options.rows)}` : null,
498
+ grid.of.columns > grid.columns.length ? `${grid.columns.length} of ${grid.of.columns} ${pluralWords(store.schema, options.columns)} across` : null,
499
+ ]
500
+ .filter(Boolean)
501
+ .join(" · ")
502
+ .replace(/^./, (first) => first.toUpperCase()), " ", "\u2014 what is missing is still counted over all of them."] })) : null, _jsx("div", { children: grid.rows.map((row, index) => {
503
+ const previous = grid.rows[index - 1];
504
+ const startsGroup = previous === undefined || previous.group !== row.group;
505
+ const missing = row.required && !row.covered;
506
+ const dim = lit.size > 0 && !lit.has(row.id);
507
+ return (_jsxs("div", { children: [startsGroup && row.group ? (_jsx("div", { style: {
508
+ fontSize: "0.6875rem",
509
+ letterSpacing: "0.16em",
510
+ textTransform: "uppercase",
511
+ color: "var(--graview-ink-faint)",
512
+ padding: "9px 0 3px",
513
+ }, children: _jsx("span", { style: {
514
+ position: "sticky",
515
+ left: 0,
516
+ display: "inline-block",
517
+ paddingRight: 8,
518
+ background: "var(--graview-panel)",
519
+ }, children: row.group }) })) : null, _jsxs("div", { style: {
520
+ display: "flex",
521
+ flexDirection: stacked ? "column" : "row",
522
+ alignItems: "stretch",
523
+ borderTop: "1px solid var(--graview-edge)",
524
+ opacity: dim ? 0.72 : 1,
525
+ }, children: [_jsxs("div", { "data-graview-pick": row.id, "data-graview-emphasis": lit.size === 0 ? "plain" : dim ? "dimmed" : "lit", title: row.label, style: {
526
+ width: stacked ? "auto" : labelWidth,
527
+ flex: "0 0 auto",
528
+ /* And the row's name stays when the grid slides
529
+ sideways, for the same reason the column's does. */
530
+ position: stacked ? "static" : "sticky",
531
+ left: 0,
532
+ zIndex: 1,
533
+ background: "var(--graview-panel)",
534
+ display: "flex",
535
+ alignItems: "center",
536
+ gap: 7,
537
+ padding: "6px 10px 6px 0",
538
+ // Padding inside the width, so the cells start where the column heads do.
539
+ boxSizing: "border-box",
540
+ fontSize: "0.8125rem",
541
+ minWidth: 0,
542
+ color: missing || broken.has(row.id) ? "var(--graview-warn)" : "var(--graview-ink)",
543
+ }, children: [row.ref ? (_jsx("span", { style: {
544
+ flex: "0 0 auto",
545
+ fontVariantNumeric: "tabular-nums",
546
+ color: "var(--graview-ink-faint)",
547
+ }, children: row.ref })) : null, _jsx("span", { style: {
548
+ minWidth: 0,
549
+ overflow: "hidden",
550
+ textOverflow: "ellipsis",
551
+ whiteSpace: "nowrap",
552
+ }, children: row.label }), options.badge ? (_jsx("span", { title: row.badged ? options.badge.title : options.badge.missingTitle, style: {
553
+ marginLeft: "auto",
554
+ flex: "0 0 auto",
555
+ fontSize: "0.75rem",
556
+ color: row.badged
557
+ ? "var(--graview-ink-faint)"
558
+ : "var(--graview-warn)",
559
+ }, children: row.badged ? options.badge.symbol : "○" })) : null] }), _jsx("div", { style: {
560
+ display: "flex",
561
+ flex: 1,
562
+ minWidth: 0,
563
+ ...(stacked ? { flexWrap: "wrap", gap: 6, padding: "0 0 8px" } : {}),
564
+ }, children: grid.columns.map((column) => {
565
+ const filled = grid.cells.some((cell) => cell.rowId === row.id && cell.columnId === column.id);
566
+ return (_jsxs("div", { "data-graview-mark": "", ...(filled
567
+ ? {
568
+ "data-graview-pick": column.id,
569
+ /*
570
+ * A cell is an EDGE, lit when the selection
571
+ * reaches both its ends. Lit when it reached
572
+ * either, selecting one song lit the whole
573
+ * column of its theme — every other song
574
+ * about night, none of them its business.
575
+ */
576
+ "data-graview-emphasis": lit.size === 0
577
+ ? "plain"
578
+ : lit.has(column.id) && lit.has(row.id)
579
+ ? "lit"
580
+ : "dimmed",
581
+ }
582
+ : {}), title: filled
583
+ ? `${column.label} answers ${row.ref || row.label}`
584
+ : `${column.label} does not answer ${row.ref || row.label}`, style: stacked
585
+ ? {
586
+ // A labelled mark: the column's name beside its cell,
587
+ // since there is no header strip to read it off.
588
+ flex: "0 0 auto",
589
+ minHeight: 24,
590
+ boxSizing: "border-box",
591
+ display: "inline-flex",
592
+ alignItems: "center",
593
+ gap: 6,
594
+ padding: "2px 9px 2px 7px",
595
+ borderRadius: 999,
596
+ border: "1px solid var(--graview-edge)",
597
+ fontSize: "0.75rem",
598
+ color: filled ? "var(--graview-ink)" : "var(--graview-ink-faint)",
599
+ }
600
+ : {
601
+ flex: 1,
602
+ minWidth: 28,
603
+ boxSizing: "border-box",
604
+ display: "flex",
605
+ alignItems: "center",
606
+ justifyContent: "center",
607
+ borderLeft: "1px solid var(--graview-edge)",
608
+ }, children: [_jsx("span", { style: cellStyle(filled, missing, lit.has(column.id) && lit.has(row.id), lit.size > 0) }), stacked ? _jsx("span", { children: column.label }) : null] }, column.id));
609
+ }) })] })] }, row.id));
610
+ }) })] }) }));
611
+ }
612
+ /**
613
+ * A cell is a RELATIONSHIP, so it is drawn as a mark rather than a box: a
614
+ * filled grid of tinted rectangles reads as a heat map, which implies a
615
+ * magnitude that is not there. Presence and absence is the whole content.
616
+ */
617
+ function cellStyle(filled, rowIsMissing, related, anyRelated) {
618
+ if (!filled) {
619
+ return {
620
+ width: 5,
621
+ height: 5,
622
+ borderRadius: 999,
623
+ background: rowIsMissing ? "var(--graview-warn)" : "var(--graview-edge)",
624
+ opacity: rowIsMissing ? 0.5 : 1,
625
+ };
626
+ }
627
+ return {
628
+ width: 13,
629
+ height: 13,
630
+ borderRadius: 4,
631
+ background: related || !anyRelated ? "var(--graview-accent)" : "var(--graview-accent-dim)",
632
+ boxShadow: related && anyRelated ? "0 0 12px -2px var(--graview-accent)" : undefined,
633
+ };
634
+ }
635
+ /** How much of what had to be covered is covered. */
636
+ function CoverageBar({ grid }) {
637
+ const required = grid.rows.filter((row) => row.required);
638
+ const covered = required.filter((row) => row.covered).length;
639
+ const share = required.length === 0 ? 1 : covered / required.length;
640
+ return (_jsx("div", { role: "img", "aria-label": `${covered} of ${required.length} covered`, style: {
641
+ height: 6,
642
+ borderRadius: 999,
643
+ overflow: "hidden",
644
+ background: "var(--graview-edge)",
645
+ }, children: _jsx("div", { style: {
646
+ width: `${Math.round(share * 100)}%`,
647
+ height: "100%",
648
+ background: share === 1 ? "var(--graview-accent)" : "var(--graview-warn)",
649
+ } }) }));
650
+ }
651
+ export function createCoverageLens(options) {
652
+ /*
653
+ * A real COMPONENT, not a method that happens to call hooks.
654
+ *
655
+ * `View` is rendered as `<lens.View />`, so it is a component — but written
656
+ * as a method on an object literal it looked like one to the hooks lint rule
657
+ * and needed a disable in three files. A disable repeated three times is a
658
+ * rule telling you something, and what it was telling us is that this wanted
659
+ * to be a component.
660
+ */
661
+ function Bound(props) {
662
+ // The SCHEMA comes from the provider: `ViewProps` carries none, so a lens
663
+ // rendered through the registry ran without it and every schema-aware
664
+ // decision inside quietly took its fallback path.
665
+ const { store } = useGraview();
666
+ return _jsx(CoverageView, { schema: store.schema, ...props, options: options });
667
+ }
668
+ return {
669
+ name: "coverage",
670
+ requiredRoles: [...COVERAGE_REQUIRED_ROLES],
671
+ options,
672
+ /*
673
+ * The SCHEMA comes from the provider, not from the caller.
674
+ *
675
+ * `ViewProps` carries no schema — the registry never passes one — so a
676
+ * lens rendered through the registry ran without it and every
677
+ * schema-aware decision inside quietly took its fallback path. In the
678
+ * timeline that meant an optional field absent on one node looked exactly
679
+ * like a role nobody bound, and a single unplanned task threw for the
680
+ * whole view.
681
+ */
682
+ View: Bound,
683
+ build(nodes, edges, schema) {
684
+ return buildCoverage(nodes, edges, options, schema);
685
+ },
686
+ };
687
+ }
688
+ /**
689
+ * The columns whose heads have scrolled under the sticky names, by id —
690
+ * measured against the corner the names sit in, on every sideways scroll.
691
+ */
692
+ function useColumnsUnderTheNames() {
693
+ const corner = useRef(null);
694
+ const heads = useRef(null);
695
+ const [hidden, setHidden] = useState(new Set());
696
+ useEffect(() => {
697
+ const strip = heads.current;
698
+ if (!strip || typeof window === "undefined")
699
+ return;
700
+ let scroller = strip.parentElement;
701
+ while (scroller && !/(auto|scroll)/.test(getComputedStyle(scroller).overflowX))
702
+ scroller = scroller.parentElement;
703
+ if (!scroller)
704
+ return;
705
+ let queued = 0;
706
+ const measure = () => {
707
+ queued = 0;
708
+ const edge = corner.current?.getBoundingClientRect().right ?? 0;
709
+ const under = new Set();
710
+ for (const head of strip.querySelectorAll("[data-graview-column]")) {
711
+ const box = head.getBoundingClientRect();
712
+ // Its label stands on the column's middle: under the names once that is.
713
+ if (box.left + box.width / 2 < edge)
714
+ under.add(head.dataset["graviewColumn"]);
715
+ }
716
+ setHidden((current) => (current.size === under.size && [...under].every((id) => current.has(id)) ? current : under));
717
+ };
718
+ const onScroll = () => {
719
+ if (queued === 0)
720
+ queued = requestAnimationFrame(measure);
721
+ };
722
+ measure();
723
+ scroller.addEventListener("scroll", onScroll, { passive: true });
724
+ return () => {
725
+ scroller.removeEventListener("scroll", onScroll);
726
+ if (queued !== 0)
727
+ cancelAnimationFrame(queued);
728
+ };
729
+ }, []);
730
+ return { corner, heads, hidden };
731
+ }
732
+ //# sourceMappingURL=coverage.js.map