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