@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.
- package/LICENSE +96 -0
- package/README.md +25 -0
- package/dist/arrange-bar.d.ts +49 -0
- package/dist/arrange-bar.d.ts.map +1 -0
- package/dist/arrange-bar.js +264 -0
- package/dist/arrange-bar.js.map +1 -0
- package/dist/chat.d.ts +46 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +343 -0
- package/dist/chat.js.map +1 -0
- package/dist/companion.d.ts +58 -0
- package/dist/companion.d.ts.map +1 -0
- package/dist/companion.js +433 -0
- package/dist/companion.js.map +1 -0
- package/dist/connections.d.ts +23 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +100 -0
- package/dist/connections.js.map +1 -0
- package/dist/default-views.d.ts +35 -0
- package/dist/default-views.d.ts.map +1 -0
- package/dist/default-views.js +605 -0
- package/dist/default-views.js.map +1 -0
- package/dist/editable.d.ts +71 -0
- package/dist/editable.d.ts.map +1 -0
- package/dist/editable.js +225 -0
- package/dist/editable.js.map +1 -0
- package/dist/figure.d.ts +30 -0
- package/dist/figure.d.ts.map +1 -0
- package/dist/figure.js +66 -0
- package/dist/figure.js.map +1 -0
- package/dist/find.d.ts +5 -0
- package/dist/find.d.ts.map +1 -0
- package/dist/find.js +252 -0
- package/dist/find.js.map +1 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/installation.d.ts +17 -0
- package/dist/installation.d.ts.map +1 -0
- package/dist/installation.js +37 -0
- package/dist/installation.js.map +1 -0
- package/dist/ladder.d.ts +2 -0
- package/dist/ladder.d.ts.map +1 -0
- package/dist/ladder.js +53 -0
- package/dist/ladder.js.map +1 -0
- package/dist/lens/arranging.d.ts +41 -0
- package/dist/lens/arranging.d.ts.map +1 -0
- package/dist/lens/arranging.js +38 -0
- package/dist/lens/arranging.js.map +1 -0
- package/dist/lens/board.d.ts +146 -0
- package/dist/lens/board.d.ts.map +1 -0
- package/dist/lens/board.js +658 -0
- package/dist/lens/board.js.map +1 -0
- package/dist/lens/calendar-dates.d.ts +19 -0
- package/dist/lens/calendar-dates.d.ts.map +1 -0
- package/dist/lens/calendar-dates.js +55 -0
- package/dist/lens/calendar-dates.js.map +1 -0
- package/dist/lens/calendar-drawing.d.ts +59 -0
- package/dist/lens/calendar-drawing.d.ts.map +1 -0
- package/dist/lens/calendar-drawing.js +229 -0
- package/dist/lens/calendar-drawing.js.map +1 -0
- package/dist/lens/calendar-options.d.ts +107 -0
- package/dist/lens/calendar-options.d.ts.map +1 -0
- package/dist/lens/calendar-options.js +19 -0
- package/dist/lens/calendar-options.js.map +1 -0
- package/dist/lens/calendar-placing.d.ts +35 -0
- package/dist/lens/calendar-placing.d.ts.map +1 -0
- package/dist/lens/calendar-placing.js +113 -0
- package/dist/lens/calendar-placing.js.map +1 -0
- package/dist/lens/calendar-spans.d.ts +48 -0
- package/dist/lens/calendar-spans.d.ts.map +1 -0
- package/dist/lens/calendar-spans.js +146 -0
- package/dist/lens/calendar-spans.js.map +1 -0
- package/dist/lens/calendar-view.d.ts +34 -0
- package/dist/lens/calendar-view.d.ts.map +1 -0
- package/dist/lens/calendar-view.js +228 -0
- package/dist/lens/calendar-view.js.map +1 -0
- package/dist/lens/calendar.d.ts +43 -0
- package/dist/lens/calendar.d.ts.map +1 -0
- package/dist/lens/calendar.js +43 -0
- package/dist/lens/calendar.js.map +1 -0
- package/dist/lens/coverage.d.ts +183 -0
- package/dist/lens/coverage.d.ts.map +1 -0
- package/dist/lens/coverage.js +732 -0
- package/dist/lens/coverage.js.map +1 -0
- package/dist/lens/horizon.d.ts +14 -0
- package/dist/lens/horizon.d.ts.map +1 -0
- package/dist/lens/horizon.js +20 -0
- package/dist/lens/horizon.js.map +1 -0
- package/dist/lens/more.d.ts +16 -0
- package/dist/lens/more.d.ts.map +1 -0
- package/dist/lens/more.js +17 -0
- package/dist/lens/more.js.map +1 -0
- package/dist/lens/plan-lens.d.ts +17 -0
- package/dist/lens/plan-lens.d.ts.map +1 -0
- package/dist/lens/plan-lens.js +24 -0
- package/dist/lens/plan-lens.js.map +1 -0
- package/dist/lens/plan-state.d.ts +122 -0
- package/dist/lens/plan-state.d.ts.map +1 -0
- package/dist/lens/plan-state.js +152 -0
- package/dist/lens/plan-state.js.map +1 -0
- package/dist/lens/plan-view.d.ts +10 -0
- package/dist/lens/plan-view.d.ts.map +1 -0
- package/dist/lens/plan-view.js +576 -0
- package/dist/lens/plan-view.js.map +1 -0
- package/dist/lens/plan.d.ts +25 -0
- package/dist/lens/plan.d.ts.map +1 -0
- package/dist/lens/plan.js +25 -0
- package/dist/lens/plan.js.map +1 -0
- package/dist/lens/reach.d.ts +47 -0
- package/dist/lens/reach.d.ts.map +1 -0
- package/dist/lens/reach.js +67 -0
- package/dist/lens/reach.js.map +1 -0
- package/dist/lens/timeline.d.ts +137 -0
- package/dist/lens/timeline.d.ts.map +1 -0
- package/dist/lens/timeline.js +426 -0
- package/dist/lens/timeline.js.map +1 -0
- package/dist/markup.d.ts +23 -0
- package/dist/markup.d.ts.map +1 -0
- package/dist/markup.js +24 -0
- package/dist/markup.js.map +1 -0
- package/dist/places.d.ts +24 -0
- package/dist/places.d.ts.map +1 -0
- package/dist/places.js +227 -0
- package/dist/places.js.map +1 -0
- package/dist/popover.d.ts +28 -0
- package/dist/popover.d.ts.map +1 -0
- package/dist/popover.js +59 -0
- package/dist/popover.js.map +1 -0
- package/dist/primitives/index.d.ts +214 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +381 -0
- package/dist/primitives/index.js.map +1 -0
- package/dist/profile.d.ts +65 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +233 -0
- package/dist/profile.js.map +1 -0
- package/dist/quick-relations.d.ts +16 -0
- package/dist/quick-relations.d.ts.map +1 -0
- package/dist/quick-relations.js +183 -0
- package/dist/quick-relations.js.map +1 -0
- package/dist/relation-key.d.ts +44 -0
- package/dist/relation-key.d.ts.map +1 -0
- package/dist/relation-key.js +196 -0
- package/dist/relation-key.js.map +1 -0
- package/dist/seat.d.ts +137 -0
- package/dist/seat.d.ts.map +1 -0
- package/dist/seat.js +162 -0
- package/dist/seat.js.map +1 -0
- package/dist/seats.d.ts +21 -0
- package/dist/seats.d.ts.map +1 -0
- package/dist/seats.js +62 -0
- package/dist/seats.js.map +1 -0
- package/dist/seeding.d.ts +150 -0
- package/dist/seeding.d.ts.map +1 -0
- package/dist/seeding.js +538 -0
- package/dist/seeding.js.map +1 -0
- package/dist/shell.d.ts +74 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +86 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme.d.ts +50 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +1692 -0
- package/dist/theme.js.map +1 -0
- package/dist/wordmark.d.ts +15 -0
- package/dist/wordmark.d.ts.map +1 -0
- package/dist/wordmark.js +55 -0
- package/dist/wordmark.js.map +1 -0
- package/dist/workbench/activity.d.ts +75 -0
- package/dist/workbench/activity.d.ts.map +1 -0
- package/dist/workbench/activity.js +259 -0
- package/dist/workbench/activity.js.map +1 -0
- package/dist/workbench/agent-seat.d.ts +51 -0
- package/dist/workbench/agent-seat.d.ts.map +1 -0
- package/dist/workbench/agent-seat.js +112 -0
- package/dist/workbench/agent-seat.js.map +1 -0
- package/dist/workbench/answer-args.d.ts +31 -0
- package/dist/workbench/answer-args.d.ts.map +1 -0
- package/dist/workbench/answer-args.js +257 -0
- package/dist/workbench/answer-args.js.map +1 -0
- package/dist/workbench/back-out.d.ts +71 -0
- package/dist/workbench/back-out.d.ts.map +1 -0
- package/dist/workbench/back-out.js +238 -0
- package/dist/workbench/back-out.js.map +1 -0
- package/dist/workbench/following.d.ts +7 -0
- package/dist/workbench/following.d.ts.map +1 -0
- package/dist/workbench/following.js +26 -0
- package/dist/workbench/following.js.map +1 -0
- package/dist/workbench/index.d.ts +23 -0
- package/dist/workbench/index.d.ts.map +1 -0
- package/dist/workbench/index.js +23 -0
- package/dist/workbench/index.js.map +1 -0
- package/dist/workbench/inspector.d.ts +27 -0
- package/dist/workbench/inspector.d.ts.map +1 -0
- package/dist/workbench/inspector.js +745 -0
- package/dist/workbench/inspector.js.map +1 -0
- package/dist/workbench/standing.d.ts +16 -0
- package/dist/workbench/standing.d.ts.map +1 -0
- package/dist/workbench/standing.js +102 -0
- package/dist/workbench/standing.js.map +1 -0
- package/dist/workbench/trail.d.ts +35 -0
- package/dist/workbench/trail.d.ts.map +1 -0
- package/dist/workbench/trail.js +150 -0
- package/dist/workbench/trail.js.map +1 -0
- 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
|