@issuegraph/viewer 0.2.0 → 0.5.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/README.md +39 -2
- package/dist/document.d.ts +286 -3
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +247 -1
- package/dist/document.js.map +1 -1
- package/dist/element.d.ts +11 -0
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -4
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +51 -50
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +364 -168
- package/dist/layout.js.map +1 -1
- package/dist/mount.d.ts +13 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +94 -3
- package/dist/mount.js.map +1 -1
- package/dist/navigation.d.ts +14 -0
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +20 -0
- package/dist/navigation.js.map +1 -1
- package/dist/parts.d.ts +338 -1
- package/dist/parts.d.ts.map +1 -1
- package/dist/parts.js +899 -30
- package/dist/parts.js.map +1 -1
- package/dist/projections/graph.d.ts +10 -0
- package/dist/projections/graph.d.ts.map +1 -1
- package/dist/projections/graph.js +413 -311
- package/dist/projections/graph.js.map +1 -1
- package/dist/projections/linear.d.ts +90 -0
- package/dist/projections/linear.d.ts.map +1 -1
- package/dist/projections/linear.js +220 -28
- package/dist/projections/linear.js.map +1 -1
- package/dist/projections/tree.d.ts.map +1 -1
- package/dist/projections/tree.js +29 -3
- package/dist/projections/tree.js.map +1 -1
- package/dist/styles.d.ts +10 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +825 -68
- package/dist/styles.js.map +1 -1
- package/dist/theme.d.ts +41 -10
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +265 -11
- package/dist/theme.js.map +1 -1
- package/dist/vocabulary.d.ts +39 -0
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +27 -0
- package/dist/vocabulary.js.map +1 -1
- package/package.json +2 -2
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
import { edgeIdentity } from '@issuegraph/core';
|
|
17
17
|
import { clustersOf } from "../clusters.js";
|
|
18
18
|
import { element, svg } from "../element.js";
|
|
19
|
-
import { edgeGeometry,
|
|
20
|
-
import { emptyState, legend,
|
|
19
|
+
import { edgeGeometry, layoutGraph, } from "../layout.js";
|
|
20
|
+
import { adoptionNote, atStations, cardBlocks, caveatBadges, conditionKind, conditionNotice, footerLabels, nowRows, caveatText, edgeBadgeList, emptyState, evidenceBadge, hostHeader, statesItsOwnCause, identity, legend, notReadyBadge, priorityBadge, slotLabel, stationFill, stationsOf, unitBlock, } from "../parts.js";
|
|
21
21
|
import { resolveFocusKey } from "../scene.js";
|
|
22
22
|
import { resolveTheme } from "../theme.js";
|
|
23
23
|
import { dashArrayFor, treatmentFor } from "../vocabulary.js";
|
|
24
|
-
import { excludedRow, isFooterSlot } from "./linear.js";
|
|
24
|
+
import { asideRow, excludedRow, footerHeading, footerRow, isFooterSlot, slotRow, } from "./linear.js";
|
|
25
25
|
/**
|
|
26
26
|
* The node budget, from the design's scale table. Above the first threshold the
|
|
27
27
|
* canvas shows component capsules; above the second, clusters only.
|
|
@@ -127,231 +127,204 @@ function edgePaths(edge, geometry, theme) {
|
|
|
127
127
|
return [svg('path', base)];
|
|
128
128
|
}
|
|
129
129
|
/**
|
|
130
|
-
* One node on the
|
|
130
|
+
* One node, drawn as an HTML card positioned on the coordinates the layout gave
|
|
131
|
+
* it.
|
|
131
132
|
*
|
|
132
|
-
* A
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
133
|
+
* A CARD, NOT A LABELLED RECTANGLE, and that is the change this pass exists to
|
|
134
|
+
* make. Drawing a node's text in SVG means the text neither wraps nor clips, so
|
|
135
|
+
* every title had to be run through a width fit and came out truncated — the
|
|
136
|
+
* "Retype the ca…" the issue names. §16b's node is three lines deep: a title
|
|
137
|
+
* that wraps, an identity, and a badge row, and none of that is expressible as
|
|
138
|
+
* one centred `<text>`.
|
|
137
139
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
140
|
+
* IT ALSO SETTLES WHERE FOCUS LIVES. There used to be two kinds of node — a
|
|
141
|
+
* rail row for a ranked slot and an SVG group for everything else — and three
|
|
142
|
+
* rounds of review found the same class of defect at the seam between them: a
|
|
143
|
+
* key published as a navigation target with no focusable element behind it. One
|
|
144
|
+
* kind of node cannot have that seam.
|
|
141
145
|
*/
|
|
142
|
-
function
|
|
146
|
+
function nodeCard(document, layout, key, options, focused) {
|
|
143
147
|
const box = layout.nodes.get(key);
|
|
144
148
|
if (box === undefined)
|
|
145
149
|
return null;
|
|
146
150
|
const issue = document.byKey.get(key);
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// key is the other repair codex offered and it is the one round ten already
|
|
181
|
-
// rejected: it splits the unit into two stations, which `navigation.test.ts`
|
|
182
|
-
// forbids in as many words. So the member keeps its node and loses only its
|
|
183
|
-
// FOCUS identity, which it never legitimately had.
|
|
184
|
-
// `GROUP_ATTRIBUTE` IS EXACTLY THIS CHANNEL — the enclosure and the connector
|
|
185
|
-
// already answer a pointer with their unit's lead through it, and a member's
|
|
186
|
-
// node is the same question about the same unit. `keyAt` reads it as the
|
|
187
|
-
// fallback the focus index never sees.
|
|
188
|
-
// A KEY NO SLOT REPRESENTS FALLS BACK TO ITSELF, which is no worse than what
|
|
189
|
-
// it published before; the orphan pass above is what makes such a key
|
|
190
|
-
// navigable, and the invariant test is what proves it did.
|
|
191
|
-
const published = navigable.keys.includes(key);
|
|
192
|
-
const station = document.order.slots.find((slot) => slot.members.includes(key))?.lead ?? key;
|
|
193
|
-
return svg('g', {
|
|
194
|
-
class: 'ig-node-group',
|
|
195
|
-
'data-ig-key': published ? key : null,
|
|
196
|
-
'data-ig-group': published ? null : station,
|
|
197
|
-
'data-column': box.column,
|
|
198
|
-
'aria-current': selected ? 'true' : 'false',
|
|
199
|
-
role: ownsTabStop ? 'img' : null,
|
|
200
|
-
// THE REASON RIDES THE NAME WHEN THERE IS ONE, on the same channels the
|
|
201
|
-
// rail row uses, so one hold reads the same whichever surface drew it.
|
|
202
|
-
'aria-label': ownsTabStop
|
|
203
|
-
? heldBecause === ''
|
|
204
|
-
? `${full} — ${key}`
|
|
205
|
-
: `${full} — ${key} — ${heldBecause}`
|
|
206
|
-
: null,
|
|
207
|
-
tabindex: ownsTabStop ? (navigable.focused === key ? 0 : -1) : null,
|
|
151
|
+
const slot = document.order.slots.find((candidate) => candidate.lead === key);
|
|
152
|
+
const members = layout.slotMembers.get(key) ?? [key];
|
|
153
|
+
const exclusion = document.order.excluded.find((candidate) => candidate.key === key);
|
|
154
|
+
const caveats = caveatText(issue);
|
|
155
|
+
const heldBecause = [
|
|
156
|
+
...(slot?.holds ?? []).map((hold) => hold.reason),
|
|
157
|
+
...(caveats === '' ? [] : [caveats]),
|
|
158
|
+
...(exclusion === undefined
|
|
159
|
+
? []
|
|
160
|
+
: [`${treatmentFor('duplicate-of').label} ${exclusion.canonical} — never worked`]),
|
|
161
|
+
].join(' · ');
|
|
162
|
+
// RENDERED FROM THE CARD'S OWN DESCRIPTION, which is also what the layout
|
|
163
|
+
// counted this card's height from — see `CardBlock`. Built from a second
|
|
164
|
+
// reading of what the card should hold, four blocks in a row were added to
|
|
165
|
+
// the drawing and not to the count, and every one of them overran the box the
|
|
166
|
+
// arcs were anchored to.
|
|
167
|
+
const drawn = cardBlocks(document, key, members, box.column === 'spine', box.now === true).map((block) => cardMark(document, block, key, members, slot));
|
|
168
|
+
const label = [
|
|
169
|
+
slot === undefined ? (issue?.title ?? key) : slotLabel(document, slot),
|
|
170
|
+
heldBecause === '' ? null : heldBecause,
|
|
171
|
+
]
|
|
172
|
+
.filter((part) => part !== null)
|
|
173
|
+
.join(' — ');
|
|
174
|
+
return element('li', {
|
|
175
|
+
class: 'ig-rail-row',
|
|
176
|
+
'data-ig-key': key,
|
|
177
|
+
'aria-current': options.selected === key ? 'true' : 'false',
|
|
178
|
+
'aria-label': label,
|
|
179
|
+
title: heldBecause === '' ? null : heldBecause,
|
|
180
|
+
tabindex: focused === key ? 0 : -1,
|
|
181
|
+
// Positioned from the layout, not from the flow, so a card sits where its
|
|
182
|
+
// edges terminate however the theme scales the geometry.
|
|
183
|
+
style: `--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`,
|
|
208
184
|
}, [
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
heldBecause === '' ? null : svg('title', {}, [`${full} — ${heldBecause}`]),
|
|
213
|
-
svg('rect', {
|
|
214
|
-
class: 'ig-node',
|
|
185
|
+
element('div', {
|
|
186
|
+
class: 'ig-card',
|
|
187
|
+
'data-column': box.column,
|
|
215
188
|
'data-held': box.held ? 'true' : 'false',
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
rx: theme.metrics['--ig-radius'],
|
|
221
|
-
}),
|
|
222
|
-
// A node is labelled by its RAIL ROW when it has one, so labelling it
|
|
223
|
-
// here too would print the title twice — once selectable, once not. But
|
|
224
|
-
// the rail draws only the ranked slots, and keying this on the COLUMN
|
|
225
|
-
// rather than on the rail left a tracker-held slot as a blank rectangle:
|
|
226
|
-
// no title, no hold reason, nothing. The question is whether this key is
|
|
227
|
-
// railed, not which column it sits in.
|
|
228
|
-
navigable.railed.has(key)
|
|
229
|
-
? null
|
|
230
|
-
: svg('text', {
|
|
231
|
-
class: 'ig-node-label',
|
|
232
|
-
x: box.x + theme.metrics['--ig-space'],
|
|
233
|
-
// `dominant-baseline` centres the glyphs on the line rather than
|
|
234
|
-
// a remembered offset, so the label stays centred at any scale.
|
|
235
|
-
y: box.y + box.height / 2,
|
|
236
|
-
'dominant-baseline': 'middle',
|
|
237
|
-
}, [
|
|
238
|
-
// THE FULL TITLE IS NOT LOST WHEN IT DOES NOT FIT — and it has to
|
|
239
|
-
// be a CHILD ELEMENT to do that job. This was written as a `title`
|
|
240
|
-
// ATTRIBUTE, which SVG ignores entirely: no tooltip, no accessible
|
|
241
|
-
// description, nothing. The markup contained the string, so a test
|
|
242
|
-
// asserting the full title was "somewhere in the markup" passed
|
|
243
|
-
// while a reader hovering the shortened label recovered nothing.
|
|
244
|
-
// FIRST CHILD, because that is where SVG looks for it, and only on
|
|
245
|
-
// truncation — an untruncated label already reads in full, and a
|
|
246
|
-
// `<title>` echoing it would announce it twice.
|
|
247
|
-
// A railed node never reaches here at all: the rail row is its
|
|
248
|
-
// label, and its own CSS ellipsis handles the same overflow.
|
|
249
|
-
drawn === full ? null : svg('title', {}, [full]),
|
|
250
|
-
drawn,
|
|
251
|
-
]),
|
|
189
|
+
'data-now': box.now === true ? 'true' : 'false',
|
|
190
|
+
'data-unit': members.length > 1 ? 'true' : 'false',
|
|
191
|
+
'data-exclusion': exclusion === undefined ? null : 'true',
|
|
192
|
+
}, drawn),
|
|
252
193
|
]);
|
|
253
194
|
}
|
|
195
|
+
/** One described block, drawn. The other half of `cardBlocks`. */
|
|
196
|
+
function cardMark(document, block, key, members, slot) {
|
|
197
|
+
switch (block.kind) {
|
|
198
|
+
case 'banner':
|
|
199
|
+
return element('div', { class: 'ig-unit-mark' }, [
|
|
200
|
+
element('span', { class: block.mark === 'now' ? 'ig-now-mark' : 'ig-unit-pill' }, [block.mark]),
|
|
201
|
+
block.note === '' ? null : element('span', { class: 'ig-unit-note' }, [block.note]),
|
|
202
|
+
]);
|
|
203
|
+
case 'head': {
|
|
204
|
+
const issue = document.byKey.get(block.key);
|
|
205
|
+
return element('div', { class: 'ig-row-head' }, [
|
|
206
|
+
element('span', { class: 'ig-title' }, [issue?.title ?? block.key]),
|
|
207
|
+
issue === undefined ? null : identity(issue),
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
case 'unit':
|
|
211
|
+
// Through `unitBlock`, so the enclosure and its deep links are the ones
|
|
212
|
+
// the list draws rather than a second spelling of them.
|
|
213
|
+
return slot === undefined
|
|
214
|
+
? element('div', { class: 'ig-row-head' }, [])
|
|
215
|
+
: (unitBlock(document, slot) ?? element('div', { class: 'ig-row-head' }, []));
|
|
216
|
+
case 'badges':
|
|
217
|
+
// THE BADGE ROW IS THE SAME ONE THE LIST DRAWS, which is what makes the
|
|
218
|
+
// two projections one grammar rather than two spellings. A gutter card
|
|
219
|
+
// carries it too: it is the only mark this projection draws for its
|
|
220
|
+
// issue, so a relation left off it is one the graph reader never sees.
|
|
221
|
+
return element('div', { class: 'ig-badges' },
|
|
222
|
+
// FROM THE SAME BUILDERS `badgeTexts` READS, so what the layout packed
|
|
223
|
+
// and what the card draws are one list of chips rather than two that
|
|
224
|
+
// can differ by one.
|
|
225
|
+
[
|
|
226
|
+
priorityBadge(document.byKey.get(key)?.provenance),
|
|
227
|
+
evidenceBadge(document.byKey.get(key)),
|
|
228
|
+
slot === undefined ? null : notReadyBadge(slot),
|
|
229
|
+
...caveatBadges(document.byKey.get(key)),
|
|
230
|
+
...edgeBadgeList(document, members),
|
|
231
|
+
].filter((badge) => badge !== null));
|
|
232
|
+
case 'note':
|
|
233
|
+
// A SPINE CARD KEEPS ITS SENTENCES ON THE TOOLTIP, because the list
|
|
234
|
+
// projection prints them and §16b's spine card draws a chip. A gutter card
|
|
235
|
+
// is the only mark this projection makes for its issue, so its sentence
|
|
236
|
+
// has nowhere else to be — and §16b prints it there.
|
|
237
|
+
return element('p', { class: 'ig-hold', 'data-family': 'tracker' }, [
|
|
238
|
+
element('span', {}, [block.text]),
|
|
239
|
+
]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
254
242
|
/**
|
|
255
|
-
* The spine
|
|
256
|
-
*
|
|
257
|
-
* Text in SVG is not selectable, not reflowable and announces poorly, so the
|
|
258
|
-
* spine's rows stay HTML — but they are the labels FOR the spine nodes, and
|
|
259
|
-
* emitting them as a sibling block put them above the drawing instead of on it.
|
|
260
|
-
* Ranks and stations then described a picture the reader had to hold in their
|
|
261
|
-
* head, which is the opposite of the design's claim that the spine IS the
|
|
262
|
-
* order.
|
|
243
|
+
* The spine: one line, and one station per card on it.
|
|
263
244
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
245
|
+
* THE LINE IS THE POINT. §16c's whole argument for this layout is that sequence
|
|
246
|
+
* gets a channel of its own — vertical position on a single line — so that
|
|
247
|
+
* nothing has to encode sequence and dependency at once. Without the line drawn
|
|
248
|
+
* there is no single line to read down, and the picture degrades into the
|
|
249
|
+
* column of rectangles the alternatives were rejected for.
|
|
268
250
|
*/
|
|
251
|
+
function spineStations(document, layout, theme) {
|
|
252
|
+
const size = theme.metrics['--ig-station-box'];
|
|
253
|
+
const stations = [];
|
|
254
|
+
for (const key of layout.spineOrder) {
|
|
255
|
+
const box = layout.nodes.get(key);
|
|
256
|
+
if (box === undefined)
|
|
257
|
+
continue;
|
|
258
|
+
const slot = document.order.slots.find((candidate) => candidate.lead === key);
|
|
259
|
+
const fill = slot === undefined ? 'filled' : stationFill(slot);
|
|
260
|
+
// A serialize hold waits on a PEER rather than on a rank above it, and the
|
|
261
|
+
// frame colours that station differently for exactly that reason.
|
|
262
|
+
const waiting = slot?.holds.some((hold) => hold.code === 'serialized') === true;
|
|
263
|
+
stations.push(element('span', {
|
|
264
|
+
class: 'ig-spine-station',
|
|
265
|
+
'data-fill': box.now === true ? 'filled' : fill,
|
|
266
|
+
'data-wait': waiting ? 'serialize' : null,
|
|
267
|
+
role: 'img',
|
|
268
|
+
'aria-label': box.now === true
|
|
269
|
+
? 'working now'
|
|
270
|
+
: slot === undefined || slot.rank === null
|
|
271
|
+
? 'held, no rank'
|
|
272
|
+
: `rank ${String(slot.rank)}`,
|
|
273
|
+
style: `--ig-station-x:${String(box.x - theme.metrics['--ig-space'] - size)}px;--ig-station-y:${String(box.y + theme.metrics['--ig-space'])}px`,
|
|
274
|
+
}, [box.now === true ? '▸' : slot?.rank === null || slot === undefined ? '—' : String(slot.rank)]));
|
|
275
|
+
}
|
|
276
|
+
return stations;
|
|
277
|
+
}
|
|
278
|
+
/** The three column headings, set at the x of the column each names. */
|
|
279
|
+
function columnHeads(layout) {
|
|
280
|
+
const heads = [
|
|
281
|
+
['left', 'Explains the order'],
|
|
282
|
+
['spine', 'The work order ↓'],
|
|
283
|
+
['right', 'Not worked'],
|
|
284
|
+
];
|
|
285
|
+
const drawn = new Set();
|
|
286
|
+
for (const box of layout.nodes.values())
|
|
287
|
+
drawn.add(box.column);
|
|
288
|
+
return heads
|
|
289
|
+
.filter(([column]) => drawn.has(column))
|
|
290
|
+
.map(([column, text]) => element('span', {
|
|
291
|
+
class: 'ig-column-head',
|
|
292
|
+
'data-column': column,
|
|
293
|
+
style: `--ig-col-x:${String(layout.columnX[column])}px`,
|
|
294
|
+
}, [text]));
|
|
295
|
+
}
|
|
269
296
|
/**
|
|
270
|
-
* What the
|
|
297
|
+
* What the ORDER UI draws when the canvas will not.
|
|
271
298
|
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* reintroduced by fixing its sibling. Deriving both from here is what makes the
|
|
278
|
-
* two provably agree rather than agree by inspection.
|
|
299
|
+
* A refusal's list is the whole order UI — the refusal's own last sentence
|
|
300
|
+
* promises "the order list is complete at any size" — so it carries the footer
|
|
301
|
+
* slots and the exclusions the canvas would otherwise have drawn. With a
|
|
302
|
+
* canvas, every one of those is a card, so the list carries nothing and this
|
|
303
|
+
* answers empty.
|
|
279
304
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
305
|
+
* ONE RULE, TWO READERS. This decides what renders AND what the focus index
|
|
306
|
+
* publishes; declaring them separately is how a keyed row with nothing to focus
|
|
307
|
+
* kept coming back.
|
|
283
308
|
*/
|
|
284
|
-
function
|
|
285
|
-
return
|
|
286
|
-
? { slots: document.order.slots
|
|
287
|
-
: { slots:
|
|
309
|
+
function refusalContents(document, refused) {
|
|
310
|
+
return refused
|
|
311
|
+
? { slots: document.order.slots, excluded: document.order.excluded }
|
|
312
|
+
: { slots: [], excluded: [] };
|
|
288
313
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// A plain list for the reason `linear.ts` gives: an interactive descendant
|
|
304
|
-
// may not live inside `role="option"`.
|
|
305
|
-
// WHEN THERE IS NO CANVAS THERE IS NOTHING TO SIT ON. A refusal draws no
|
|
306
|
-
// spine nodes, so the rail returns to ordinary flow rather than positioning
|
|
307
|
-
// itself against coordinates nothing rendered.
|
|
308
|
-
{ class: positioned ? 'ig-list ig-rail' : 'ig-list', 'aria-label': 'work order' }, [
|
|
309
|
-
...slots.map((slot) => {
|
|
310
|
-
const box = layout.nodes.get(slot.lead);
|
|
311
|
-
// THE REASON A SLOT IS HELD IS PART OF WHAT THIS ROW MEANS. `ViewerHold`
|
|
312
|
-
// says the viewer renders the reason verbatim, and the linear projection
|
|
313
|
-
// does — this one rendered rank, station and title, so a graph reader was
|
|
314
|
-
// told THAT a slot is held and never WHY. `data-held` styles the row, so
|
|
315
|
-
// the holding was visible and the host's sentence was not, on either the
|
|
316
|
-
// visible or the accessible channel.
|
|
317
|
-
// ON THE LABEL AND THE TOOLTIP, NOT AS A BLOCK IN THE ROW. This row is
|
|
318
|
-
// positioned onto its node's box — `--ig-row-h` IS the node height — so a
|
|
319
|
-
// paragraph per hold would overflow geometry the layout computed for a
|
|
320
|
-
// node, which is a worse defect than the one being fixed. `title` plus
|
|
321
|
-
// `aria-label` is how this package already carries text that cannot take
|
|
322
|
-
// space (see the edge badges): sighted readers hover, screen readers hear
|
|
323
|
-
// it, and the layout is untouched.
|
|
324
|
-
// ONLY HERE, not in `slotLabel`. That helper is shared with the linear
|
|
325
|
-
// projection, which renders the same holds as visible paragraphs — adding
|
|
326
|
-
// them there would announce every linear hold twice.
|
|
327
|
-
const heldBecause = slot.holds.map((hold) => hold.reason).join(' · ');
|
|
328
|
-
return element('li', {
|
|
329
|
-
class: positioned ? 'ig-slot ig-rail-row' : 'ig-slot',
|
|
330
|
-
'data-ig-key': slot.lead,
|
|
331
|
-
'data-held': slot.ready ? 'false' : 'true',
|
|
332
|
-
'aria-current': options.selected === slot.lead ? 'true' : 'false',
|
|
333
|
-
'aria-label': heldBecause === ''
|
|
334
|
-
? slotLabel(document, slot)
|
|
335
|
-
: `${slotLabel(document, slot)} — ${heldBecause}`,
|
|
336
|
-
title: heldBecause === '' ? null : heldBecause,
|
|
337
|
-
tabindex: focused === slot.lead ? 0 : -1,
|
|
338
|
-
// Positioned from the layout, not from the flow, so a row sits on the
|
|
339
|
-
// node it names however the theme scales the geometry.
|
|
340
|
-
style: positioned && box !== undefined
|
|
341
|
-
? `--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`
|
|
342
|
-
: null,
|
|
343
|
-
}, [
|
|
344
|
-
element('span', { class: 'ig-rank', 'data-held': slot.ready ? 'false' : 'true', 'aria-hidden': 'true' }, [slot.rank === null ? '—' : String(slot.rank)]),
|
|
345
|
-
station(stationFill(slot)),
|
|
346
|
-
element('span', { class: 'ig-title' }, [slotTitle(document, slot)]),
|
|
347
|
-
]);
|
|
348
|
-
}),
|
|
349
|
-
// AND THE EXCLUSIONS, on the same terms: they have no canvas node in any
|
|
350
|
-
// mode, so ordinary graph mode simply never showed them — which is correct
|
|
351
|
-
// there, because the spine rail sits ON the canvas and an exclusion is not
|
|
352
|
-
// on it. In a refusal there is no canvas, the rail is the entire order, and
|
|
353
|
-
// an exclusion left out is a row of the order that is missing.
|
|
354
|
-
...excluded.map((exclusion) => excludedRow(document, exclusion.key, exclusion.canonical, { ...options, focused })),
|
|
314
|
+
/**
|
|
315
|
+
* The order as a list, for a document the canvas refuses.
|
|
316
|
+
*
|
|
317
|
+
* IT IS THE LINEAR PROJECTION'S OWN ROW, not a second spelling of it. The rail
|
|
318
|
+
* that shipped had its own three-child row — rank, station, title — which drew
|
|
319
|
+
* neither the badges nor the provenance the list draws, so a refused document
|
|
320
|
+
* silently lost the explanation the unrefused one carried.
|
|
321
|
+
*/
|
|
322
|
+
function refusalOrder(document, options, focused) {
|
|
323
|
+
const { slots, excluded } = refusalContents(document, true);
|
|
324
|
+
const withFocus = { ...options, focused };
|
|
325
|
+
return element('ol', { class: 'ig-list', 'aria-label': 'work order' }, [
|
|
326
|
+
...slots.map((slot) => slotRow(document, slot, withFocus, true)),
|
|
327
|
+
...excluded.map((exclusion) => excludedRow(document, exclusion.key, exclusion.canonical, withFocus)),
|
|
355
328
|
]);
|
|
356
329
|
}
|
|
357
330
|
/**
|
|
@@ -422,12 +395,71 @@ function refusal(document, layout, nodeCount, mode) {
|
|
|
422
395
|
]),
|
|
423
396
|
]);
|
|
424
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* The runner-held slots the canvas draws nowhere, as §16a's footer group.
|
|
400
|
+
*
|
|
401
|
+
* THE GRAPH AND THE LIST MUST SHOW THE SAME ISSUES. A projection change is a
|
|
402
|
+
* change of representation, never of subject — so an issue the list carries in
|
|
403
|
+
* its footer cannot simply be absent from the graph, whatever the canvas has
|
|
404
|
+
* room for.
|
|
405
|
+
*/
|
|
406
|
+
function footerGroup(document, layout, options, focused) {
|
|
407
|
+
const entries = footerKeys(document, layout, { ...options, focused });
|
|
408
|
+
if (entries.length === 0)
|
|
409
|
+
return null;
|
|
410
|
+
const slots = document.order.slots.filter((slot) => layout.footer.includes(slot.lead));
|
|
411
|
+
const excluded = document.order.excluded.filter((exclusion) => layout.footer.includes(exclusion.key));
|
|
412
|
+
// WHAT THIS GROUP ACTUALLY HOLDS. In the column it also holds every gutter
|
|
413
|
+
// endpoint the compact picture does not draw — an ordinary open blocker, say,
|
|
414
|
+
// which no runner holds and which is worked like anything else — so a heading
|
|
415
|
+
// that named only the runner and the never-worked said something untrue about
|
|
416
|
+
// them.
|
|
417
|
+
const undrawn = entries.length > slots.length + excluded.length;
|
|
418
|
+
const labels = footerLabels(slots);
|
|
419
|
+
return element('section', { class: 'ig-footer' }, [
|
|
420
|
+
element('div', { class: 'ig-footer-head' }, [
|
|
421
|
+
element('p', { class: 'ig-footer-title' }, [
|
|
422
|
+
footerHeading(entries.length, {
|
|
423
|
+
runner: slots.length > 0,
|
|
424
|
+
neverWorked: excluded.length > 0,
|
|
425
|
+
undrawn,
|
|
426
|
+
}),
|
|
427
|
+
]),
|
|
428
|
+
labels === '' ? null : element('span', { class: 'ig-footer-labels' }, [labels]),
|
|
429
|
+
]),
|
|
430
|
+
element('ol', { class: 'ig-list', 'aria-label': 'outside the order' }, entries),
|
|
431
|
+
]);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* A row for EVERY key the canvas drew nowhere, in the layout's own order.
|
|
435
|
+
*
|
|
436
|
+
* DERIVED FROM `layout.footer` AND NOTHING ELSE, which is the whole correction.
|
|
437
|
+
* A group built from `order.slots` covered the runner-held ones; adding
|
|
438
|
+
* `order.excluded` covered the duplicates; and an ordinary off-order
|
|
439
|
+
* relationship endpoint — an open blocker, a closed split origin — is neither,
|
|
440
|
+
* so in compact mode, where the gutters are not drawn, it was in
|
|
441
|
+
* `layout.footer` and rendered by nothing at all. Two partial rules chasing one
|
|
442
|
+
* set is how the key that belongs to neither goes missing, so there is one
|
|
443
|
+
* rule: the layout says what it did not draw, and every one of those gets a row.
|
|
444
|
+
*/
|
|
445
|
+
function footerKeys(document, layout, options) {
|
|
446
|
+
return layout.footer.map((key) => {
|
|
447
|
+
const slot = document.order.slots.find((candidate) => candidate.lead === key);
|
|
448
|
+
if (slot !== undefined)
|
|
449
|
+
return footerRow(document, slot, options);
|
|
450
|
+
const exclusion = document.order.excluded.find((candidate) => candidate.key === key);
|
|
451
|
+
if (exclusion !== undefined) {
|
|
452
|
+
return excludedRow(document, key, exclusion.canonical, options);
|
|
453
|
+
}
|
|
454
|
+
return asideRow(document, key, options);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
425
457
|
export function graphScene(document, rawOptions = {}) {
|
|
426
458
|
// See `linearScene` — the same rule, applied before the canvas is laid out.
|
|
427
459
|
const stations = stationsOf(document);
|
|
428
460
|
const options = atStations(rawOptions, stations);
|
|
429
461
|
const theme = resolveTheme(options.theme);
|
|
430
|
-
const layout = layoutGraph(document, theme);
|
|
462
|
+
const layout = layoutGraph(document, theme, options.compact === true);
|
|
431
463
|
const nodeCount = layout.nodes.size;
|
|
432
464
|
const inline = document.order.slots.filter((slot) => !isFooterSlot(slot));
|
|
433
465
|
const footerSlots = document.order.slots.filter(isFooterSlot);
|
|
@@ -445,24 +477,44 @@ export function graphScene(document, rawOptions = {}) {
|
|
|
445
477
|
// and draws NOTHING keyed when it refuses or is empty. Everything downstream
|
|
446
478
|
// is a subset of that, so "every published target is focusable" holds by
|
|
447
479
|
// construction rather than by remembering.
|
|
448
|
-
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
480
|
+
// AN EMPTY NODE MAP IS NOT AN EMPTY PANEL. In the column the gutters are not
|
|
481
|
+
// drawn, so a document whose issues are all off the order — relationships and
|
|
482
|
+
// no ranked or running station — lays out NOTHING and puts every one of them
|
|
483
|
+
// in `layout.footer`. Read as a refusal, that said "no issue in this document
|
|
484
|
+
// declares a relationship" about a document full of them AND suppressed the
|
|
485
|
+
// footer group that was holding all of them, so the panel came back blank.
|
|
486
|
+
// The question is whether this scene draws anything, and the footer draws.
|
|
487
|
+
const drawsNothing = nodeCount === 0 && layout.footer.length === 0;
|
|
488
|
+
const refused = drawsNothing || nodeCount > GRAPH_NODE_BUDGET;
|
|
489
|
+
// FROM THE SAME RULE THE LIST RENDERS FROM — see `refusalContents`. With a
|
|
490
|
+
// canvas every node is a card and the card owns the tab stop; without one,
|
|
491
|
+
// the list is the whole order UI and its rows do.
|
|
492
|
+
const shown = refusalContents(document, refused);
|
|
453
493
|
const railed = new Set([
|
|
454
494
|
...shown.slots.map((slot) => slot.lead),
|
|
455
495
|
...shown.excluded.map((exclusion) => exclusion.key),
|
|
456
496
|
]);
|
|
497
|
+
// THE FOOTER GROUP IS FOCUSABLE TOO, and it is off the canvas: its rows are
|
|
498
|
+
// drawn beneath the stage, so a set derived from the laid-out nodes alone
|
|
499
|
+
// published nothing for them and a keyboard could not reach half the issues
|
|
500
|
+
// the projection draws.
|
|
457
501
|
const focusable = new Set([
|
|
458
502
|
...railed,
|
|
459
|
-
...(refused ? [] : layout.nodes.keys()),
|
|
503
|
+
...(refused ? [] : [...layout.nodes.keys(), ...layout.footer]),
|
|
460
504
|
]);
|
|
505
|
+
// THE SPINE AS THE LAYOUT ORDERED IT, FIRST. §16e fixes that the graph walks
|
|
506
|
+
// its stations in RANK order, and the layout puts the running job at the top
|
|
507
|
+
// — above rank 1, which is where §16b draws it. Rebuilding the order from the
|
|
508
|
+
// slots instead reached that card at its old rank, or near the end when it
|
|
509
|
+
// held no slot, so the keyboard walked the drawing non-monotonically: down
|
|
510
|
+
// the spine, back up to the top, on again.
|
|
511
|
+
const spineFirst = refused ? [] : [...layout.spineOrder];
|
|
461
512
|
const focusOrder = [
|
|
513
|
+
...spineFirst,
|
|
462
514
|
...inline.map((slot) => slot.lead),
|
|
463
515
|
...footerSlots.map((slot) => slot.lead),
|
|
464
516
|
...document.order.excluded.map((exclusion) => exclusion.key),
|
|
465
|
-
].filter((key) => focusable.has(key));
|
|
517
|
+
].filter((key, index, all) => focusable.has(key) && all.indexOf(key) === index);
|
|
466
518
|
// ── the lateral axis: ONLY PAIRS WHOSE REVERSE HOLDS ──────────────────────
|
|
467
519
|
//
|
|
468
520
|
// A one-way mapping is not a traversal — focus went out to a gutter node and
|
|
@@ -486,6 +538,14 @@ export function graphScene(document, rawOptions = {}) {
|
|
|
486
538
|
for (const slot of document.order.slots) {
|
|
487
539
|
if (!focusable.has(slot.lead))
|
|
488
540
|
continue;
|
|
541
|
+
// FROM THE SPINE OUTWARD, and only from there. §16f gives the lateral keys
|
|
542
|
+
// one job: leave the sequence for the gutter card that explains this rank,
|
|
543
|
+
// and come back. A gutter card linked to ANOTHER gutter card is a step that
|
|
544
|
+
// never touches the order at all — and it consumed the one neighbour-per-
|
|
545
|
+
// side each node has, so the reverse link back to the spine could not be
|
|
546
|
+
// written and the traversal stopped being reversible.
|
|
547
|
+
if (layout.nodes.get(slot.lead)?.column !== 'spine')
|
|
548
|
+
continue;
|
|
489
549
|
// Every MEMBER's edges, not just the lead's: a together unit is one station
|
|
490
550
|
// with one focus key, so a gutter neighbour reachable only through its
|
|
491
551
|
// second member would otherwise be unreachable by keyboard entirely.
|
|
@@ -525,7 +585,11 @@ export function graphScene(document, rawOptions = {}) {
|
|
|
525
585
|
for (const member of slot.members)
|
|
526
586
|
represented.add(member);
|
|
527
587
|
if (!refused) {
|
|
528
|
-
|
|
588
|
+
// THE FOOTER GROUP'S KEYS TOO. Its rows are drawn beneath the stage rather
|
|
589
|
+
// than on a column, so a set derived from the laid-out nodes alone reached
|
|
590
|
+
// none of them — and in compact mode, where the gutters are not drawn, that
|
|
591
|
+
// is every off-order endpoint the panel still lists.
|
|
592
|
+
for (const key of [...layout.nodes.keys(), ...layout.footer]) {
|
|
529
593
|
if (!focusOrder.includes(key) && !lateral.has(key) && !represented.has(key))
|
|
530
594
|
focusOrder.push(key);
|
|
531
595
|
}
|
|
@@ -552,9 +616,22 @@ export function graphScene(document, rawOptions = {}) {
|
|
|
552
616
|
};
|
|
553
617
|
const diagnostics = [];
|
|
554
618
|
let canvas;
|
|
555
|
-
if (nodeCount === 0) {
|
|
619
|
+
if (statesItsOwnCause(document) && (drawsNothing || nodeCount === 0)) {
|
|
620
|
+
// THE HOST'S NOTICE IS THE PANEL'S ONE CAUSE STATEMENT — see `linear.ts`.
|
|
621
|
+
// This arm's sentence is the worse of the two to leave standing: it names
|
|
622
|
+
// RELATIONSHIPS, so an unreadable index or a running import would be
|
|
623
|
+
// explained to the reader as a document that declares no edges, which is a
|
|
624
|
+
// claim about something else entirely and may simply be untrue.
|
|
625
|
+
canvas = null;
|
|
626
|
+
}
|
|
627
|
+
else if (drawsNothing) {
|
|
556
628
|
canvas = emptyState('No issue in this document declares a relationship, so the canvas is empty.');
|
|
557
629
|
}
|
|
630
|
+
else if (nodeCount === 0) {
|
|
631
|
+
// Nothing to draw ON the canvas, and a footer group beneath it that is the
|
|
632
|
+
// whole panel. No stage, no refusal: the group renders in ordinary flow.
|
|
633
|
+
canvas = emptyState('Nothing in this document is in the order, so the spine is empty.');
|
|
634
|
+
}
|
|
558
635
|
else if (nodeCount > CLUSTER_ONLY_BUDGET) {
|
|
559
636
|
diagnostics.push(`graph refused: ${String(nodeCount)} nodes is past the cluster-only budget of ${String(CLUSTER_ONLY_BUDGET)}`);
|
|
560
637
|
canvas = refusal(document, layout, nodeCount, 'clusters');
|
|
@@ -565,12 +642,27 @@ export function graphScene(document, rawOptions = {}) {
|
|
|
565
642
|
}
|
|
566
643
|
else {
|
|
567
644
|
const edgeLayers = [];
|
|
645
|
+
// ONE STATION PER SLOT MEANS ONE BOX PER SLOT, so an edge inside a unit has
|
|
646
|
+
// both ends on the same card and no arc to draw. It is not lost: the card
|
|
647
|
+
// lists its members, and the badge row names the relationship, which is the
|
|
648
|
+
// same treatment the list projection gives it. `edgeGeometry` answers null
|
|
649
|
+
// for exactly that case, and it resolves a member to its unit's card for
|
|
650
|
+
// every other — so this loop asks the geometry rather than deciding twice.
|
|
568
651
|
for (const edge of document.edges) {
|
|
569
|
-
//
|
|
570
|
-
// arc
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
652
|
+
// No arcs in the column: §16b says they cannot be drawn legibly there,
|
|
653
|
+
// and an arc drawn illegibly is worse than an arc a reader knows to
|
|
654
|
+
// expand for. The badge row on every card still names the relationship.
|
|
655
|
+
if (layout.compact)
|
|
656
|
+
break;
|
|
657
|
+
// THE ARROW POINTS AT WHAT IS HELD UP, not at what holds it. §16b draws
|
|
658
|
+
// every blocking arc arriving on the blocked card, and it is the reading
|
|
659
|
+
// that answers the question the panel exists for: a reader following an
|
|
660
|
+
// arrowhead is asking "what is waiting on this", and an arrow into the
|
|
661
|
+
// blocker answers the question nobody asked. The DECLARATION is untouched
|
|
662
|
+
// — the identity below is still the field's own orientation, so what a
|
|
663
|
+
// click reports is the edge as written.
|
|
664
|
+
const drawn = edge.field === 'blocked-by' ? { field: edge.field, from: edge.to, to: edge.from } : edge;
|
|
665
|
+
const geometry = edgeGeometry(layout, drawn);
|
|
574
666
|
if (geometry === null)
|
|
575
667
|
continue;
|
|
576
668
|
edgeLayers.push(...edgePaths(edge, geometry, theme));
|
|
@@ -578,108 +670,118 @@ export function graphScene(document, rawOptions = {}) {
|
|
|
578
670
|
if (marker !== null)
|
|
579
671
|
edgeLayers.push(marker);
|
|
580
672
|
}
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
// to a canvas-owned lead call `focus()` on this non-tabbable rect
|
|
594
|
-
// instead of its `<g tabindex="0">`. Decoration does not compete with
|
|
595
|
-
// the thing it decorates for an identity.
|
|
596
|
-
svg('rect', {
|
|
597
|
-
class: 'ig-enclosure',
|
|
598
|
-
'data-ig-group': lead,
|
|
599
|
-
'stroke-dasharray': dashArrayFor('enclosure'),
|
|
600
|
-
x: bounds.x,
|
|
601
|
-
y: bounds.y,
|
|
602
|
-
width: bounds.width,
|
|
603
|
-
height: bounds.height,
|
|
604
|
-
rx: theme.metrics['--ig-radius'],
|
|
605
|
-
role: 'img',
|
|
606
|
-
'aria-label': `${members.join(' and ')} share one rank`,
|
|
607
|
-
}));
|
|
608
|
-
const inUnit = new Set(members);
|
|
609
|
-
for (const edge of document.edges) {
|
|
610
|
-
// ONE CONNECTOR PER DECLARED EDGE, NOT ONE PER ADJACENT PAIR. Walking
|
|
611
|
-
// the member list pairwise assumed a group is written as a chain — but
|
|
612
|
-
// a writer joins one by pointing at ANY existing member (§4.3.7), so
|
|
613
|
-
// `2 together-with 1` and `3 together-with 1` is an ordinary star. That
|
|
614
|
-
// walk drew a `2`–`3` connector for a relationship the document does
|
|
615
|
-
// not contain, so the identity it published resolved to no
|
|
616
|
-
// `StoredEdge`, while the real `1`–`3` edge got no connector at all.
|
|
617
|
-
// Reading the edges themselves cannot invent one and cannot miss one.
|
|
618
|
-
if (edge.field !== 'together-with')
|
|
619
|
-
continue;
|
|
620
|
-
if (!inUnit.has(edge.from) || !inUnit.has(edge.to))
|
|
621
|
-
continue;
|
|
622
|
-
const d = connectorPath(layout, members, edge.from, edge.to, theme);
|
|
623
|
-
if (d === null)
|
|
624
|
-
continue;
|
|
625
|
-
enclosures.push(svg('path', {
|
|
626
|
-
class: 'ig-connector',
|
|
627
|
-
fill: 'none',
|
|
628
|
-
// THE PAIR'S OWN IDENTITY, NOT THE SLOT'S. Every connector in a
|
|
629
|
-
// unit used to carry the lead, so a three-member unit drew two
|
|
630
|
-
// marks a pointer could not tell apart and a click on either named
|
|
631
|
-
// the unit — the one thing an editor cannot delete, retype or
|
|
632
|
-
// report a write against. `edgeIdentity` is the SAME function the
|
|
633
|
-
// store derives `StoredEdge.id` with, so what `onSelect` hands the
|
|
634
|
-
// host is a key `findEdge` resolves rather than a shape it has to
|
|
635
|
-
// be taught.
|
|
636
|
-
//
|
|
637
|
-
// STILL `data-ig-group`, and that is not an oversight about the
|
|
638
|
-
// "addressable via the key scheme" wording. Decoration announces
|
|
639
|
-
// itself on this attribute precisely so it stays OUT of the focus
|
|
640
|
-
// index — one element per key, or `focus()` lands on a
|
|
641
|
-
// non-tabbable mark instead of the node it decorates. `keyAt`
|
|
642
|
-
// reads both attributes for POINTER identity, which is the
|
|
643
|
-
// question a click asks, so the connector is addressable without
|
|
644
|
-
// re-opening that.
|
|
645
|
-
'data-ig-group': edgeIdentity(edge.field, edge.from, edge.to),
|
|
646
|
-
d,
|
|
647
|
-
}));
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
const nodeShapes = [...layout.nodes.keys()]
|
|
651
|
-
.map((key) => nodeShape(document, layout, key, options, theme, navigable))
|
|
652
|
-
.filter((node) => node !== null);
|
|
673
|
+
// THE SPINE ITSELF, painted first so every arc bows off a line that is
|
|
674
|
+
// already there. It spans the stations and no further: a line running the
|
|
675
|
+
// full height of the canvas would imply order where the gutters sit.
|
|
676
|
+
const spine = layout.spineOrder.length === 0
|
|
677
|
+
? null
|
|
678
|
+
: svg('line', {
|
|
679
|
+
class: 'ig-spine',
|
|
680
|
+
x1: layout.spineLineX,
|
|
681
|
+
y1: layout.spineTop,
|
|
682
|
+
x2: layout.spineLineX,
|
|
683
|
+
y2: layout.spineBottom,
|
|
684
|
+
});
|
|
653
685
|
canvas = svg('svg', {
|
|
654
686
|
class: 'ig-canvas',
|
|
655
687
|
viewBox: `0 0 ${String(Math.round(layout.width))} ${String(Math.round(layout.height))}`,
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}, [...enclosures, ...edgeLayers, ...nodeShapes]);
|
|
688
|
+
// The picture is decoration over an HTML rail that carries every node,
|
|
689
|
+
// every name and every tab stop, so it announces nothing of its own.
|
|
690
|
+
// `role="img"` with a label would put a second, flattened description of
|
|
691
|
+
// the same nodes into the accessibility tree.
|
|
692
|
+
'aria-hidden': 'true',
|
|
693
|
+
}, [spine, ...edgeLayers]);
|
|
663
694
|
}
|
|
664
|
-
|
|
665
|
-
|
|
695
|
+
// ONE LIST PER COLUMN, LABELLED THE WAY ITS HEADING IS. Every card in one
|
|
696
|
+
// list called "work order" told a screen reader that the left gutter's
|
|
697
|
+
// explanations and the right gutter's never-worked issues are ordered work —
|
|
698
|
+
// which is the opposite of what those two columns exist to say, and what
|
|
699
|
+
// their own visible headings say instead.
|
|
700
|
+
const columns = [
|
|
701
|
+
['spine', 'work order'],
|
|
702
|
+
['left', 'explains the order'],
|
|
703
|
+
['right', 'not worked'],
|
|
704
|
+
];
|
|
705
|
+
const cardLists = refused
|
|
706
|
+
? []
|
|
707
|
+
: columns
|
|
708
|
+
.map(([column, label]) => {
|
|
709
|
+
const cards = [...layout.nodes.keys()]
|
|
710
|
+
.filter((key) => layout.nodes.get(key)?.column === column)
|
|
711
|
+
.map((key) => nodeCard(document, layout, key, options, navigable.focused))
|
|
712
|
+
.filter((card) => card !== null);
|
|
713
|
+
return cards.length === 0
|
|
714
|
+
? null
|
|
715
|
+
: element('ol', { class: 'ig-list', 'aria-label': label }, cards);
|
|
716
|
+
})
|
|
717
|
+
.filter((list) => list !== null);
|
|
718
|
+
const root = element('section', {
|
|
719
|
+
class: 'ig-viewer ig-graph',
|
|
720
|
+
'data-projection': 'graph',
|
|
721
|
+
'data-ig-condition': conditionKind(document, options.chrome),
|
|
722
|
+
'aria-label': 'issue order and relationships',
|
|
723
|
+
}, [
|
|
724
|
+
options.chrome === false
|
|
725
|
+
? null
|
|
726
|
+
: hostHeader(document, {
|
|
727
|
+
projection: 'graph',
|
|
728
|
+
compact: layout.compact,
|
|
729
|
+
switchable: options.switchable === true,
|
|
730
|
+
}),
|
|
731
|
+
// ABOVE THE STAGE, which is where this projection's order is drawn — the
|
|
732
|
+
// same position relative to the order that the list gives it. It is NOT
|
|
733
|
+
// the list's arrangement: the graph draws its NOW row only when the canvas
|
|
734
|
+
// refuses (otherwise the running job is a station on the spine), so there
|
|
735
|
+
// is usually no NOW row here for the notice to sit under, and in the
|
|
736
|
+
// refused case the band stands in for the spine the notice qualifies.
|
|
737
|
+
// Gated as chrome, for the reason `conditionNotice` states.
|
|
738
|
+
conditionNotice(document, options.chrome),
|
|
666
739
|
// ONE STAGE, sized in the layout's own units, so an absolutely-positioned
|
|
667
|
-
//
|
|
668
|
-
// canvas would rescale under the
|
|
669
|
-
// A refusal draws no nodes, so it needs no stage and the
|
|
740
|
+
// card and an SVG coordinate mean the same thing. A percentage-width
|
|
741
|
+
// canvas would rescale under the cards and the two would drift apart.
|
|
742
|
+
// A refusal draws no nodes, so it needs no stage and the list stays in
|
|
670
743
|
// ordinary flow — a fixed-height stage would clip it.
|
|
671
|
-
|
|
744
|
+
// NO STAGE WITHOUT A NODE TO SIT ON. A stage is sized in the layout's own
|
|
745
|
+
// units, and a layout with no boxes has none — so a document whose issues
|
|
746
|
+
// are all in the footer renders its message and its group in ordinary
|
|
747
|
+
// flow, exactly as a refusal does, without BEING a refusal.
|
|
748
|
+
refused || nodeCount === 0
|
|
672
749
|
? canvas
|
|
673
750
|
: element('div', {
|
|
674
751
|
class: 'ig-stage',
|
|
675
752
|
style: `--ig-stage-w:${String(Math.round(layout.width))}px;--ig-stage-h:${String(Math.round(layout.height))}px`,
|
|
676
|
-
}, [
|
|
677
|
-
|
|
753
|
+
}, [
|
|
754
|
+
canvas,
|
|
755
|
+
element('div', { class: 'ig-rail' }, [
|
|
756
|
+
...columnHeads(layout),
|
|
757
|
+
...cardLists,
|
|
758
|
+
...spineStations(document, layout, theme),
|
|
759
|
+
]),
|
|
760
|
+
]),
|
|
761
|
+
// THE RUNNING JOB SURVIVES A REFUSAL. A refused canvas draws no station,
|
|
762
|
+
// so the one issue the panel exists to say is in flight vanished
|
|
763
|
+
// completely when it held no slot, and was reduced to an ordinary order
|
|
764
|
+
// row — no phase, no elapsed time — when it did. The band is what §16a
|
|
765
|
+
// uses where there is no spine to put a station on, and a refusal is
|
|
766
|
+
// exactly that case.
|
|
767
|
+
refused ? nowRows(document) : null,
|
|
768
|
+
refused ? refusalOrder(document, options, navigable.focused) : null,
|
|
769
|
+
// §16a'S FOOTER GROUP, ON THE GRAPH. A runner-held slot that blocks
|
|
770
|
+
// nothing on the spine has no column to sit in — it is neither the order
|
|
771
|
+
// nor an explanation of it — and drawing it on the spine gave it a
|
|
772
|
+
// position in a sequence it is not part of. So it lands here, in the same
|
|
773
|
+
// one-line-each group the list draws, which is also what keeps the two
|
|
774
|
+
// projections showing the same set of issues.
|
|
775
|
+
refused ? null : footerGroup(document, layout, options, navigable.focused),
|
|
678
776
|
document.isolated.length === 0
|
|
679
777
|
? null
|
|
680
778
|
: element('p', { class: 'ig-count' }, [
|
|
681
779
|
`${String(document.isolated.length)} isolated ${document.isolated.length === 1 ? 'issue' : 'issues'} not drawn`,
|
|
682
780
|
]),
|
|
781
|
+
legend(),
|
|
782
|
+
// LAST, because §16h calls it the panel FOOTER and the legend is a footer
|
|
783
|
+
// bar. Above it, the line was not the last thing on the panel.
|
|
784
|
+
adoptionNote(document, options.chrome),
|
|
683
785
|
]);
|
|
684
786
|
return {
|
|
685
787
|
projection: 'graph',
|