@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
package/dist/layout.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* same document always produces the same coordinates, which is what lets a
|
|
23
23
|
* panel whose job is to be authoritative be trusted between refreshes.
|
|
24
24
|
*/
|
|
25
|
+
import { cardBlocks, cardText } from "./parts.js";
|
|
25
26
|
import { defaultTheme } from "./theme.js";
|
|
26
27
|
/**
|
|
27
28
|
* Custom properties carrying LAYOUT OUTPUT rather than theme input.
|
|
@@ -39,6 +40,14 @@ export const LAYOUT_PROPERTIES = Object.freeze([
|
|
|
39
40
|
'--ig-row-y',
|
|
40
41
|
'--ig-row-w',
|
|
41
42
|
'--ig-row-h',
|
|
43
|
+
// §16b's station column: a disc ON the spine line, to the LEFT of the card it
|
|
44
|
+
// numbers. It was drawn inside the card, which is what made the picture a
|
|
45
|
+
// column of rectangles rather than a spine — the sequence has to be readable
|
|
46
|
+
// off one vertical line, and a number inside a box is not on a line.
|
|
47
|
+
'--ig-station-x',
|
|
48
|
+
'--ig-station-y',
|
|
49
|
+
// Where each of the three column headings sits.
|
|
50
|
+
'--ig-col-x',
|
|
42
51
|
]);
|
|
43
52
|
function metric(theme, token) {
|
|
44
53
|
// FALLS BACK TO THE DEFAULT, AS A BACKSTOP RATHER THAN AS THE FIX.
|
|
@@ -49,15 +58,17 @@ function metric(theme, token) {
|
|
|
49
58
|
// A MISSING METRIC DOES NOT FAIL LOUDLY, which is what makes it worth a line:
|
|
50
59
|
// it reads `undefined`, arithmetic on it yields `NaN`, and every comparison
|
|
51
60
|
// against `NaN` is false — so a fitting check silently passes everything.
|
|
52
|
-
// Measured when `--ig-label-char-width` was added: a 0.1.0 theme made
|
|
53
|
-
// `
|
|
54
|
-
//
|
|
61
|
+
// Measured when `--ig-label-char-width` was added: a 0.1.0 theme made every
|
|
62
|
+
// measured width `NaN`, so a card's height came out `NaN` and every station
|
|
63
|
+
// in the spine stacked at the same y.
|
|
55
64
|
return theme.metrics[token] ?? defaultTheme.metrics[token];
|
|
56
65
|
}
|
|
57
|
-
|
|
58
|
-
* A
|
|
59
|
-
*
|
|
60
|
-
*
|
|
66
|
+
/*
|
|
67
|
+
* A CARD FILLS ITS COLUMN. It used to be sized to its own label and clamped to
|
|
68
|
+
* the column, which made every card a different width and every edge terminate
|
|
69
|
+
* at a different x — and the label was then fitted to that width and truncated.
|
|
70
|
+
* §16b draws one width per column and lets the card grow DOWNWARDS instead, so
|
|
71
|
+
* the arcs arrive on one vertical and nothing is cut.
|
|
61
72
|
*/
|
|
62
73
|
/**
|
|
63
74
|
* The label as it fits the box the layout gave it.
|
|
@@ -152,54 +163,23 @@ function labelWidth(glyphs, average) {
|
|
|
152
163
|
return total;
|
|
153
164
|
}
|
|
154
165
|
/**
|
|
155
|
-
* How wide a drawn label runs
|
|
156
|
-
*
|
|
166
|
+
* How wide a drawn label runs.
|
|
167
|
+
*
|
|
168
|
+
* NOTHING TRUNCATES ANY MORE, and this is what replaced the function that did.
|
|
169
|
+
* A node is an HTML card whose title wraps, so the question stopped being "how
|
|
170
|
+
* much of this fits on one line" and became "how many lines does this need" —
|
|
171
|
+
* which is what {@link layoutGraph} asks it, to give a card a height before
|
|
172
|
+
* anything is rendered. `fitLabel` went with the truncation it existed to
|
|
173
|
+
* perform: §16b's acceptance is that nothing truncates that the design draws in
|
|
174
|
+
* full, and a helper that shortens a title had no caller left.
|
|
157
175
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* named — so the guard shared the defect it was guarding against. Measuring
|
|
162
|
-
* with this checks that the right box got the right label; the wide-glyph
|
|
163
|
-
* bound is what checks the model itself, against a number stated outside it.
|
|
176
|
+
* IT IS A CEILING, per character class rather than a flat average — see
|
|
177
|
+
* `labelWidth`. A card that reserves slightly too much leaves a gap; one that
|
|
178
|
+
* reserves too little is overlapped by the next rank.
|
|
164
179
|
*/
|
|
165
180
|
export function measureLabel(theme, label) {
|
|
166
181
|
return labelWidth([...label], metric(theme, '--ig-label-char-width'));
|
|
167
182
|
}
|
|
168
|
-
export function fitLabel(theme, label, width) {
|
|
169
|
-
const room = width - metric(theme, '--ig-space') * 2;
|
|
170
|
-
const average = metric(theme, '--ig-label-char-width');
|
|
171
|
-
// BY CODE POINT, NOT BY UTF-16 CODE UNIT. `slice` counts units, so a cut
|
|
172
|
-
// landing between an astral character's surrogate halves drew a lone
|
|
173
|
-
// surrogate — a replacement glyph immediately before the ellipsis. Measured:
|
|
174
|
-
// a title of `𝗔` came back containing one. Emoji and mathematical alphanumerics
|
|
175
|
-
// are the ordinary way a title reaches this.
|
|
176
|
-
const glyphs = [...label];
|
|
177
|
-
if (labelWidth(glyphs, average) <= room)
|
|
178
|
-
return label;
|
|
179
|
-
// MEASURED, NOT COUNTED, so the ellipsis is included in what has to fit and
|
|
180
|
-
// the kept text is as long as the box actually allows.
|
|
181
|
-
const ellipsis = labelWidth(['\u2026'], average);
|
|
182
|
-
let used = ellipsis;
|
|
183
|
-
let kept = 0;
|
|
184
|
-
for (const glyph of glyphs) {
|
|
185
|
-
const next = used + labelWidth([glyph], average);
|
|
186
|
-
if (next > room)
|
|
187
|
-
break;
|
|
188
|
-
used = next;
|
|
189
|
-
kept += 1;
|
|
190
|
-
}
|
|
191
|
-
// Below one glyph there is no room for anything AND the ellipsis, and an
|
|
192
|
-
// ellipsis alone names nothing — so the box has become too small to label at
|
|
193
|
-
// all, and drawing nothing is honester than drawing a lone dot.
|
|
194
|
-
if (kept < 1)
|
|
195
|
-
return '';
|
|
196
|
-
return `${glyphs.slice(0, kept).join('')}\u2026`;
|
|
197
|
-
}
|
|
198
|
-
function boxWidth(theme, label, columnWidth) {
|
|
199
|
-
const padding = metric(theme, '--ig-space') * 2;
|
|
200
|
-
const measured = label.length * metric(theme, '--ig-char-width') + padding;
|
|
201
|
-
return Math.min(columnWidth, Math.max(columnWidth / 2, measured));
|
|
202
|
-
}
|
|
203
183
|
/**
|
|
204
184
|
* Assign every key that needs a box to a column.
|
|
205
185
|
*
|
|
@@ -210,18 +190,40 @@ function boxWidth(theme, label, columnWidth) {
|
|
|
210
190
|
* when the order deliberately never works it, the left one when it is an open
|
|
211
191
|
* issue standing in the way.
|
|
212
192
|
*/
|
|
213
|
-
function assignColumns(document
|
|
193
|
+
function assignColumns(document,
|
|
194
|
+
/**
|
|
195
|
+
* The slot leads the runner is working right now.
|
|
196
|
+
*
|
|
197
|
+
* They are placed by {@link layoutGraph} at the top of the spine as §16b's
|
|
198
|
+
* NOW station, so this pass must not ALSO place them — a key with two boxes
|
|
199
|
+
* is the one-element-per-key rule broken, whichever column the second one
|
|
200
|
+
* lands in.
|
|
201
|
+
*/
|
|
202
|
+
running) {
|
|
203
|
+
// ONE BOX PER SLOT, NOT PER MEMBER. §16b draws a together unit as ONE card
|
|
204
|
+
// with the members listed inside it, and the frame's own vocabulary table
|
|
205
|
+
// calls the enclosure "distinct issues forming one unit of work". Giving each
|
|
206
|
+
// member its own box and drawing a dashed rectangle round the pair says
|
|
207
|
+
// something weaker and costs the spine a station: the unit occupied two rows
|
|
208
|
+
// of a column whose vertical position IS the rank, so two boxes claimed two
|
|
209
|
+
// ranks for one. `slotMembers` is how the card knows what to list.
|
|
210
|
+
//
|
|
211
|
+
// AND ONLY THE SLOTS THAT ARE IN THE ORDER. A slot the RUNNER holds — claimed,
|
|
212
|
+
// parked — is not a fact about the work and earns no rank, which §16a says by
|
|
213
|
+
// collecting it in a footer group. Drawing it on the spine gave it a position
|
|
214
|
+
// in a column whose vertical position IS the sequence, so a parked issue was
|
|
215
|
+
// drawn as a step in the work order with a dash where its number should be.
|
|
214
216
|
const spine = [];
|
|
217
|
+
const footerLeads = [];
|
|
215
218
|
const slotMembers = new Map();
|
|
216
219
|
const placed = new Set();
|
|
217
220
|
for (const slot of document.order.slots) {
|
|
218
221
|
slotMembers.set(slot.lead, slot.members);
|
|
219
|
-
for (const member of slot.members)
|
|
220
|
-
if (placed.has(member))
|
|
221
|
-
continue;
|
|
222
|
+
for (const member of slot.members)
|
|
222
223
|
placed.add(member);
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
if (running.has(slot.lead))
|
|
225
|
+
continue;
|
|
226
|
+
(slot.holds.some((hold) => hold.family === 'tracker') ? footerLeads : spine).push(slot.lead);
|
|
225
227
|
}
|
|
226
228
|
const neverWorked = new Set(document.order.excluded.map((exclusion) => exclusion.key));
|
|
227
229
|
for (const edge of document.edges) {
|
|
@@ -231,9 +233,39 @@ function assignColumns(document) {
|
|
|
231
233
|
if (origin !== undefined && !origin.open && !placed.has(edge.to))
|
|
232
234
|
neverWorked.add(edge.to);
|
|
233
235
|
}
|
|
236
|
+
// Which keys stand on the spine, for the "does this explain a hold" test
|
|
237
|
+
// below. A member is represented by its slot's lead.
|
|
238
|
+
const onSpine = new Set();
|
|
239
|
+
for (const lead of spine)
|
|
240
|
+
for (const member of slotMembers.get(lead) ?? [lead])
|
|
241
|
+
onSpine.add(member);
|
|
242
|
+
// EVERY MEMBER'S EDGES, NOT JUST THE LEAD'S. A together unit is one card and
|
|
243
|
+
// its partners get no card of their own, so a unit that blocks a ranked issue
|
|
244
|
+
// THROUGH its partner read as touching nothing and was sent to the footer —
|
|
245
|
+
// the graph then lost both the gutter card and the arc that explains the
|
|
246
|
+
// hold. The same rule the lateral axis already applies for the same reason.
|
|
247
|
+
const touchesSpine = (lead) => (slotMembers.get(lead) ?? [lead]).some((member) => (document.edgesOf.get(member) ?? []).some((edge) => onSpine.has(edge.from === member ? edge.to : edge.from)));
|
|
234
248
|
const left = [];
|
|
235
249
|
const right = [];
|
|
236
|
-
const
|
|
250
|
+
const footer = [];
|
|
251
|
+
// A HELD SLOT THE ORDER DOES NOT RANK IS EITHER AN EXPLANATION OR A FOOTNOTE,
|
|
252
|
+
// and §16b's left column heading — "Explains the order" — is the test. A
|
|
253
|
+
// parked issue that BLOCKS a ranked one is the answer to "why isn't my P1
|
|
254
|
+
// running", so it is drawn beside the spine; one that blocks nothing on the
|
|
255
|
+
// spine explains nothing about it, and belongs in the footer group with the
|
|
256
|
+
// duplicates. That is the documented reading of §16b's "open issues outside
|
|
257
|
+
// the order that explain a hold" — outside the order is the runner's hold,
|
|
258
|
+
// and explaining is the edge.
|
|
259
|
+
for (const lead of footerLeads) {
|
|
260
|
+
(touchesSpine(lead) ? left : footer).push(lead);
|
|
261
|
+
}
|
|
262
|
+
// THE RUNNING KEYS TOO. A job with no slot is placed by `layoutGraph` as the
|
|
263
|
+
// NOW station and is therefore absent from `placed` — so an edge touching it
|
|
264
|
+
// sent it to a gutter as well. In the expanded graph the gutter box then
|
|
265
|
+
// overwrote the spine one and the NOW marker vanished; in compact mode, where
|
|
266
|
+
// the gutters are not drawn, the key appeared as a card AND as a footer row.
|
|
267
|
+
// Both are the same fault: one key, two boxes.
|
|
268
|
+
const seen = new Set([...placed, ...running, ...left, ...footer]);
|
|
237
269
|
for (const edge of document.edges) {
|
|
238
270
|
for (const end of [edge.from, edge.to]) {
|
|
239
271
|
if (seen.has(end))
|
|
@@ -248,37 +280,165 @@ function assignColumns(document) {
|
|
|
248
280
|
seen.add(key);
|
|
249
281
|
right.push(key);
|
|
250
282
|
}
|
|
251
|
-
return { spine, left, right, slotMembers };
|
|
283
|
+
return { spine, left, right, footer, slotMembers };
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* How tall a card is, counted rather than measured.
|
|
287
|
+
*
|
|
288
|
+
* COUNTED FROM THE CARD'S OWN DESCRIPTION — see `CardBlock`. It used to be
|
|
289
|
+
* counted from a second reading of what the projection draws, and four times
|
|
290
|
+
* running a block was added to the drawing and not to the count: the badge row
|
|
291
|
+
* wrapped and was charged one line, a unit in the gutter was sized as a single
|
|
292
|
+
* issue, the unit branch returned before the notes, and the NOW banner was
|
|
293
|
+
* added with no allowance at all. Each time the card overran its box, the next
|
|
294
|
+
* card was drawn over the overflow, and the arcs stayed anchored to geometry
|
|
295
|
+
* nobody drew. A block added to `cardBlocks` now reaches this by construction.
|
|
296
|
+
*
|
|
297
|
+
* Counted rather than MEASURED because this module is pure and deterministic:
|
|
298
|
+
* the same document must produce the same coordinates on a server with no fonts
|
|
299
|
+
* as in a browser with them. It rounds up, for the reason {@link measureLabel}
|
|
300
|
+
* gives about its own metric — a card that reserves slightly too much leaves a
|
|
301
|
+
* gap, and one that reserves too little is overlapped by the next rank.
|
|
302
|
+
*/
|
|
303
|
+
function cardHeight(theme, document, key, width, members, onSpine, now) {
|
|
304
|
+
const pad = metric(theme, '--ig-space') * 2;
|
|
305
|
+
const line = metric(theme, '--ig-card-line');
|
|
306
|
+
const gap = metric(theme, '--ig-space-tight');
|
|
307
|
+
const memberPad = metric(theme, '--ig-space-snug') * 2;
|
|
308
|
+
const inner = Math.max(1, width - metric(theme, '--ig-space') * 2);
|
|
309
|
+
// A UNIT'S TITLES SIT INSIDE ANOTHER BOX. `.ig-unit` has its own border and
|
|
310
|
+
// `.ig-unit-member` its own inline padding, so measuring a member's title at
|
|
311
|
+
// the CARD's inner width let a title near the boundary take a line the
|
|
312
|
+
// reservation did not have.
|
|
313
|
+
const unitInner = Math.max(1, inner - memberPad - metric(theme, '--ig-stroke-connector') * 2);
|
|
314
|
+
const blocks = cardBlocks(document, key, members, onSpine, now);
|
|
315
|
+
let height = pad;
|
|
316
|
+
blocks.forEach((block, index) => {
|
|
317
|
+
if (index > 0)
|
|
318
|
+
height += gap;
|
|
319
|
+
if (block.kind === 'badges') {
|
|
320
|
+
// A CHIP IS TALLER THAN ITS TEXT, and the rows are spaced. `.ig-badge`
|
|
321
|
+
// adds its own vertical padding and border, and `.ig-badges` puts a gap
|
|
322
|
+
// between wrapped rows — so `rows * line` under-reserved a wrapping badge
|
|
323
|
+
// row by more than the gap between cards, and the card beneath was drawn
|
|
324
|
+
// over it.
|
|
325
|
+
const rows = packedRows(theme, block.texts, inner);
|
|
326
|
+
const chipHeight = line + metric(theme, '--ig-space-micro') * 2 + metric(theme, '--ig-stroke') * 2;
|
|
327
|
+
height += rows * chipHeight + Math.max(0, rows - 1) * gap;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const width = block.kind === 'unit' ? unitInner : inner;
|
|
331
|
+
height += cardText(document, block).reduce((total, text) => total + Math.max(1, Math.ceil(measureLabel(theme, text) / width)) * line, 0);
|
|
332
|
+
if (block.kind === 'unit') {
|
|
333
|
+
// The enclosure pads each member's pair on both sides, puts a gap between
|
|
334
|
+
// the title and the identity, and rules between members.
|
|
335
|
+
height +=
|
|
336
|
+
block.members.length * (memberPad + metric(theme, '--ig-space-micro')) +
|
|
337
|
+
Math.max(0, block.members.length - 1) * metric(theme, '--ig-stroke');
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
// A DELIBERATE OVER-RESERVATION, and it is the structural half of this rule.
|
|
341
|
+
// A pure layout cannot MEASURE text, so every height here is an estimate, and
|
|
342
|
+
// an estimate can always be wrong by some box-model detail the counter does
|
|
343
|
+
// not mirror — six review rounds found six of them. What matters is which way
|
|
344
|
+
// it is wrong: a card positioned absolutely that reserves too little is
|
|
345
|
+
// OVERLAPPED by the next rank, which is a legibility failure, while one that
|
|
346
|
+
// reserves too much leaves a gap nobody minds. One line of slack per card
|
|
347
|
+
// turns the next such miss into the harmless kind.
|
|
348
|
+
return height + line;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* How many rows a wrapping row of chips takes.
|
|
352
|
+
*
|
|
353
|
+
* THE BADGE ROW WRAPS, AND IT IS WHAT OVERFLOWED FIRST. A row of chips was
|
|
354
|
+
* counted as one line however many chips it held, so a card with four
|
|
355
|
+
* relationships reserved the height of a card with one and the two beneath it
|
|
356
|
+
* were drawn over. Packed here rather than assumed: each chip is its own text
|
|
357
|
+
* plus its padding, laid into the card's inner width.
|
|
358
|
+
*/
|
|
359
|
+
function packedRows(theme, texts, inner) {
|
|
360
|
+
if (texts.length === 0)
|
|
361
|
+
return 0;
|
|
362
|
+
const gap = metric(theme, '--ig-space-tight');
|
|
363
|
+
// THE CHIP'S OWN INNER GAP, TOO. A relationship or status chip is a glyph and
|
|
364
|
+
// a label — two children with `gap: var(--ig-space-tight)` between them — and
|
|
365
|
+
// measuring the concatenated text plus the outer padding alone under-measured
|
|
366
|
+
// every one of them. Near a row boundary the browser then wrapped a chip this
|
|
367
|
+
// packer had kept on the previous row, the height omitted that whole row, and
|
|
368
|
+
// the cards beneath were drawn over it.
|
|
369
|
+
const chipPadding = gap * 3 + metric(theme, '--ig-stroke') * 2;
|
|
370
|
+
let used = 0;
|
|
371
|
+
let rows = 1;
|
|
372
|
+
for (const text of texts) {
|
|
373
|
+
const chip = measureLabel(theme, text) + chipPadding;
|
|
374
|
+
if (used > 0 && used + gap + chip > inner) {
|
|
375
|
+
rows += 1;
|
|
376
|
+
used = chip;
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
used += (used > 0 ? gap : 0) + chip;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return rows;
|
|
252
383
|
}
|
|
253
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
384
|
+
/**
|
|
385
|
+
* Lay a document out. Pure: coordinates depend only on the document, the theme
|
|
386
|
+
* and the size.
|
|
387
|
+
*
|
|
388
|
+
* COMPACT IS A SIZE, NOT A DEGRADED MODE. §16b is explicit that at a settings
|
|
389
|
+
* column's width the arcs and both gutters cannot be drawn legibly, so the
|
|
390
|
+
* in-column case is a spine-only preview with an expand affordance rather than
|
|
391
|
+
* the same picture squeezed. Expressed as a zero-width gutter, so exactly one
|
|
392
|
+
* layout computes both and the spine's own geometry cannot differ between them.
|
|
393
|
+
*/
|
|
394
|
+
export function layoutGraph(document, theme, compact = false) {
|
|
395
|
+
const gap = metric(theme, '--ig-space-loose');
|
|
396
|
+
const gutterWidth = compact ? 0 : metric(theme, '--ig-gutter-width');
|
|
258
397
|
const spineWidth = metric(theme, '--ig-spine-width');
|
|
259
|
-
const channelWidth = metric(theme, '--ig-gutter-width') / 2;
|
|
398
|
+
const channelWidth = compact ? metric(theme, '--ig-space') : metric(theme, '--ig-gutter-width') / 2;
|
|
399
|
+
const stationBox = metric(theme, '--ig-station-box');
|
|
400
|
+
// Room above the first card for the three column headings, which say what
|
|
401
|
+
// each column is FOR. Without them the gutters read as two more piles of
|
|
402
|
+
// issues rather than as the two answers the spine deliberately keeps off it.
|
|
403
|
+
const headroom = metric(theme, '--ig-space-wide') + metric(theme, '--ig-space-loose');
|
|
260
404
|
// THE CANVAS RESERVES WHAT THE ENCLOSURE NEEDS, AND WHAT THE FOCUS RING DOES.
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// same edge: `:focus-visible` sits `--ig-space-tight` clear of the element and
|
|
266
|
-
// is `--ig-focus-ring` thick, so it extends their SUM outward, while a margin
|
|
267
|
-
// of `--ig-space-tight` alone left the top segment outside the stage — and on
|
|
268
|
-
// a one-row graph the bottom segment with it. A partial ring is exactly the
|
|
269
|
-
// indicator a keyboard reader depends on, so the margin covers the larger of
|
|
270
|
-
// the two demands rather than the one that happens to be named here.
|
|
405
|
+
// The focus ring sits `--ig-space-tight` clear of an element and is
|
|
406
|
+
// `--ig-focus-ring` thick, so it extends their SUM outward and was clipped by
|
|
407
|
+
// the stage, which hides its overflow. A partial ring is exactly the indicator
|
|
408
|
+
// a keyboard reader depends on.
|
|
271
409
|
// SUMMED FROM THE TOKENS, never written as 8: geometry is theme data in this
|
|
272
410
|
// package, so a retheme that changes either token moves the reservation with
|
|
273
411
|
// it instead of silently reintroducing the clip.
|
|
274
|
-
// The ENCLOSURE's own inset stays `--ig-space-tight` — this is the canvas
|
|
275
|
-
// margin, which must merely be big enough for both, not the same quantity.
|
|
276
412
|
const pad = metric(theme, '--ig-space-tight') + metric(theme, '--ig-focus-ring');
|
|
277
|
-
|
|
413
|
+
// THE RUNNING JOBS LEAD THE SPINE — §16b's NOW station, above rank 1, because
|
|
414
|
+
// "what is running" and "what is next" are one question asked a step apart.
|
|
415
|
+
//
|
|
416
|
+
// WHATEVER SLOT THEY HOLD. A job is usually running BECAUSE a runner claimed
|
|
417
|
+
// it, so its slot is tracker-held and off the spine by construction. Skipping
|
|
418
|
+
// a job that held any slot kept it out of two boxes and left it in NO NOW
|
|
419
|
+
// state at all: the graph drew a generic footer row for the one issue the
|
|
420
|
+
// panel exists to say is in flight. Placing it here and skipping it in
|
|
421
|
+
// `assignColumns` is what gives it exactly one box, which is the rule `mount`
|
|
422
|
+
// indexes on.
|
|
423
|
+
//
|
|
424
|
+
// BY THE SLOT'S LEAD. A together unit is one card, so a running PARTNER marks
|
|
425
|
+
// the unit rather than taking a card the projection has no station for.
|
|
426
|
+
const nowKeys = [];
|
|
427
|
+
for (const job of document.host.running) {
|
|
428
|
+
const lead = document.order.slots.find((slot) => slot.members.includes(job.key))?.lead ?? job.key;
|
|
429
|
+
if (!nowKeys.includes(lead))
|
|
430
|
+
nowKeys.push(lead);
|
|
431
|
+
}
|
|
432
|
+
const { spine, left, right, footer, slotMembers } = assignColumns(document, new Set(nowKeys));
|
|
278
433
|
const leftX = pad;
|
|
279
|
-
|
|
434
|
+
// THE STATION COLUMN SITS BETWEEN THE CHANNEL AND THE CARDS, and the spine
|
|
435
|
+
// line runs down its centre. §16b puts the line at x=392, the 26px station at
|
|
436
|
+
// 379 (so the line bisects it) and the card at 418 — the card starts where
|
|
437
|
+
// the station ends. Expressed from the tokens so a retheme moves all three.
|
|
438
|
+
const spineLineX = pad + gutterWidth + channelWidth + stationBox / 2;
|
|
439
|
+
const spineX = spineLineX + stationBox / 2 + metric(theme, '--ig-space');
|
|
280
440
|
const rightX = spineX + spineWidth + channelWidth;
|
|
281
|
-
const width = rightX + gutterWidth + pad;
|
|
441
|
+
const width = compact ? spineX + spineWidth + pad : rightX + gutterWidth + pad;
|
|
282
442
|
// The channels sit between the columns, so no arc routed through one can
|
|
283
443
|
// cross an occupied x-range.
|
|
284
444
|
const leftChannel = pad + gutterWidth + channelWidth / 2;
|
|
@@ -292,34 +452,125 @@ export function layoutGraph(document, theme) {
|
|
|
292
452
|
}
|
|
293
453
|
}
|
|
294
454
|
const nodes = new Map();
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
455
|
+
// THE SPINE IS STACKED FIRST, because it is the sequence and everything else
|
|
456
|
+
// is positioned against it. Each card takes the height its own contents need,
|
|
457
|
+
// so the gaps between stations vary — which is what §16b draws, and what a
|
|
458
|
+
// fixed row height cannot express once a unit card lists two issues.
|
|
459
|
+
let y = pad + headroom;
|
|
460
|
+
const stacked = [...nowKeys, ...spine];
|
|
461
|
+
for (const key of stacked) {
|
|
462
|
+
const members = slotMembers.get(key) ?? [key];
|
|
463
|
+
const height = cardHeight(theme, document, key, spineWidth, members, true, nowKeys.includes(key));
|
|
464
|
+
nodes.set(key, {
|
|
465
|
+
key,
|
|
466
|
+
column: 'spine',
|
|
467
|
+
x: spineX,
|
|
468
|
+
y,
|
|
469
|
+
width: spineWidth,
|
|
470
|
+
height,
|
|
471
|
+
rank: rankOf.get(key) ?? null,
|
|
472
|
+
held: heldOf.get(key) ?? false,
|
|
473
|
+
now: nowKeys.includes(key),
|
|
474
|
+
});
|
|
475
|
+
y += height + gap;
|
|
476
|
+
}
|
|
477
|
+
const spineBottom = stacked.length === 0 ? pad + headroom : y - gap;
|
|
478
|
+
// A GUTTER CARD SITS BESIDE THE SPINE ROW IT EXPLAINS. Stacking the gutters
|
|
479
|
+
// from the top independently, which is what shipped, drew #470 beside rank 3
|
|
480
|
+
// and its arc across four cards to reach rank 1 — the arcs then crossed the
|
|
481
|
+
// spine, which is the one thing §16b's whole layout exists to prevent. The
|
|
482
|
+
// partner is whichever spine node an edge joins it to; a gutter node with no
|
|
483
|
+
// spine partner falls in behind the ones that have one.
|
|
484
|
+
const partnerY = (key) => {
|
|
485
|
+
// EVERY MEMBER'S EDGES, as the classification and the lateral axis already
|
|
486
|
+
// do. A tracker-held unit reaches the gutter because a NON-LEAD member
|
|
487
|
+
// blocks a ranked row — that is the whole case — and looking the partner up
|
|
488
|
+
// through the lead alone then found nothing, dropped the card at the
|
|
489
|
+
// fallback top row, and drew exactly the long cross-row arc this pass
|
|
490
|
+
// exists to prevent. Three passes asking the same question about a unit
|
|
491
|
+
// have to ask it the same way.
|
|
492
|
+
const mine = new Set(slotMembers.get(key) ?? [key]);
|
|
493
|
+
for (const edge of document.edges) {
|
|
494
|
+
const other = mine.has(edge.from) ? edge.to : mine.has(edge.to) ? edge.from : undefined;
|
|
495
|
+
if (other === undefined || mine.has(other))
|
|
496
|
+
continue;
|
|
497
|
+
// THE SLOT'S LEAD, OR THE KEY ITSELF. An unslotted running job has a
|
|
498
|
+
// spine box of its own — it is the NOW station — and searching `slots`
|
|
499
|
+
// alone could not resolve it, so a gutter card explaining the SECOND of
|
|
500
|
+
// two running jobs fell back beside the first row and drew exactly the
|
|
501
|
+
// cross-row arc this pass exists to prevent.
|
|
502
|
+
const station = document.order.slots.find((slot) => slot.members.includes(other))?.lead ?? other;
|
|
503
|
+
const box = nodes.get(station);
|
|
504
|
+
// A SPINE PARTNER, NEVER A GUTTER ONE. The right gutter is placed after
|
|
505
|
+
// the left, so by then a left card has a box too — and aligning a right
|
|
506
|
+
// card to a left one would chain two alignments and drift both away from
|
|
507
|
+
// the row they are supposed to explain.
|
|
508
|
+
if (box !== undefined && box.column === 'spine')
|
|
509
|
+
return box.y;
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
};
|
|
513
|
+
const placeGutter = (keys, column, x) => {
|
|
514
|
+
let fallback = pad + headroom;
|
|
515
|
+
// SORTED BY THE ROW THEY EXPLAIN, so two gutter cards never swap places
|
|
516
|
+
// relative to the spine and then need arcs that cross each other.
|
|
517
|
+
const ordered = [...keys].sort((a, b) => (partnerY(a) ?? Number.POSITIVE_INFINITY) - (partnerY(b) ?? Number.POSITIVE_INFINITY));
|
|
518
|
+
let lowest = pad + headroom;
|
|
519
|
+
for (const key of ordered) {
|
|
520
|
+
// THE SLOT'S MEMBERS, as the spine placement already does. A tracker-held
|
|
521
|
+
// together unit can land in the gutter, and `nodeCard` reads
|
|
522
|
+
// `slotMembers` there too — so sizing it as a single issue reserved a
|
|
523
|
+
// fraction of the markup it draws, and the next gutter card was placed on
|
|
524
|
+
// top of the difference.
|
|
525
|
+
const height = cardHeight(theme, document, key, gutterWidth, slotMembers.get(key) ?? [key], false, false);
|
|
526
|
+
// Never above the previous card in the same gutter: an alignment that
|
|
527
|
+
// would overlap gives way to the stack, because a hidden card explains
|
|
528
|
+
// nothing at all.
|
|
529
|
+
const wanted = partnerY(key) ?? fallback;
|
|
530
|
+
const top = Math.max(wanted, lowest);
|
|
298
531
|
nodes.set(key, {
|
|
299
532
|
key,
|
|
300
533
|
column,
|
|
301
534
|
x,
|
|
302
|
-
y:
|
|
303
|
-
width:
|
|
304
|
-
height
|
|
305
|
-
rank:
|
|
535
|
+
y: top,
|
|
536
|
+
width: gutterWidth,
|
|
537
|
+
height,
|
|
538
|
+
rank: null,
|
|
306
539
|
held: heldOf.get(key) ?? false,
|
|
307
540
|
});
|
|
308
|
-
|
|
541
|
+
lowest = top + height + gap;
|
|
542
|
+
fallback = lowest;
|
|
543
|
+
}
|
|
309
544
|
};
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
545
|
+
// NOTHING SITS IN A GUTTER THAT HAS NO WIDTH. In compact mode the gutter
|
|
546
|
+
// cards and every arc are dropped rather than drawn at a width that cannot
|
|
547
|
+
// carry them — the expand affordance is how a reader gets to them, which is
|
|
548
|
+
// what §16b says in as many words.
|
|
549
|
+
if (!compact) {
|
|
550
|
+
placeGutter(left, 'left', leftX);
|
|
551
|
+
placeGutter(right, 'right', rightX);
|
|
552
|
+
}
|
|
553
|
+
let bottom = spineBottom;
|
|
554
|
+
for (const box of nodes.values())
|
|
555
|
+
bottom = Math.max(bottom, box.y + box.height);
|
|
556
|
+
const height = nodes.size === 0 ? 0 : bottom + pad;
|
|
315
557
|
return {
|
|
316
558
|
width,
|
|
317
559
|
height,
|
|
318
560
|
nodes,
|
|
319
|
-
spineOrder:
|
|
561
|
+
spineOrder: stacked,
|
|
320
562
|
slotMembers,
|
|
321
563
|
leftChannel,
|
|
322
564
|
rightChannel,
|
|
565
|
+
spineLineX,
|
|
566
|
+
spineTop: pad + headroom,
|
|
567
|
+
spineBottom,
|
|
568
|
+
columnX: Object.freeze({ left: leftX, spine: spineX, right: rightX }),
|
|
569
|
+
// In compact mode the gutters are not drawn, so the issues that would have
|
|
570
|
+
// sat in them join the footer group — absent from the picture, present in
|
|
571
|
+
// the panel, which is the difference between a smaller view and a lying one.
|
|
572
|
+
footer: compact ? [...footer, ...left, ...right] : footer,
|
|
573
|
+
compact,
|
|
323
574
|
};
|
|
324
575
|
}
|
|
325
576
|
/**
|
|
@@ -369,10 +620,27 @@ function anchor(box, fraction, side) {
|
|
|
369
620
|
* built a layout from a different document than the edge came from. Refusing is
|
|
370
621
|
* better than drawing at coordinates nobody computed.
|
|
371
622
|
*/
|
|
623
|
+
/** The box a key is drawn on: its own, or the card of the unit it belongs to. */
|
|
624
|
+
function stationFor(layout, key) {
|
|
625
|
+
if (layout.nodes.has(key))
|
|
626
|
+
return key;
|
|
627
|
+
for (const [lead, members] of layout.slotMembers) {
|
|
628
|
+
if (members.includes(key))
|
|
629
|
+
return lead;
|
|
630
|
+
}
|
|
631
|
+
return key;
|
|
632
|
+
}
|
|
372
633
|
export function edgeGeometry(layout, edge) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
634
|
+
// AN ENDPOINT IS RESOLVED TO THE BOX THAT REPRESENTS IT. With one card per
|
|
635
|
+
// slot, a together unit's partner has no box of its own — so an edge naming
|
|
636
|
+
// the partner looked up `undefined` and was silently dropped, and the
|
|
637
|
+
// fixture's `104 decomposed-from 107` disappeared from the canvas entirely.
|
|
638
|
+
// The card is what stands for the member, so the card is what the arc lands
|
|
639
|
+
// on. An edge whose two ends resolve to the SAME card is drawn inside it, and
|
|
640
|
+
// answers `null` here rather than as a zero-length arc.
|
|
641
|
+
const from = layout.nodes.get(stationFor(layout, edge.from));
|
|
642
|
+
const to = layout.nodes.get(stationFor(layout, edge.to));
|
|
643
|
+
if (from === undefined || to === undefined || from.key === to.key)
|
|
376
644
|
return null;
|
|
377
645
|
// The channel is chosen FIRST, because on a same-column tie it is what
|
|
378
646
|
// decides which bound each endpoint uses. A pair of spine nodes bows LEFT, as
|
|
@@ -380,8 +648,10 @@ export function edgeGeometry(layout, edge) {
|
|
|
380
648
|
// so the two families never tangle.
|
|
381
649
|
const usesRight = from.column === 'right' || to.column === 'right';
|
|
382
650
|
const controlX = usesRight ? layout.rightChannel : layout.leftChannel;
|
|
383
|
-
const
|
|
384
|
-
const
|
|
651
|
+
const startSide = facingSide(from, to, controlX);
|
|
652
|
+
const endSide = facingSide(to, from, controlX);
|
|
653
|
+
const start = anchor(from, DEPART_FRACTION, startSide);
|
|
654
|
+
const end = anchor(to, ARRIVE_FRACTION, endSide);
|
|
385
655
|
const controlY = (start.y + end.y) / 2;
|
|
386
656
|
const round = (value) => (Math.round(value * 100) / 100).toFixed(2);
|
|
387
657
|
const d = `M ${round(start.x)} ${round(start.y)} Q ${round(controlX)} ${round(controlY)} ${round(end.x)} ${round(end.y)}`;
|
|
@@ -389,79 +659,10 @@ export function edgeGeometry(layout, edge) {
|
|
|
389
659
|
d,
|
|
390
660
|
start,
|
|
391
661
|
end,
|
|
662
|
+
control: { x: controlX, y: controlY },
|
|
663
|
+
startSide,
|
|
664
|
+
endSide,
|
|
392
665
|
endAngle: Math.atan2(end.y - controlY, end.x - controlX),
|
|
393
666
|
};
|
|
394
667
|
}
|
|
395
|
-
/**
|
|
396
|
-
* The path joining two members of one together unit, clear of every member box.
|
|
397
|
-
*
|
|
398
|
-
* ONE CONNECTOR PER DECLARED EDGE, which is why this takes two keys rather than
|
|
399
|
-
* a position in the member list. A group is joined by pointing at any existing
|
|
400
|
-
* member (§4.3.7), so `2 together-with 1` plus `3 together-with 1` is an
|
|
401
|
-
* ordinary STAR whose members lay out as three consecutive rows — and a
|
|
402
|
-
* connector drawn between adjacent rows would claim a `2`–`3` relationship the
|
|
403
|
-
* document never declares while leaving the real `1`–`3` edge undrawable.
|
|
404
|
-
*
|
|
405
|
-
* TWO ROUTES, and the branch is the whole of it. Members of a slot are placed
|
|
406
|
-
* consecutively in one column, so an edge between NEIGHBOURING rows crosses
|
|
407
|
-
* only the gap between them and is drawn straight. An edge that spans a row —
|
|
408
|
-
* which only a star can produce — would run through the node between them, so
|
|
409
|
-
* it is routed out to a lane in the enclosure's own padding. The lane sits left
|
|
410
|
-
* of `x`, and every member box in a column starts at exactly `x`, so the
|
|
411
|
-
* vertical run is provably outside every occupied x-range rather than merely
|
|
412
|
-
* looking clear.
|
|
413
|
-
*
|
|
414
|
-
* Endpoints come from measured bounds on both routes, per the kit's
|
|
415
|
-
* implementation note: a hit target derived from an eyeballed offset drifts the
|
|
416
|
-
* first time the type scale moves.
|
|
417
|
-
*/
|
|
418
|
-
export function connectorPath(layout, members, from, to, theme) {
|
|
419
|
-
const a = layout.nodes.get(from);
|
|
420
|
-
const b = layout.nodes.get(to);
|
|
421
|
-
if (a === undefined || b === undefined)
|
|
422
|
-
return null;
|
|
423
|
-
const [upper, lower] = a.y <= b.y ? [a, b] : [b, a];
|
|
424
|
-
const round = (value) => (Math.round(value * 100) / 100).toFixed(2);
|
|
425
|
-
// Anything belonging to this unit that sits strictly between the two rows.
|
|
426
|
-
const between = members.some((member) => {
|
|
427
|
-
if (member === from || member === to)
|
|
428
|
-
return false;
|
|
429
|
-
const box = layout.nodes.get(member);
|
|
430
|
-
return box !== undefined && box.y > upper.y && box.y < lower.y;
|
|
431
|
-
});
|
|
432
|
-
if (!between) {
|
|
433
|
-
const x1 = upper.x + upper.width / 2;
|
|
434
|
-
const x2 = lower.x + lower.width / 2;
|
|
435
|
-
return `M ${round(x1)} ${round(upper.y + upper.height)} L ${round(x2)} ${round(lower.y)}`;
|
|
436
|
-
}
|
|
437
|
-
const pad = metric(theme, '--ig-space-tight');
|
|
438
|
-
const boxes = members
|
|
439
|
-
.map((member) => layout.nodes.get(member))
|
|
440
|
-
.filter((box) => box !== undefined);
|
|
441
|
-
// HALF A PAD INSIDE THE ENCLOSURE, so the run clears every box without
|
|
442
|
-
// landing on the enclosure's own stroke and reading as part of it.
|
|
443
|
-
const laneX = Math.min(...boxes.map((box) => box.x)) - pad / 2;
|
|
444
|
-
const upperY = upper.y + upper.height / 2;
|
|
445
|
-
const lowerY = lower.y + lower.height / 2;
|
|
446
|
-
return [
|
|
447
|
-
`M ${round(upper.x)} ${round(upperY)}`,
|
|
448
|
-
`L ${round(laneX)} ${round(upperY)}`,
|
|
449
|
-
`L ${round(laneX)} ${round(lowerY)}`,
|
|
450
|
-
`L ${round(lower.x)} ${round(lowerY)}`,
|
|
451
|
-
].join(' ');
|
|
452
|
-
}
|
|
453
|
-
/** The box enclosing a together unit's members, padded clear of their bounds. */
|
|
454
|
-
export function enclosureBounds(layout, members, theme) {
|
|
455
|
-
const boxes = members
|
|
456
|
-
.map((member) => layout.nodes.get(member))
|
|
457
|
-
.filter((box) => box !== undefined);
|
|
458
|
-
if (boxes.length < 2)
|
|
459
|
-
return null;
|
|
460
|
-
const pad = metric(theme, '--ig-space-tight');
|
|
461
|
-
const x = Math.min(...boxes.map((box) => box.x)) - pad;
|
|
462
|
-
const y = Math.min(...boxes.map((box) => box.y)) - pad;
|
|
463
|
-
const right = Math.max(...boxes.map((box) => box.x + box.width)) + pad;
|
|
464
|
-
const bottom = Math.max(...boxes.map((box) => box.y + box.height)) + pad;
|
|
465
|
-
return { x, y, width: right - x, height: bottom - y };
|
|
466
|
-
}
|
|
467
668
|
//# sourceMappingURL=layout.js.map
|