@issuegraph/viewer 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 +201 -0
- package/README.md +156 -0
- package/dist/clusters.d.ts +34 -0
- package/dist/clusters.d.ts.map +1 -0
- package/dist/clusters.js +195 -0
- package/dist/clusters.js.map +1 -0
- package/dist/document.d.ts +190 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +308 -0
- package/dist/document.js.map +1 -0
- package/dist/element.d.ts +95 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +174 -0
- package/dist/element.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +122 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +297 -0
- package/dist/layout.js.map +1 -0
- package/dist/mount.d.ts +64 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +402 -0
- package/dist/mount.js.map +1 -0
- package/dist/navigation.d.ts +56 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +121 -0
- package/dist/navigation.js.map +1 -0
- package/dist/parts.d.ts +89 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +214 -0
- package/dist/parts.js.map +1 -0
- package/dist/projections/graph.d.ts +30 -0
- package/dist/projections/graph.d.ts.map +1 -0
- package/dist/projections/graph.js +644 -0
- package/dist/projections/graph.js.map +1 -0
- package/dist/projections/linear.d.ts +35 -0
- package/dist/projections/linear.d.ts.map +1 -0
- package/dist/projections/linear.js +157 -0
- package/dist/projections/linear.js.map +1 -0
- package/dist/projections/tree.d.ts +17 -0
- package/dist/projections/tree.d.ts.map +1 -0
- package/dist/projections/tree.js +209 -0
- package/dist/projections/tree.js.map +1 -0
- package/dist/render.d.ts +35 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +45 -0
- package/dist/render.js.map +1 -0
- package/dist/scene.d.ts +94 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +48 -0
- package/dist/scene.js.map +1 -0
- package/dist/styles.d.ts +17 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +389 -0
- package/dist/styles.js.map +1 -0
- package/dist/theme.d.ts +71 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +169 -0
- package/dist/theme.js.map +1 -0
- package/dist/vocabulary.d.ts +107 -0
- package/dist/vocabulary.d.ts.map +1 -0
- package/dist/vocabulary.js +98 -0
- package/dist/vocabulary.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph projection: the ordered spine with gutters, and arcs for everything
|
|
3
|
+
* off it.
|
|
4
|
+
*
|
|
5
|
+
* Sequence is vertical position on the spine. Dependency is the arcs. Both
|
|
6
|
+
* readings work at once because they use different visual channels, which is
|
|
7
|
+
* the whole reason this shape was chosen over a force-directed or layered
|
|
8
|
+
* drawing — those make dependency legible and sequence a hunt, and their layout
|
|
9
|
+
* shifts between refreshes, which destroys trust in a panel whose job is to be
|
|
10
|
+
* authoritative.
|
|
11
|
+
*
|
|
12
|
+
* IT REFUSES RATHER THAN DEGRADES. The list scales; the canvas is a local
|
|
13
|
+
* instrument answering "what surrounds this issue", so past its budget it says
|
|
14
|
+
* so and offers the next move instead of drawing a hairball.
|
|
15
|
+
*/
|
|
16
|
+
import { clustersOf } from "../clusters.js";
|
|
17
|
+
import { element, svg } from "../element.js";
|
|
18
|
+
import { edgeGeometry, enclosureBounds, fitLabel, layoutGraph, } from "../layout.js";
|
|
19
|
+
import { emptyState, legend, slotLabel, slotTitle, station, stationFill, stationsOf, atStations, } from "../parts.js";
|
|
20
|
+
import { resolveFocusKey } from "../scene.js";
|
|
21
|
+
import { defaultTheme } from "../theme.js";
|
|
22
|
+
import { dashArrayFor, treatmentFor } from "../vocabulary.js";
|
|
23
|
+
import { excludedRow, isFooterSlot } from "./linear.js";
|
|
24
|
+
/**
|
|
25
|
+
* The node budget, from the design's scale table. Above the first threshold the
|
|
26
|
+
* canvas shows component capsules; above the second, clusters only.
|
|
27
|
+
*/
|
|
28
|
+
export const GRAPH_NODE_BUDGET = 60;
|
|
29
|
+
export const CLUSTER_ONLY_BUDGET = 300;
|
|
30
|
+
function terminalMarker(terminal, geometry, field, theme) {
|
|
31
|
+
const { end, endAngle } = geometry;
|
|
32
|
+
const degrees = (endAngle * 180) / Math.PI;
|
|
33
|
+
const transform = `translate(${end.x.toFixed(2)} ${end.y.toFixed(2)}) rotate(${degrees.toFixed(2)})`;
|
|
34
|
+
const common = { class: 'ig-terminal', 'data-edge': field, transform };
|
|
35
|
+
// Every dimension is theme data. A marker sized by a literal would stay put
|
|
36
|
+
// while a host scaled the type around it, and the shape channel the
|
|
37
|
+
// colour-blind-safety claim leans on is exactly what would stop reading.
|
|
38
|
+
const length = theme.metrics['--ig-terminal-length'];
|
|
39
|
+
const half = theme.metrics['--ig-terminal-width'] / 2;
|
|
40
|
+
switch (terminal) {
|
|
41
|
+
case 'arrow':
|
|
42
|
+
return svg('path', {
|
|
43
|
+
...common,
|
|
44
|
+
d: `M 0 0 L ${String(-length)} ${String(-half)} L ${String(-length)} ${String(half)} Z`,
|
|
45
|
+
fill: 'currentColor',
|
|
46
|
+
});
|
|
47
|
+
case 'hollow-circle':
|
|
48
|
+
return svg('circle', {
|
|
49
|
+
...common,
|
|
50
|
+
cx: -half,
|
|
51
|
+
cy: 0,
|
|
52
|
+
r: half,
|
|
53
|
+
fill: 'none',
|
|
54
|
+
stroke: 'currentColor',
|
|
55
|
+
});
|
|
56
|
+
case 'tee':
|
|
57
|
+
return svg('path', {
|
|
58
|
+
...common,
|
|
59
|
+
d: `M 0 ${String(-half)} L 0 ${String(half)}`,
|
|
60
|
+
stroke: 'currentColor',
|
|
61
|
+
fill: 'none',
|
|
62
|
+
});
|
|
63
|
+
case 'none':
|
|
64
|
+
case 'enclosure':
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One edge, drawn on all four channels: the path carries dash and hue, the
|
|
70
|
+
* marker carries the terminal, and the badge grammar carries the glyph.
|
|
71
|
+
*
|
|
72
|
+
* `serialize-with` is drawn as two parallel strokes rather than one dashed
|
|
73
|
+
* line — the "double" pattern is structural, so it cannot be confused with a
|
|
74
|
+
* dash under any theme.
|
|
75
|
+
*/
|
|
76
|
+
function edgePaths(edge, geometry, theme) {
|
|
77
|
+
const treatment = treatmentFor(edge.field);
|
|
78
|
+
const dash = dashArrayFor(treatment.dash);
|
|
79
|
+
const label = `${edge.from} ${treatment.label} ${edge.to}`;
|
|
80
|
+
const base = {
|
|
81
|
+
class: 'ig-edge',
|
|
82
|
+
'data-edge': edge.field,
|
|
83
|
+
d: geometry.d,
|
|
84
|
+
'stroke-dasharray': dash,
|
|
85
|
+
role: 'img',
|
|
86
|
+
'aria-label': label,
|
|
87
|
+
};
|
|
88
|
+
if (treatment.dash === 'double') {
|
|
89
|
+
// Separated by one stroke width, so the pair reads as two lines at any
|
|
90
|
+
// scale rather than merging once a host thickens the stroke.
|
|
91
|
+
const offset = theme.metrics['--ig-stroke'];
|
|
92
|
+
return [
|
|
93
|
+
svg('path', { ...base, transform: `translate(0 ${String(-offset)})` }),
|
|
94
|
+
svg('path', {
|
|
95
|
+
...base,
|
|
96
|
+
transform: `translate(0 ${String(offset)})`,
|
|
97
|
+
'aria-hidden': 'true',
|
|
98
|
+
role: null,
|
|
99
|
+
}),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
return [svg('path', base)];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* One node on the canvas.
|
|
106
|
+
*
|
|
107
|
+
* A NODE THE SCENE PUBLISHES AS A NAVIGATION TARGET HAS TO BE FOCUSABLE. The
|
|
108
|
+
* rail draws only the ranked spine slots, so a tracker-held slot, a duplicate
|
|
109
|
+
* and every gutter node exist ONLY as this group — and an SVG group with no
|
|
110
|
+
* `tabindex` cannot take focus, so navigating to one called `focus()` on
|
|
111
|
+
* nothing and the visible tab stop vanished.
|
|
112
|
+
*
|
|
113
|
+
* `railed` names the keys the rail already draws, so exactly one element per
|
|
114
|
+
* key is tabbable; a second tab stop for the same issue would be worse than
|
|
115
|
+
* none. A focusable element also needs a name, hence `role`/`aria-label`.
|
|
116
|
+
*/
|
|
117
|
+
function nodeShape(document, layout, key, options, theme, navigable) {
|
|
118
|
+
const box = layout.nodes.get(key);
|
|
119
|
+
if (box === undefined)
|
|
120
|
+
return null;
|
|
121
|
+
const issue = document.byKey.get(key);
|
|
122
|
+
const selected = options.selected === key;
|
|
123
|
+
const ownsTabStop = navigable.keys.includes(key) && !navigable.railed.has(key);
|
|
124
|
+
const full = issue?.title ?? key;
|
|
125
|
+
const drawn = fitLabel(theme, full, box.width);
|
|
126
|
+
// A HOLD THE RAIL DOES NOT CARRY HAS TO BE CARRIED HERE. The rail draws only
|
|
127
|
+
// the non-footer slots, so a tracker-held slot is filtered out of it — and the
|
|
128
|
+
// reason last round put on the rail row therefore never reached the graph at
|
|
129
|
+
// all for exactly those slots, while `ViewerHold` says the viewer renders the
|
|
130
|
+
// reason verbatim. Measured on the fixture: `claimed by another run` appeared
|
|
131
|
+
// nowhere in graph markup.
|
|
132
|
+
// I DEFERRED THIS ONCE ON A REASON THAT WAS WRONG, and it is worth recording:
|
|
133
|
+
// I said `nodeShape` "takes a key and an issue and knows nothing about slots",
|
|
134
|
+
// so carrying the reason would be a signature change touching every node. It
|
|
135
|
+
// takes the whole `document`, and the slots are on it. There was no signature
|
|
136
|
+
// change to make. See issue #41.
|
|
137
|
+
// ONLY FOR A NODE THE RAIL DOES NOT LABEL — a railed slot already carries its
|
|
138
|
+
// reason on its row, and repeating it here would announce the same sentence
|
|
139
|
+
// twice for one slot.
|
|
140
|
+
const heldBecause = navigable.railed.has(key)
|
|
141
|
+
? ''
|
|
142
|
+
: document.order.slots
|
|
143
|
+
.filter((slot) => slot.lead === key || slot.members.includes(key))
|
|
144
|
+
.flatMap((slot) => slot.holds.map((hold) => hold.reason))
|
|
145
|
+
.join(' · ');
|
|
146
|
+
// THE POINTER MUST NOT NAME AN IDENTITY THE KEYBOARD CANNOT REACH. A together
|
|
147
|
+
// unit is ONE station with one focus key, so its non-lead members are absent
|
|
148
|
+
// from `navigable` deliberately — and this published each of them as its own
|
|
149
|
+
// `data-ig-key` anyway. Measured on the fixture: clicking `104` emitted `104`,
|
|
150
|
+
// selected `104`, and threw focus to `102` — not even the unit that was
|
|
151
|
+
// clicked, because `resolveFocusKey` found neither the selection nor the
|
|
152
|
+
// requested key in the order and fell back to its first entry. No keyboard can
|
|
153
|
+
// produce that state.
|
|
154
|
+
// ROUTED TO THE STATION, NOT MADE A STATION. Giving the partner its own focus
|
|
155
|
+
// key is the other repair codex offered and it is the one round ten already
|
|
156
|
+
// rejected: it splits the unit into two stations, which `navigation.test.ts`
|
|
157
|
+
// forbids in as many words. So the member keeps its node and loses only its
|
|
158
|
+
// FOCUS identity, which it never legitimately had.
|
|
159
|
+
// `GROUP_ATTRIBUTE` IS EXACTLY THIS CHANNEL — the enclosure and the connector
|
|
160
|
+
// already answer a pointer with their unit's lead through it, and a member's
|
|
161
|
+
// node is the same question about the same unit. `keyAt` reads it as the
|
|
162
|
+
// fallback the focus index never sees.
|
|
163
|
+
// A KEY NO SLOT REPRESENTS FALLS BACK TO ITSELF, which is no worse than what
|
|
164
|
+
// it published before; the orphan pass above is what makes such a key
|
|
165
|
+
// navigable, and the invariant test is what proves it did.
|
|
166
|
+
const published = navigable.keys.includes(key);
|
|
167
|
+
const station = document.order.slots.find((slot) => slot.members.includes(key))?.lead ?? key;
|
|
168
|
+
return svg('g', {
|
|
169
|
+
class: 'ig-node-group',
|
|
170
|
+
'data-ig-key': published ? key : null,
|
|
171
|
+
'data-ig-group': published ? null : station,
|
|
172
|
+
'data-column': box.column,
|
|
173
|
+
'aria-current': selected ? 'true' : 'false',
|
|
174
|
+
role: ownsTabStop ? 'img' : null,
|
|
175
|
+
// THE REASON RIDES THE NAME WHEN THERE IS ONE, on the same channels the
|
|
176
|
+
// rail row uses, so one hold reads the same whichever surface drew it.
|
|
177
|
+
'aria-label': ownsTabStop
|
|
178
|
+
? heldBecause === ''
|
|
179
|
+
? `${full} — ${key}`
|
|
180
|
+
: `${full} — ${key} — ${heldBecause}`
|
|
181
|
+
: null,
|
|
182
|
+
tabindex: ownsTabStop ? (navigable.focused === key ? 0 : -1) : null,
|
|
183
|
+
}, [
|
|
184
|
+
// AND ON THE POINTER CHANNEL, which is also the only one left for a node
|
|
185
|
+
// that owns no tab stop and therefore carries no `aria-label` at all.
|
|
186
|
+
// First child, because that is where SVG looks for `<title>`.
|
|
187
|
+
heldBecause === '' ? null : svg('title', {}, [`${full} — ${heldBecause}`]),
|
|
188
|
+
svg('rect', {
|
|
189
|
+
class: 'ig-node',
|
|
190
|
+
'data-held': box.held ? 'true' : 'false',
|
|
191
|
+
x: box.x,
|
|
192
|
+
y: box.y,
|
|
193
|
+
width: box.width,
|
|
194
|
+
height: box.height,
|
|
195
|
+
rx: theme.metrics['--ig-radius'],
|
|
196
|
+
}),
|
|
197
|
+
// A node is labelled by its RAIL ROW when it has one, so labelling it
|
|
198
|
+
// here too would print the title twice — once selectable, once not. But
|
|
199
|
+
// the rail draws only the ranked slots, and keying this on the COLUMN
|
|
200
|
+
// rather than on the rail left a tracker-held slot as a blank rectangle:
|
|
201
|
+
// no title, no hold reason, nothing. The question is whether this key is
|
|
202
|
+
// railed, not which column it sits in.
|
|
203
|
+
navigable.railed.has(key)
|
|
204
|
+
? null
|
|
205
|
+
: svg('text', {
|
|
206
|
+
class: 'ig-node-label',
|
|
207
|
+
x: box.x + theme.metrics['--ig-space'],
|
|
208
|
+
// `dominant-baseline` centres the glyphs on the line rather than
|
|
209
|
+
// a remembered offset, so the label stays centred at any scale.
|
|
210
|
+
y: box.y + box.height / 2,
|
|
211
|
+
'dominant-baseline': 'middle',
|
|
212
|
+
}, [
|
|
213
|
+
// THE FULL TITLE IS NOT LOST WHEN IT DOES NOT FIT — and it has to
|
|
214
|
+
// be a CHILD ELEMENT to do that job. This was written as a `title`
|
|
215
|
+
// ATTRIBUTE, which SVG ignores entirely: no tooltip, no accessible
|
|
216
|
+
// description, nothing. The markup contained the string, so a test
|
|
217
|
+
// asserting the full title was "somewhere in the markup" passed
|
|
218
|
+
// while a reader hovering the shortened label recovered nothing.
|
|
219
|
+
// FIRST CHILD, because that is where SVG looks for it, and only on
|
|
220
|
+
// truncation — an untruncated label already reads in full, and a
|
|
221
|
+
// `<title>` echoing it would announce it twice.
|
|
222
|
+
// A railed node never reaches here at all: the rail row is its
|
|
223
|
+
// label, and its own CSS ellipsis handles the same overflow.
|
|
224
|
+
drawn === full ? null : svg('title', {}, [full]),
|
|
225
|
+
drawn,
|
|
226
|
+
]),
|
|
227
|
+
]);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The spine's ranks and readiness stations, drawn as HTML ON the canvas.
|
|
231
|
+
*
|
|
232
|
+
* Text in SVG is not selectable, not reflowable and announces poorly, so the
|
|
233
|
+
* spine's rows stay HTML — but they are the labels FOR the spine nodes, and
|
|
234
|
+
* emitting them as a sibling block put them above the drawing instead of on it.
|
|
235
|
+
* Ranks and stations then described a picture the reader had to hold in their
|
|
236
|
+
* head, which is the opposite of the design's claim that the spine IS the
|
|
237
|
+
* order.
|
|
238
|
+
*
|
|
239
|
+
* So each row is positioned at the coordinates the LAYOUT computed for its own
|
|
240
|
+
* node — same source of truth as every edge endpoint. Hand-authoring these
|
|
241
|
+
* against remembered positions is the failure mode the design's implementation
|
|
242
|
+
* note names; the numbers ride custom properties so the theme still owns them.
|
|
243
|
+
*/
|
|
244
|
+
/**
|
|
245
|
+
* What the rail draws — ONE rule, because two callers read it.
|
|
246
|
+
*
|
|
247
|
+
* `spineRail` renders these rows and the focus index has to publish exactly the
|
|
248
|
+
* same set, or the projection draws a keyed row nothing can reach. That is not
|
|
249
|
+
* hypothetical: widening the rail for the refusal without widening the index
|
|
250
|
+
* left the footer slot and the exclusion drawn with `data-ig-key` and absent
|
|
251
|
+
* from `navigable` — the identical defect the canvas had two rounds ago,
|
|
252
|
+
* reintroduced by fixing its sibling. Deriving both from here is what makes the
|
|
253
|
+
* two provably agree rather than agree by inspection.
|
|
254
|
+
*
|
|
255
|
+
* `positioned` is the refusal signal inverted: with a canvas, a footer slot is
|
|
256
|
+
* drawn as a canvas node and an exclusion sits off the spine entirely, so the
|
|
257
|
+
* rail carries neither. Without one, the rail IS the order.
|
|
258
|
+
*/
|
|
259
|
+
function railContents(document, positioned) {
|
|
260
|
+
return positioned
|
|
261
|
+
? { slots: document.order.slots.filter((slot) => !isFooterSlot(slot)), excluded: [] }
|
|
262
|
+
: { slots: document.order.slots, excluded: document.order.excluded };
|
|
263
|
+
}
|
|
264
|
+
function spineRail(document, layout, options, focused, positioned) {
|
|
265
|
+
// A REFUSAL'S RAIL IS THE WHOLE ORDER UI, so it must carry what the canvas
|
|
266
|
+
// would otherwise have drawn. `positioned` is exactly the refusal signal — the
|
|
267
|
+
// only `false` call site is the refusal arm — and when the canvas is absent, a
|
|
268
|
+
// footer slot has nothing to draw it and an exclusion has no row at all.
|
|
269
|
+
// Measured on a refused document: the tracker-held slot's title, its hold
|
|
270
|
+
// reason, and the excluded key were all absent from the markup, while the
|
|
271
|
+
// refusal's own text said "The order list is complete at any size". That claim
|
|
272
|
+
// was written into this file and it was false.
|
|
273
|
+
// FILTERED ONLY WHEN THE CANVAS DRAWS THEM. In ordinary graph mode a footer
|
|
274
|
+
// slot IS drawn, as a canvas node, so keeping it out of the rail is what stops
|
|
275
|
+
// one slot appearing twice — the filter is right there and wrong here.
|
|
276
|
+
const { slots, excluded } = railContents(document, positioned);
|
|
277
|
+
return element('ol',
|
|
278
|
+
// A plain list for the reason `linear.ts` gives: an interactive descendant
|
|
279
|
+
// may not live inside `role="option"`.
|
|
280
|
+
// WHEN THERE IS NO CANVAS THERE IS NOTHING TO SIT ON. A refusal draws no
|
|
281
|
+
// spine nodes, so the rail returns to ordinary flow rather than positioning
|
|
282
|
+
// itself against coordinates nothing rendered.
|
|
283
|
+
{ class: positioned ? 'ig-list ig-rail' : 'ig-list', 'aria-label': 'work order' }, [
|
|
284
|
+
...slots.map((slot) => {
|
|
285
|
+
const box = layout.nodes.get(slot.lead);
|
|
286
|
+
// THE REASON A SLOT IS HELD IS PART OF WHAT THIS ROW MEANS. `ViewerHold`
|
|
287
|
+
// says the viewer renders the reason verbatim, and the linear projection
|
|
288
|
+
// does — this one rendered rank, station and title, so a graph reader was
|
|
289
|
+
// told THAT a slot is held and never WHY. `data-held` styles the row, so
|
|
290
|
+
// the holding was visible and the host's sentence was not, on either the
|
|
291
|
+
// visible or the accessible channel.
|
|
292
|
+
// ON THE LABEL AND THE TOOLTIP, NOT AS A BLOCK IN THE ROW. This row is
|
|
293
|
+
// positioned onto its node's box — `--ig-row-h` IS the node height — so a
|
|
294
|
+
// paragraph per hold would overflow geometry the layout computed for a
|
|
295
|
+
// node, which is a worse defect than the one being fixed. `title` plus
|
|
296
|
+
// `aria-label` is how this package already carries text that cannot take
|
|
297
|
+
// space (see the edge badges): sighted readers hover, screen readers hear
|
|
298
|
+
// it, and the layout is untouched.
|
|
299
|
+
// ONLY HERE, not in `slotLabel`. That helper is shared with the linear
|
|
300
|
+
// projection, which renders the same holds as visible paragraphs — adding
|
|
301
|
+
// them there would announce every linear hold twice.
|
|
302
|
+
const heldBecause = slot.holds.map((hold) => hold.reason).join(' · ');
|
|
303
|
+
return element('li', {
|
|
304
|
+
class: positioned ? 'ig-slot ig-rail-row' : 'ig-slot',
|
|
305
|
+
'data-ig-key': slot.lead,
|
|
306
|
+
'data-held': slot.ready ? 'false' : 'true',
|
|
307
|
+
'aria-current': options.selected === slot.lead ? 'true' : 'false',
|
|
308
|
+
'aria-label': heldBecause === ''
|
|
309
|
+
? slotLabel(document, slot)
|
|
310
|
+
: `${slotLabel(document, slot)} — ${heldBecause}`,
|
|
311
|
+
title: heldBecause === '' ? null : heldBecause,
|
|
312
|
+
tabindex: focused === slot.lead ? 0 : -1,
|
|
313
|
+
// Positioned from the layout, not from the flow, so a row sits on the
|
|
314
|
+
// node it names however the theme scales the geometry.
|
|
315
|
+
style: positioned && box !== undefined
|
|
316
|
+
? `--ig-row-x:${String(box.x)}px;--ig-row-y:${String(box.y)}px;--ig-row-w:${String(box.width)}px;--ig-row-h:${String(box.height)}px`
|
|
317
|
+
: null,
|
|
318
|
+
}, [
|
|
319
|
+
element('span', { class: 'ig-rank', 'data-held': slot.ready ? 'false' : 'true', 'aria-hidden': 'true' }, [slot.rank === null ? '—' : String(slot.rank)]),
|
|
320
|
+
station(stationFill(slot)),
|
|
321
|
+
element('span', { class: 'ig-title' }, [slotTitle(document, slot)]),
|
|
322
|
+
]);
|
|
323
|
+
}),
|
|
324
|
+
// AND THE EXCLUSIONS, on the same terms: they have no canvas node in any
|
|
325
|
+
// mode, so ordinary graph mode simply never showed them — which is correct
|
|
326
|
+
// there, because the spine rail sits ON the canvas and an exclusion is not
|
|
327
|
+
// on it. In a refusal there is no canvas, the rail is the entire order, and
|
|
328
|
+
// an exclusion left out is a row of the order that is missing.
|
|
329
|
+
...excluded.map((exclusion) => excludedRow(document, exclusion.key, exclusion.canonical, { ...options, focused })),
|
|
330
|
+
]);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The refusal.
|
|
334
|
+
*
|
|
335
|
+
* A refusal with a route forward reads as competence — but only if the route is
|
|
336
|
+
* one the reader can actually take. "Select one component to draw it" was not:
|
|
337
|
+
* the capsules carried no identity, nothing dispatched from them, and this
|
|
338
|
+
* package never narrows to a component. Advertising an action nobody can
|
|
339
|
+
* perform is worse than a plain refusal, so each capsule now carries its
|
|
340
|
+
* component's lead as a pointer identity (a host receives it through
|
|
341
|
+
* `onSelect`) and the instruction names what actually happens next — the host
|
|
342
|
+
* narrows the document, because narrowing IS the host's job in a package that
|
|
343
|
+
* renders exactly what it is given.
|
|
344
|
+
*/
|
|
345
|
+
function refusal(document, layout, nodeCount, mode) {
|
|
346
|
+
// THE SAME KEY SET THE COUNT ABOVE CAME FROM. `nodeCount` is `layout.nodes`,
|
|
347
|
+
// so the component list has to partition `layout.nodes` or the two disagree —
|
|
348
|
+
// which is how an edge-free over-budget document listed no components at all
|
|
349
|
+
// under a sentence telling the reader to choose one.
|
|
350
|
+
const clusters = clustersOf(document, new Set(layout.nodes.keys()));
|
|
351
|
+
const heading = mode === 'capsules'
|
|
352
|
+
? `${String(nodeCount)} related issues is past this canvas's budget of ${String(GRAPH_NODE_BUDGET)}, so it is not drawing them.`
|
|
353
|
+
: `${String(nodeCount)} related issues is far past this canvas's budget, so it is showing clusters only.`;
|
|
354
|
+
// THE REFUSAL IS INFORMATIONAL. IT PUBLISHES NO CONTROL — and that is a
|
|
355
|
+
// RESTRUCTURE, not a regression of the round-five finding it answers.
|
|
356
|
+
// That finding offered two remedies: expose an actionable component target,
|
|
357
|
+
// OR replace the instruction with an action the rendered API actually
|
|
358
|
+
// supports. The first was taken, and it drew a defect in every round since —
|
|
359
|
+
// the capsule was pointer-only, then it was a button outside the focus index
|
|
360
|
+
// whose activation redraws and destroys itself, leaving a keyboard reader
|
|
361
|
+
// with focus on nothing. Each patch was correct and each bought another.
|
|
362
|
+
// The cause underneath them is that this package DOES NOT NARROW. It renders
|
|
363
|
+
// exactly what it is given, so a control here can never complete the action
|
|
364
|
+
// it advertises; only the host can, by narrowing the document and rendering
|
|
365
|
+
// again. A control that cannot finish its own job is the surface generating
|
|
366
|
+
// the findings, so it goes rather than gets a fourth fix — and with it go the
|
|
367
|
+
// focus index it never belonged to, the synthesized click, and the group
|
|
368
|
+
// identity nothing could act on.
|
|
369
|
+
// The order list remains, and IS complete at any size: that is the action a
|
|
370
|
+
// reader can actually take inside this package, so it is the one named below.
|
|
371
|
+
const LIMIT = 12;
|
|
372
|
+
const shown = mode === 'capsules' ? clusters : clusters.slice(0, LIMIT);
|
|
373
|
+
// SAY WHAT WAS OMITTED. A silent slice left a reader looking at twelve
|
|
374
|
+
// components and no indication that 139 others existed — under a heading
|
|
375
|
+
// announcing it was showing clusters. Refusing to draw is defensible;
|
|
376
|
+
// under-reporting the shape without saying so is not, because the reader
|
|
377
|
+
// cannot tell a complete list from a truncated one.
|
|
378
|
+
const omitted = clusters.length - shown.length;
|
|
379
|
+
return element('section', { class: 'ig-refusal', role: 'note' }, [
|
|
380
|
+
element('p', {}, [heading]),
|
|
381
|
+
element('ol', { class: 'ig-list', 'aria-label': 'connected components' }, shown.map((cluster) => element('li', { class: 'ig-capsule' }, [
|
|
382
|
+
element('span', { class: 'ig-count' }, [`${String(cluster.members.length)} issues`]),
|
|
383
|
+
element('span', { class: 'ig-count' }, [`${String(cluster.blockedByEdges)} blocking`]),
|
|
384
|
+
element('span', { class: 'ig-count' }, [`depth ${String(cluster.chainDepth)}`]),
|
|
385
|
+
cluster.hasCycle
|
|
386
|
+
? element('span', { class: 'ig-badge', 'data-edge': 'blocked-by' }, ['cycle'])
|
|
387
|
+
: null,
|
|
388
|
+
element('span', { class: 'ig-id' }, [cluster.members.slice(0, 3).join(', ')]),
|
|
389
|
+
]))),
|
|
390
|
+
omitted > 0
|
|
391
|
+
? element('p', { class: 'ig-refusal-omitted' }, [
|
|
392
|
+
`${String(omitted)} further ${omitted === 1 ? 'component is' : 'components are'} not listed; ${String(clusters.length)} were found in total.`,
|
|
393
|
+
])
|
|
394
|
+
: null,
|
|
395
|
+
element('p', { class: 'ig-refusal-next' }, [
|
|
396
|
+
'Narrow the document to one neighbourhood and render again — narrowing is the host\'s, because this package draws exactly what it is given. The order list is complete at any size.',
|
|
397
|
+
]),
|
|
398
|
+
]);
|
|
399
|
+
}
|
|
400
|
+
export function graphScene(document, rawOptions = {}) {
|
|
401
|
+
// See `linearScene` — the same rule, applied before the canvas is laid out.
|
|
402
|
+
const stations = stationsOf(document);
|
|
403
|
+
const options = atStations(rawOptions, stations);
|
|
404
|
+
const theme = options.theme ?? defaultTheme;
|
|
405
|
+
const layout = layoutGraph(document, theme);
|
|
406
|
+
const nodeCount = layout.nodes.size;
|
|
407
|
+
const inline = document.order.slots.filter((slot) => !isFooterSlot(slot));
|
|
408
|
+
const footerSlots = document.order.slots.filter(isFooterSlot);
|
|
409
|
+
// ── WHICH KEYS CAN HOLD FOCUS IS DERIVED FROM WHAT THIS SCENE WILL DRAW ────
|
|
410
|
+
//
|
|
411
|
+
// Three rounds of review found the same class here — a key published as a
|
|
412
|
+
// navigation target with no focusable element behind it — in three different
|
|
413
|
+
// places: gutter nodes with no `tabindex`, then refusal mode replacing the
|
|
414
|
+
// whole canvas while the published sets still named its nodes. Patching each
|
|
415
|
+
// site kept the invariant true by maintenance, which is why it kept coming
|
|
416
|
+
// back.
|
|
417
|
+
//
|
|
418
|
+
// So the sets are FILTERED BY WHAT RENDERS instead of declared beside it. The
|
|
419
|
+
// rail always draws the ranked slots; the canvas draws every laid-out node,
|
|
420
|
+
// and draws NOTHING keyed when it refuses or is empty. Everything downstream
|
|
421
|
+
// is a subset of that, so "every published target is focusable" holds by
|
|
422
|
+
// construction rather than by remembering.
|
|
423
|
+
const refused = nodeCount === 0 || nodeCount > GRAPH_NODE_BUDGET;
|
|
424
|
+
// FROM THE SAME RULE THE RAIL RENDERS FROM — see `railContents`. Hard-coding
|
|
425
|
+
// `inline` here was correct only while the rail rendered exactly `inline`, and
|
|
426
|
+
// it silently stopped being correct the moment the refusal's rail widened.
|
|
427
|
+
const shown = railContents(document, !refused);
|
|
428
|
+
const railed = new Set([
|
|
429
|
+
...shown.slots.map((slot) => slot.lead),
|
|
430
|
+
...shown.excluded.map((exclusion) => exclusion.key),
|
|
431
|
+
]);
|
|
432
|
+
const focusable = new Set([
|
|
433
|
+
...railed,
|
|
434
|
+
...(refused ? [] : layout.nodes.keys()),
|
|
435
|
+
]);
|
|
436
|
+
const focusOrder = [
|
|
437
|
+
...inline.map((slot) => slot.lead),
|
|
438
|
+
...footerSlots.map((slot) => slot.lead),
|
|
439
|
+
...document.order.excluded.map((exclusion) => exclusion.key),
|
|
440
|
+
].filter((key) => focusable.has(key));
|
|
441
|
+
// ── the lateral axis: ONLY PAIRS WHOSE REVERSE HOLDS ──────────────────────
|
|
442
|
+
//
|
|
443
|
+
// A one-way mapping is not a traversal — focus went out to a gutter node and
|
|
444
|
+
// the opposite arrow answered `none`. Recording both ends fixed that, and
|
|
445
|
+
// then broke on the case one gutter node is related to TWO spine slots: each
|
|
446
|
+
// slot overwrote the gutter's single reverse entry, so `A.left = G` while
|
|
447
|
+
// `G.right = B`, and left-then-right did not come back.
|
|
448
|
+
//
|
|
449
|
+
// A node has ONE neighbour per side, so a shared gutter cannot point back to
|
|
450
|
+
// both — no amount of care makes it. So the invariant is the thing published:
|
|
451
|
+
// a pair is written only when its reverse is still free, and a forward link
|
|
452
|
+
// whose reverse could not be kept is not published either. Every pair in this
|
|
453
|
+
// map is reversible, which `graph.test.ts` asserts over the whole map rather
|
|
454
|
+
// than for one example.
|
|
455
|
+
const lateral = new Map();
|
|
456
|
+
const opposite = (side) => side === 'left' ? 'right' : 'left';
|
|
457
|
+
const linkable = (key, side) => (lateral.get(key) ?? {})[side] === undefined;
|
|
458
|
+
const link = (key, side, target) => {
|
|
459
|
+
lateral.set(key, { ...(lateral.get(key) ?? {}), [side]: target });
|
|
460
|
+
};
|
|
461
|
+
for (const slot of document.order.slots) {
|
|
462
|
+
if (!focusable.has(slot.lead))
|
|
463
|
+
continue;
|
|
464
|
+
// Every MEMBER's edges, not just the lead's: a together unit is one station
|
|
465
|
+
// with one focus key, so a gutter neighbour reachable only through its
|
|
466
|
+
// second member would otherwise be unreachable by keyboard entirely.
|
|
467
|
+
const touching = slot.members.flatMap((member) => (document.edgesOf.get(member) ?? []).map((edge) => edge.from === member ? edge.to : edge.from));
|
|
468
|
+
for (const side of ['left', 'right']) {
|
|
469
|
+
const target = touching.find((other) => focusable.has(other) &&
|
|
470
|
+
layout.nodes.get(other)?.column === side &&
|
|
471
|
+
// Both directions have to be free, or the pair is not reversible.
|
|
472
|
+
linkable(slot.lead, side) &&
|
|
473
|
+
linkable(other, opposite(side)));
|
|
474
|
+
if (target === undefined)
|
|
475
|
+
continue;
|
|
476
|
+
link(slot.lead, side, target);
|
|
477
|
+
link(target, opposite(side), slot.lead);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// A NODE NO STATION REPRESENTS HAS NO KEYBOARD EXISTENCE AT ALL. The canvas
|
|
481
|
+
// draws it with a key, so a pointer can select what a keyboard cannot reach —
|
|
482
|
+
// and where NOTHING is ordered, every list is empty and the canvas offers no
|
|
483
|
+
// keyboard entry whatsoever. Measured: a document with edges and no order
|
|
484
|
+
// slots renders two keyed nodes and not one `tabindex`.
|
|
485
|
+
// THE TEST IS "REPRESENTED BY A STATION", NOT "IN A LIST", and that distinction
|
|
486
|
+
// is the whole correctness of this. A together unit is ONE station with one
|
|
487
|
+
// focus key, so its non-lead members are absent from the order DELIBERATELY —
|
|
488
|
+
// `104` in this package's fixture is exactly that, and an earlier version of
|
|
489
|
+
// this fix gave it a station of its own, splitting the unit and breaking the
|
|
490
|
+
// rule `navigation.test.ts` states in as many words. A member is represented
|
|
491
|
+
// by its lead; only a key belonging to no slot at all is unrepresented.
|
|
492
|
+
// THEY JOIN THE VERTICAL ORDER rather than the membership set alone, because
|
|
493
|
+
// with a roving tabindex only the FOCUSED key is tabbable — a key focus can
|
|
494
|
+
// never ARRIVE at is unreachable however wide the set of things that "can hold
|
|
495
|
+
// focus" is. The arrows are the only way in.
|
|
496
|
+
// APPENDED, so every ranked position keeps its rank and the first entry is
|
|
497
|
+
// unchanged; in the layout's own node order, so the result is deterministic.
|
|
498
|
+
const represented = new Set();
|
|
499
|
+
for (const slot of document.order.slots)
|
|
500
|
+
for (const member of slot.members)
|
|
501
|
+
represented.add(member);
|
|
502
|
+
if (!refused) {
|
|
503
|
+
for (const key of layout.nodes.keys()) {
|
|
504
|
+
if (!focusOrder.includes(key) && !lateral.has(key) && !represented.has(key))
|
|
505
|
+
focusOrder.push(key);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// A GUTTER NODE IS REACHABLE SIDEWAYS WITHOUT BEING A POSITION IN THE ORDER,
|
|
509
|
+
// so the set that can hold focus is wider than the order that walks it.
|
|
510
|
+
// `focusOrder` LEADS, so the first entry is the same under either — which is
|
|
511
|
+
// why this is built AFTER the pass above rather than before it. Built first,
|
|
512
|
+
// the lateral keys landed between the ranked entries and the appended ones and
|
|
513
|
+
// `navigable` stopped leading with `focusOrder`, which the scene contract
|
|
514
|
+
// requires and `graph.test.ts` asserts. Anything derived from the ORDER has to
|
|
515
|
+
// come after a pass that changes the order.
|
|
516
|
+
const navigableKeys = [...focusOrder];
|
|
517
|
+
for (const key of lateral.keys()) {
|
|
518
|
+
if (!navigableKeys.includes(key))
|
|
519
|
+
navigableKeys.push(key);
|
|
520
|
+
}
|
|
521
|
+
// One rule, shared with `reconcile` and the other projections, so the element
|
|
522
|
+
// that renders `tabindex="0"` and the state a host reads cannot disagree.
|
|
523
|
+
const navigable = {
|
|
524
|
+
keys: navigableKeys,
|
|
525
|
+
focused: resolveFocusKey(navigableKeys, options.focused, options.selected),
|
|
526
|
+
railed,
|
|
527
|
+
};
|
|
528
|
+
const diagnostics = [];
|
|
529
|
+
let canvas;
|
|
530
|
+
if (nodeCount === 0) {
|
|
531
|
+
canvas = emptyState('No issue in this document declares a relationship, so the canvas is empty.');
|
|
532
|
+
}
|
|
533
|
+
else if (nodeCount > CLUSTER_ONLY_BUDGET) {
|
|
534
|
+
diagnostics.push(`graph refused: ${String(nodeCount)} nodes is past the cluster-only budget of ${String(CLUSTER_ONLY_BUDGET)}`);
|
|
535
|
+
canvas = refusal(document, layout, nodeCount, 'clusters');
|
|
536
|
+
}
|
|
537
|
+
else if (nodeCount > GRAPH_NODE_BUDGET) {
|
|
538
|
+
diagnostics.push(`graph refused: ${String(nodeCount)} nodes is past the node budget of ${String(GRAPH_NODE_BUDGET)}`);
|
|
539
|
+
canvas = refusal(document, layout, nodeCount, 'capsules');
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
const edgeLayers = [];
|
|
543
|
+
for (const edge of document.edges) {
|
|
544
|
+
// `together-with` is drawn as an enclosure plus its connector, not as an
|
|
545
|
+
// arc: it shares a rank rather than ordering anything.
|
|
546
|
+
if (edge.field === 'together-with')
|
|
547
|
+
continue;
|
|
548
|
+
const geometry = edgeGeometry(layout, edge);
|
|
549
|
+
if (geometry === null)
|
|
550
|
+
continue;
|
|
551
|
+
edgeLayers.push(...edgePaths(edge, geometry, theme));
|
|
552
|
+
const marker = terminalMarker(treatmentFor(edge.field).terminal, geometry, edge.field, theme);
|
|
553
|
+
if (marker !== null)
|
|
554
|
+
edgeLayers.push(marker);
|
|
555
|
+
}
|
|
556
|
+
// The one declared seam crossing: the connector lives in this layer,
|
|
557
|
+
// because a click target cannot be added from outside without the viewer
|
|
558
|
+
// knowing where members are.
|
|
559
|
+
const enclosures = [];
|
|
560
|
+
for (const [lead, members] of layout.slotMembers) {
|
|
561
|
+
const bounds = enclosureBounds(layout, members, theme);
|
|
562
|
+
if (bounds === null)
|
|
563
|
+
continue;
|
|
564
|
+
enclosures.push(
|
|
565
|
+
// `data-ig-GROUP`, not `data-ig-key`. The enclosure is painted BEFORE
|
|
566
|
+
// the nodes so it sits behind them, and `mountViewer` indexes the first
|
|
567
|
+
// element it sees for a key — so sharing the key made keyboard movement
|
|
568
|
+
// to a canvas-owned lead call `focus()` on this non-tabbable rect
|
|
569
|
+
// instead of its `<g tabindex="0">`. Decoration does not compete with
|
|
570
|
+
// the thing it decorates for an identity.
|
|
571
|
+
svg('rect', {
|
|
572
|
+
class: 'ig-enclosure',
|
|
573
|
+
'data-ig-group': lead,
|
|
574
|
+
'stroke-dasharray': dashArrayFor('enclosure'),
|
|
575
|
+
x: bounds.x,
|
|
576
|
+
y: bounds.y,
|
|
577
|
+
width: bounds.width,
|
|
578
|
+
height: bounds.height,
|
|
579
|
+
rx: theme.metrics['--ig-radius'],
|
|
580
|
+
role: 'img',
|
|
581
|
+
'aria-label': `${members.join(' and ')} share one rank`,
|
|
582
|
+
}));
|
|
583
|
+
for (let index = 1; index < members.length; index += 1) {
|
|
584
|
+
const previous = layout.nodes.get(members[index - 1]);
|
|
585
|
+
const current = layout.nodes.get(members[index]);
|
|
586
|
+
if (previous === undefined || current === undefined)
|
|
587
|
+
continue;
|
|
588
|
+
enclosures.push(svg('line', {
|
|
589
|
+
class: 'ig-connector',
|
|
590
|
+
'data-ig-group': lead,
|
|
591
|
+
x1: previous.x + previous.width / 2,
|
|
592
|
+
y1: previous.y + previous.height,
|
|
593
|
+
x2: current.x + current.width / 2,
|
|
594
|
+
y2: current.y,
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const nodeShapes = [...layout.nodes.keys()]
|
|
599
|
+
.map((key) => nodeShape(document, layout, key, options, theme, navigable))
|
|
600
|
+
.filter((node) => node !== null);
|
|
601
|
+
canvas = svg('svg', {
|
|
602
|
+
class: 'ig-canvas',
|
|
603
|
+
viewBox: `0 0 ${String(Math.round(layout.width))} ${String(Math.round(layout.height))}`,
|
|
604
|
+
// `role="img"` FLATTENS every descendant into a single image, which
|
|
605
|
+
// would hide the very node roles and labels that make gutter and
|
|
606
|
+
// held nodes reachable. A container holding separately focusable
|
|
607
|
+
// semantic children is a group, not a picture.
|
|
608
|
+
role: 'group',
|
|
609
|
+
'aria-label': `${String(nodeCount)} issues and ${String(document.edges.length)} relationships`,
|
|
610
|
+
}, [...enclosures, ...edgeLayers, ...nodeShapes]);
|
|
611
|
+
}
|
|
612
|
+
const root = element('section', { class: 'ig-viewer ig-graph', 'data-projection': 'graph', 'aria-label': 'issue order and relationships' }, [
|
|
613
|
+
legend(),
|
|
614
|
+
// ONE STAGE, sized in the layout's own units, so an absolutely-positioned
|
|
615
|
+
// rail row and an SVG coordinate mean the same thing. A percentage-width
|
|
616
|
+
// canvas would rescale under the rail and the two would drift apart.
|
|
617
|
+
// A refusal draws no nodes, so it needs no stage and the rail stays in
|
|
618
|
+
// ordinary flow — a fixed-height stage would clip it.
|
|
619
|
+
refused
|
|
620
|
+
? canvas
|
|
621
|
+
: element('div', {
|
|
622
|
+
class: 'ig-stage',
|
|
623
|
+
style: `--ig-stage-w:${String(Math.round(layout.width))}px;--ig-stage-h:${String(Math.round(layout.height))}px`,
|
|
624
|
+
}, [canvas, spineRail(document, layout, options, navigable.focused, true)]),
|
|
625
|
+
refused ? spineRail(document, layout, options, navigable.focused, false) : null,
|
|
626
|
+
document.isolated.length === 0
|
|
627
|
+
? null
|
|
628
|
+
: element('p', { class: 'ig-count' }, [
|
|
629
|
+
`${String(document.isolated.length)} isolated ${document.isolated.length === 1 ? 'issue' : 'issues'} not drawn`,
|
|
630
|
+
]),
|
|
631
|
+
]);
|
|
632
|
+
return {
|
|
633
|
+
projection: 'graph',
|
|
634
|
+
root,
|
|
635
|
+
focusOrder,
|
|
636
|
+
navigable: navigableKeys,
|
|
637
|
+
lateral,
|
|
638
|
+
// The same stations the canvas keys its member nodes to through
|
|
639
|
+
// `GROUP_ATTRIBUTE` — one rule, so the markup and the published state agree.
|
|
640
|
+
stationOf: stations,
|
|
641
|
+
diagnostics,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
//# sourceMappingURL=graph.js.map
|