@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.
Files changed (52) hide show
  1. package/README.md +39 -2
  2. package/dist/document.d.ts +286 -3
  3. package/dist/document.d.ts.map +1 -1
  4. package/dist/document.js +247 -1
  5. package/dist/document.js.map +1 -1
  6. package/dist/element.d.ts +11 -0
  7. package/dist/element.d.ts.map +1 -1
  8. package/dist/element.js.map +1 -1
  9. package/dist/index.d.ts +6 -6
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +30 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/layout.d.ts +51 -50
  14. package/dist/layout.d.ts.map +1 -1
  15. package/dist/layout.js +364 -168
  16. package/dist/layout.js.map +1 -1
  17. package/dist/mount.d.ts +13 -0
  18. package/dist/mount.d.ts.map +1 -1
  19. package/dist/mount.js +94 -3
  20. package/dist/mount.js.map +1 -1
  21. package/dist/navigation.d.ts +14 -0
  22. package/dist/navigation.d.ts.map +1 -1
  23. package/dist/navigation.js +20 -0
  24. package/dist/navigation.js.map +1 -1
  25. package/dist/parts.d.ts +338 -1
  26. package/dist/parts.d.ts.map +1 -1
  27. package/dist/parts.js +899 -30
  28. package/dist/parts.js.map +1 -1
  29. package/dist/projections/graph.d.ts +10 -0
  30. package/dist/projections/graph.d.ts.map +1 -1
  31. package/dist/projections/graph.js +413 -311
  32. package/dist/projections/graph.js.map +1 -1
  33. package/dist/projections/linear.d.ts +90 -0
  34. package/dist/projections/linear.d.ts.map +1 -1
  35. package/dist/projections/linear.js +220 -28
  36. package/dist/projections/linear.js.map +1 -1
  37. package/dist/projections/tree.d.ts.map +1 -1
  38. package/dist/projections/tree.js +29 -3
  39. package/dist/projections/tree.js.map +1 -1
  40. package/dist/styles.d.ts +10 -1
  41. package/dist/styles.d.ts.map +1 -1
  42. package/dist/styles.js +825 -68
  43. package/dist/styles.js.map +1 -1
  44. package/dist/theme.d.ts +41 -10
  45. package/dist/theme.d.ts.map +1 -1
  46. package/dist/theme.js +265 -11
  47. package/dist/theme.js.map +1 -1
  48. package/dist/vocabulary.d.ts +39 -0
  49. package/dist/vocabulary.d.ts.map +1 -1
  50. package/dist/vocabulary.js +27 -0
  51. package/dist/vocabulary.js.map +1 -1
  52. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEpE,OAAO,EAAoB,KAAK,KAAK,EAAgB,MAAM,YAAY,CAAC;AAExE;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,EAAE,SAAS,MAAM,EAe7C,CAAC;AAEH,mCAAmC;AACnC,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;AAEhD,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB;;;;;;;OAOG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7C,6DAA6D;IAC7D,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IAC7D,qDAAqD;IACrD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,yDAAyD;IACzD,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAyHD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhE;AAyOD;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CACzB,QAAQ,EAAE,kBAAkB,EAC5B,KAAK,EAAE,KAAK,EACZ,OAAO,UAAQ,GACd,WAAW,CA8Lb;AAED,MAAM,WAAW,YAAY;IAC3B,yEAAyE;IACzE,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC;IACpB,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AA4DD,wBAAgB,YAAY,CAC1B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,UAAU,GACf,YAAY,GAAG,IAAI,CAgCrB"}
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
- // `fitLabel` return a 60-character title with an ellipsis APPENDED, which is
54
- // worse overflow than the defect that token was added to fix.
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 node's width follows its own contents rather than the column, so an edge
59
- * terminating on its bound lands where the reader sees the box end. Clamped to
60
- * the column so the channels stay free.
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, under the same model {@link fitLabel} truncates
156
- * with.
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
- * EXPORTED SO A TEST CAN CHECK THE GEOMETRY WITHOUT RESTATING THE MODEL. The
159
- * overflow invariant used to be asserted with `text.length * --ig-char-width`,
160
- * which is the mono advance at the wrong size and the very assumption #44
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
- spine.push(member);
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 seen = new Set(placed);
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
- /** Lay a document out. Pure: coordinates depend only on the document and the theme. */
254
- export function layoutGraph(document, theme) {
255
- const rowHeight = metric(theme, '--ig-row-height');
256
- const gap = metric(theme, '--ig-space');
257
- const gutterWidth = metric(theme, '--ig-gutter-width');
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
- // A `together-with` enclosure pads clear of its members' bounds, so a unit on
262
- // the first or last row drew at a negative coordinate or past the bottom edge
263
- // — outside the viewBox, and outside the stage, which hides its overflow.
264
- // The focus ring reaches FURTHER than the enclosure and was clipped by the
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
- const { spine, left, right, slotMembers } = assignColumns(document);
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
- const spineX = pad + gutterWidth + channelWidth;
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
- const place = (keys, column, x, columnWidth) => {
296
- keys.forEach((key, index) => {
297
- const label = document.byKey.get(key)?.title ?? key;
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: pad + index * (rowHeight + gap),
303
- width: boxWidth(theme, label, columnWidth),
304
- height: rowHeight,
305
- rank: rankOf.get(key) ?? null,
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
- place(spine, 'spine', spineX, spineWidth);
311
- place(left, 'left', leftX, gutterWidth);
312
- place(right, 'right', rightX, gutterWidth);
313
- const rows = Math.max(spine.length, left.length, right.length);
314
- const height = rows === 0 ? 0 : rows * (rowHeight + gap) - gap + pad * 2;
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: spine,
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
- const from = layout.nodes.get(edge.from);
374
- const to = layout.nodes.get(edge.to);
375
- if (from === undefined || to === undefined)
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
@@ -392,76 +660,4 @@ export function edgeGeometry(layout, edge) {
392
660
  endAngle: Math.atan2(end.y - controlY, end.x - controlX),
393
661
  };
394
662
  }
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
663
  //# sourceMappingURL=layout.js.map