flowfig 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/model.js CHANGED
@@ -8,18 +8,14 @@ export const toBeat = (b) => {
8
8
  hops: hops.map((h) => (typeof h === 'string' ? { edge: h, back: false } : { back: false, ...h })),
9
9
  };
10
10
  };
11
- /** A React node can be an object too, so a row is told apart by its `text` key. */
11
+ // A React node can be an object, so test for the `text` key.
12
12
  export const isRows = (c) => Array.isArray(c) && c.every((r) => r != null && typeof r === 'object' && 'text' in r);
13
13
  export const edgeId = (e) => e.id ?? `${e.from}->${e.to}`;
14
14
  export const isGroup = (x) => 'children' in x;
15
15
  export const decisions = (g) => g.children.flatMap((c) => (isGroup(c) ? decisions(c) : c.shape === 'decision' ? [c.id] : []));
16
- /** The width of a content card in px. Both renderers use it, so a figure lays out the same way in each. */
17
16
  export const CARD_WIDTH = 166;
18
- /** The player runs 1.25 times faster than the timings in a figure: figure timings are written to be read slowly. The SVG matches it. */
19
17
  export const BASE_RATE = 1.25;
20
- /** The height of one text line in a content card, and the padding above and below the lines. */
21
18
  export const CARD_LINE = 15, CARD_PAD = 6;
22
- /** The fixed color of each row tone. */
23
19
  export const TONES = {
24
20
  blue: '#3b82f6',
25
21
  purple: '#8b5cf6',
@@ -28,32 +24,22 @@ export const TONES = {
28
24
  red: '#ef4444',
29
25
  gray: '#8b949e',
30
26
  };
31
- /** A tone as a fill under white text: darker, so the text keeps its contrast. */
32
27
  export const toneFill = (c) => `color-mix(in srgb, ${c} 64%, #000)`;
33
- /** The light tint of a tone over a background: the fill of a toned box. */
34
28
  export const toneTint = (c, bg, pct = 8) => `color-mix(in srgb, ${c} ${pct}%, ${bg})`;
35
- /** The width of an edge-label pill in px: the label text plus its padding. The rail uses it for a payload, so both look the same. */
36
29
  export const labelPillW = (label) => textWidth(label, 11, true) + 14;
37
- /** The width of the `async` tag in px. */
38
30
  export const ASYNC_TAG_W = textWidth('ASYNC', 9) + 8;
39
- /** The stroke width in px of an edge that a packet crosses now, and of an idle edge. */
40
31
  export const EDGE_ON = 2, EDGE_OFF = 1.25;
41
- /** The built-in colors. Both renderers and `flowfig check` read these, so they cannot drift apart. */
42
32
  export const LIGHT = { accent: '#0074d9', fg: '#1c1e21', muted: '#606770', bg: '#ffffff', surface: '#f5f7fa', border: '#d0d7de' };
43
- // The dark accent is darker than a typical dark-mode blue: white text sits on it and needs 4.5:1.
33
+ // White text on the dark accent needs 4.5:1 contrast.
44
34
  export const DARK = { accent: '#1f78c8', fg: '#e3e3e3', muted: '#9aa0a6', bg: '#1b1b1d', surface: '#242526', border: '#3a3b3c' };
45
- /** Text on an accent fill: the data chips and a lit edge label. */
46
35
  export const ON_ACCENT = '#ffffff';
47
- /** Only text survives outside React: a React element has no string form. */
36
+ // A React element has no string form, so only text survives.
48
37
  export const str = (n) => (typeof n === 'string' || typeof n === 'number' ? String(n) : '');
49
- /** The time a reader needs for a caption line: 400 ms plus 240 ms per word. The result is 0 for no text. */
50
38
  export const readMs = (say) => {
51
39
  const w = str(say).split(/\s+/).filter(Boolean).length;
52
40
  return w ? 400 + 240 * w : 0;
53
41
  };
54
- /** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
55
42
  export const beatMs = (b, speed) => b.ms ?? speed + readMs(b.say);
56
- /** The item the playhead rests on at beat `i`: the first dated id in the latest focus up to `i`, or undefined for home. */
57
43
  export const playheadItem = (items, beats, i) => {
58
44
  for (let k = Math.min(i, beats.length - 1); k >= 0; k--) {
59
45
  const id = (beats[k].focus ?? []).find((f) => items.some((x) => x.id === f));
@@ -62,33 +48,21 @@ export const playheadItem = (items, beats, i) => {
62
48
  }
63
49
  return undefined;
64
50
  };
65
- /** The item the playhead rests on when the loop starts: the playhead item of the first beat of the first step. */
66
51
  export const loopStartItem = (items, steps) => playheadItem(items, (steps[0]?.flow ?? []).slice(0, 1).map(toBeat), 0);
67
- /** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
68
52
  export const outsideLabelRect = (bar, label) => ({
69
53
  x: bar.x + bar.w + 6,
70
54
  y: bar.y,
71
55
  w: textWidth(label, 13),
72
56
  h: bar.h,
73
57
  });
74
- /**
75
- * The time span in which the playhead label shows its date. The label waits for the line to arrive: it stays hidden for
76
- * the ramp (the move), so it never shows a date at a place that is not that date, and it never meets "today" on the way.
77
- */
78
58
  export const labelSpan = (t0, end, ramp) => [Math.min(t0 + ramp, end), end];
79
- /** True if a playhead date label at x would overlap the "today" label, so it moves to the top row. Widths are in px. */
80
59
  export const dateLabelRaised = (x, today, w, todayW) => today != null && x + 3 < today - 3 && x + 3 + w > today - 3 - todayW;
81
- /** The hold at the end of each step, before the next step starts. */
82
60
  export const STEP_HOLD_MS = 2000;
83
61
  export const nodes = (g) => g.children.flatMap((c) => (isGroup(c) ? nodes(c) : [c]));
84
- /** Lane layout constants, shared by both renderers: the gap between time columns, the padding inside a lane, and the gap between lanes. */
85
62
  export const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
86
- /** True if the layout can draw as lanes: a `column` group whose every child is a labeled group with boxes only. */
87
63
  export const isLanesLayout = (g) => g.direction === 'column' && g.children.every((c) => isGroup(c) && c.label != null && c.children.every((k) => !isGroup(k)));
88
- /** True if `at` is a usable time column: an integer of 0 or more. */
89
64
  export const validAt = (at) => typeof at === 'number' && Number.isInteger(at) && at >= 0;
90
- /** The time column of each box in a lanes figure: its first appearance in the steps (from, then to; a back hop to, then from),
91
- * then the boxes no step touches in layout order. `at` overrides. Two boxes may share a column. */
65
+ /** The time column of each box: first use in the steps, then layout order. `at` wins. */
92
66
  export function laneColumns(fig) {
93
67
  const byId = new Map(fig.edges.map((e) => [edgeId(e), e]));
94
68
  const cols = new Map();
@@ -112,11 +86,8 @@ export function laneColumns(fig) {
112
86
  cols.set(n.id, n.at);
113
87
  return cols;
114
88
  }
115
- /** The side of an SVG frame, and the width limits of a box with no card. The lane split measures with them. */
116
89
  export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
117
- /** The gap between two blocks of wrapped lanes. */
118
90
  export const LANE_BLOCK_GAP = 40;
119
- /** The length of a stub line between a box and its pill, the clear space past a pill, and the room a lane grows by for a pill. */
120
91
  export const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
121
92
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
122
93
  export function nodeWidth(item, carded) {
@@ -128,11 +99,8 @@ export function nodeWidth(item, carded) {
128
99
  const sub = textWidth(str(item.sub), 12) + 32;
129
100
  return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, label, sub));
130
101
  }
131
- /** The room a diamond of size w by h gives a text line whose far edge sits `far` px from the center: the width of the outline there, less 8 px. */
132
102
  export const diamondRoom = (w, h, far) => w * Math.max(0, 1 - far / (h / 2)) - 8;
133
- /** The lines of a diamond with no card. The sub wraps to the room the outline gives its last line; the diamond grows taller by 15 px
134
- * for each line until the lines fit. `dy` is the baseline of each line below the center, as the SVG draws it: the label sits 2 px
135
- * above the center over a one-line sub, and 5 px below it with no sub. `far` is the distance from the center to the far edge of the text. */
103
+ /** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
136
104
  export function diamondLines(item, w) {
137
105
  const sub = item.sub ? str(item.sub) : '';
138
106
  for (let n = sub ? 1 : 0;; n++) {
@@ -149,8 +117,7 @@ export function diamondLines(item, w) {
149
117
  };
150
118
  }
151
119
  }
152
- /** The lane copies (`laneIndex@block`) that hold a stub end with no place clear of every edge path. A renderer grows each by
153
- * STUB_ROOM and routes again: the pill then fits under or over its box, inside the band. Both renderers call it with their routes. */
120
+ /** The lane copies whose stub end has no clear place. The renderer grows each by STUB_ROOM. */
154
121
  export function tightCopies(lanes, ends, edges, routed) {
155
122
  const laneOf = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
156
123
  const out = new Set();
@@ -166,30 +133,21 @@ export function tightCopies(lanes, ends, edges, routed) {
166
133
  }
167
134
  return out;
168
135
  }
169
- /** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
170
136
  export const laneGutter = (lanes) => Math.max(0, ...lanes.map((l) => textWidth(str(l.label).toUpperCase(), 12))) + FRAME_SIDE * 2;
171
- /** The block of a time column, from the block starts of a plan. */
172
137
  export const laneBlock = (starts, c) => {
173
138
  let k = 0;
174
139
  while (k < starts.length - 2 && c >= starts[k + 1])
175
140
  k++;
176
141
  return k;
177
142
  };
178
- /** The pill texts of a cross-block edge: "label → Target" at the source, "from Source" at the target, and "→ Target", a
179
- * shorter source text for a pill that has no place for the long one. */
180
143
  export const stubTexts = (label, from, to) => [
181
144
  `${str(label) ? str(label) + ' ' : ''}→ ${str(to)}`,
182
145
  `from ${str(from)}`,
183
146
  ...(str(label) ? [`→ ${str(to)}`] : []),
184
147
  ];
185
148
  /**
186
- * The wrap of a lanes figure. If all the time columns do not fit `width` with text at `minText` or more, the lanes wrap like a
187
- * line of text: block 1 holds columns 0..n-1, block 2 holds n..2n-1. n is the largest count that fits; it is 1 at least.
188
- * The text test is the one `check` runs: the smallest text (11 px, or 10.5 px with a mono card row) times width / figure width.
189
- * If the wrap cannot make the text readable, the lanes do not wrap: the smallest font is under `minText`, or `floor` (the least
190
- * width of the rest of the figure, such as the rail) is already too wide.
191
- * The blocks share the columns as evenly as they can, and a block holds 2 columns at least: if 2 do not fit, the lanes keep
192
- * one block. An edge between two blocks becomes two stubs with pills. A pill takes no room of its own, so n depends on the boxes.
149
+ * The wrap of a lanes figure: it wraps like text when the time columns do not fit `width`.
150
+ * The block count depends on the boxes only, not on pills.
193
151
  */
194
152
  export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor = 0 } = {}) {
195
153
  const cols = laneColumns(fig);
@@ -210,7 +168,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
210
168
  const laneOf = new Map(lanes.filter((l) => l.id != null).map((l) => [l.id, l]));
211
169
  const gutter = laneGutter(lanes);
212
170
  const plan = (count) => {
213
- // count blocks share the n columns evenly; the first n % count blocks take one column more.
214
171
  const starts = [0];
215
172
  for (let k = 0; k < count; k++)
216
173
  starts.push(starts[k] + Math.floor(n / count) + (k < n % count ? 1 : 0));
@@ -219,7 +176,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
219
176
  const held = [...new Set([...byId.keys()].map(blockOf))].sort((a, b) => a - b);
220
177
  const blocks = held.length ? held : [0];
221
178
  const wrapped = blocks.length > 1;
222
- // The blocks that show a lane: a wrapped block shows only the lanes with a box in it.
223
179
  const showing = (l) => wrapped ? [...new Set(l.children.map((b) => blockOf(b.id)))].sort((a, b) => a - b) : blocks;
224
180
  const stubs = new Map();
225
181
  const ends = new Map();
@@ -229,7 +185,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
229
185
  const [fl, tl] = [laneOf.get(e.from), laneOf.get(e.to)];
230
186
  if (!(byId.has(e.from) || fl) || !(byId.has(e.to) || tl))
231
187
  return;
232
- // A lane source takes the last block at or before the target that shows it; a lane target the first at or after the source.
233
188
  let miss = false;
234
189
  const near = (l, k, after) => {
235
190
  const ks = showing(l);
@@ -260,9 +215,7 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
260
215
  const texts = stubTexts(e.label, (byId.get(e.from) ?? fl).label, (byId.get(e.to) ?? tl).label);
261
216
  stubs.set(id, texts);
262
217
  });
263
- // A pill takes no room of its own: the route puts it in free space (route() in geometry.ts), so n depends on the boxes only.
264
- // A label on an edge between two next columns of one block sits in the gap between them. A label too wide for the gap would
265
- // cover a box, so that gap grows to the label plus 8 px. A label that fits keeps the gap of 0.4.0.
218
+ // A label wider than the gap would cover a box, so the gap grows.
266
219
  const label = Array.from({ length: n }, () => 0);
267
220
  for (const e of fig.edges) {
268
221
  const [a, b] = [cols.get(e.from), cols.get(e.to)];
@@ -275,8 +228,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
275
228
  const gaps = colW.map((_, c) => (c === starts[colBlock(c) + 1] - 1 ? FRAME_SIDE : Math.max(LANE_GAP, label[c])));
276
229
  const per = Math.max(...starts.slice(1).map((e, k) => e - starts[k]));
277
230
  const lead = Array.from({ length: count }, () => 0);
278
- // A pill takes free room only: a gap next to a stub end grows for its pill while the block still fits the width. So the
279
- // block count never depends on a pill. A pill with no room finds a free place in route() (geometry.ts).
280
231
  const want = (text) => STUB + labelPillW(text) + STUB_CLEAR;
281
232
  const fit = blocks.every((k) => widthOf(starts[k], starts[k + 1], gaps, 0) <= room);
282
233
  const out = new Map(), inn = new Map();
@@ -305,10 +256,9 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
305
256
  const need = (out.get(c) ?? 0) + (c < end - 1 ? (inn.get(c + 1) ?? 0) : 0);
306
257
  grow(need - gaps[c], () => (gaps[c] = need));
307
258
  if (gaps[c] >= need)
308
- roomOut.add(c).add(-1 - (c + 1)); // -1 - c marks room before column c
259
+ roomOut.add(c).add(-1 - (c + 1));
309
260
  }
310
261
  }
311
- // A stub end with no room beside its box makes its lane taller in that block: the pill then fits under the box, inside the band.
312
262
  const laneIndex = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
313
263
  const tall = new Set();
314
264
  for (const [id] of stubs) {
@@ -332,11 +282,8 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
332
282
  return [{ cols, starts, per, blocks, gutter, gaps, lead, ends, stubs, around, tall, lost }, fit];
333
283
  };
334
284
  const widthOf = (c, end, gaps, lead) => gutter + lead + colW.slice(c, end).reduce((a, w) => a + w, 0) + gaps.slice(c, end).reduce((a, g) => a + g, 0);
335
- // The wrap only narrows the lanes. If the text stays too small at any width, keep one block, and check reports small-text.
336
285
  if (font < minText || floor > limit)
337
286
  return plan(1)[0];
338
- // A block has 2 columns at least: one column per block reads as a list, not as lanes. If 2 do not fit, keep one block.
339
- // The fit uses the boxes and the label gaps only, before a pill takes free room.
340
287
  for (let count = 1; count <= Math.max(1, Math.floor(n / 2)); count++) {
341
288
  const [p, fit] = plan(count);
342
289
  if (fit)
@@ -344,11 +291,7 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
344
291
  }
345
292
  return plan(1)[0];
346
293
  }
347
- /**
348
- * The rect id that an edge end routes to in wrapped lanes. A box keeps its id. A lane has one band per block, so a lane end names
349
- * the band in the block from the plan (`band` finds it). A stub end at a lane is a point inside that band: the source at the band
350
- * end, past the room for its pill, and the target at the first column of the block. The function adds the rect it names to `rects`.
351
- */
294
+ /** The rect id that an edge end routes to in wrapped lanes. A stub end is a point in the band. */
352
295
  export function laneEnd(plan, edge, id, start, band, rects) {
353
296
  const k = plan.ends.get(edge)?.[start ? 0 : 1];
354
297
  const b = k == null ? undefined : band(id, k);
@@ -360,10 +303,7 @@ export function laneEnd(plan, edge, id, start, band, rects) {
360
303
  rects[key] = texts ? { x, y: b.y, w: 0, h: b.h } : b;
361
304
  return key;
362
305
  }
363
- /** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
364
- * the axis strip height and the axis width of the SVG. */
365
306
  export const TL_BAR_H = 28, TL_ROW_GAP = 8, TL_MIN_BAR = 24, TL_DIAMOND = 14, TL_AXIS_H = 28, TL_AXIS_W = 640;
366
- /** The days since 1970-01-01 (UTC) of a YYYY-MM-DD date. It is null for any other text and for a date that does not exist. */
367
307
  export const dayOf = (iso) => {
368
308
  const m = typeof iso === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso) : null;
369
309
  if (!m)
@@ -375,14 +315,12 @@ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', '
375
315
  const dateOf = (day) => new Date(day * 86400000);
376
316
  // Day 0 is a Thursday, so this is 0 for Monday and 6 for Sunday.
377
317
  const weekday = (day) => (((day + 3) % 7) + 7) % 7;
378
- /** The ISO 8601 week number of a day: the week of the year's first Thursday is week 1. */
318
+ // ISO 8601: the week with the first Thursday of the year is week 1.
379
319
  const isoWeek = (day) => {
380
320
  const thu = day - weekday(day) + 3;
381
321
  const jan1 = Date.UTC(dateOf(thu).getUTCFullYear(), 0, 1) / 86400000;
382
322
  return Math.floor((thu - jan1) / 7) + 1;
383
323
  };
384
- /** The boxes with a real `from`, in layout order, with their track (the index of the top-level child) and their days.
385
- * A `to` that is not a date, or is before `from`, makes the box a milestone; `checkSpec` reports it. */
386
324
  const datedItems = (fig) => {
387
325
  const out = [];
388
326
  fig.layout.children.forEach((c, track) => nodes(isGroup(c) ? c : { children: [c] }).forEach((n) => {
@@ -404,7 +342,7 @@ export function timelineLayout(fig, axisWidth) {
404
342
  const start = first - weekday(first);
405
343
  let end = last + (6 - weekday(last));
406
344
  const px = (day) => ((day - start) / (end - start + 1)) * axisWidth;
407
- // A label right of the last items may pass the axis end. Add whole weeks and lay out again; after 4 passes the label stays and `check` reports it.
345
+ // After 4 passes the label stays, and `check` reports it.
408
346
  for (let pass = 0; pass < 4; pass++) {
409
347
  const over = Math.max(0, ...items.map((i) => {
410
348
  const x = i.to != null ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
@@ -426,22 +364,21 @@ export function timelineLayout(fig, axisWidth) {
426
364
  ticks.push({ x: px(d), label: MONTHS[dateOf(d).getUTCMonth()] });
427
365
  const rows = fig.layout.children.map(() => 1);
428
366
  const placed = [];
429
- const busy = fig.layout.children.map(() => []); // per track: the px end of the latest item in each row, with its label
430
- const GAP = 8; // the least px between two items in a row
367
+ const rowEnd = fig.layout.children.map(() => []);
368
+ const MIN_ITEM_GAP = 8;
431
369
  for (const i of [...items].sort((a, b) => a.from - b.from)) {
432
370
  const bar = i.to != null;
433
371
  const x = bar ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
434
372
  const w = bar ? Math.max(TL_MIN_BAR, px(i.to + 1) - px(i.from)) : TL_DIAMOND;
435
373
  const need = textWidth(str(i.label.label), 13);
436
374
  const labelInside = bar && need + 16 <= w;
437
- let row = busy[i.track].findIndex((until) => until + GAP <= x);
375
+ let row = rowEnd[i.track].findIndex((until) => until + MIN_ITEM_GAP <= x);
438
376
  if (row < 0)
439
- row = busy[i.track].length;
440
- busy[i.track][row] = labelInside ? x + w : x + w + 6 + need;
377
+ row = rowEnd[i.track].length;
378
+ rowEnd[i.track][row] = labelInside ? x + w : x + w + 6 + need;
441
379
  rows[i.track] = Math.max(rows[i.track], row + 1);
442
380
  placed.push({ id: i.id, track: i.track, row, x, w, milestone: !bar, labelInside, date: fmt(i.from) });
443
381
  }
444
- // Keep the layout order in the result.
445
382
  const order = new Map(items.map((i, k) => [i.id, k]));
446
383
  placed.sort((a, b) => order.get(a.id) - order.get(b.id));
447
384
  return {
@@ -456,7 +393,7 @@ export function timelineLayout(fig, axisWidth) {
456
393
  };
457
394
  }
458
395
  const fmt = (d) => `${dateOf(d).getUTCDate()} ${MONTHS[dateOf(d).getUTCMonth()]}`;
459
- /** One step, "timeline", with one beat for each dated item in date order. The beat lights the item and says "label, d MMM to d MMM" (a milestone: "label, d MMM"), then " · sub". */
396
+ /** One step with one beat for each dated item, in date order. */
460
397
  export function timelineBeats(fig) {
461
398
  const items = datedItems(fig).sort((a, b) => a.from - b.from);
462
399
  const flow = items.map((i, k) => ({
@@ -467,9 +404,7 @@ export function timelineBeats(fig) {
467
404
  return [{ label: 'timeline', flow }];
468
405
  }
469
406
  const labeledGroups = (g) => (g.label ? 1 : 0) + g.children.reduce((n, c) => n + (isGroup(c) ? labeledGroups(c) : 0), 0);
470
- /** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
471
407
  export const counts = (p) => {
472
- // A timeline with no steps plays one synthetic step.
473
408
  const st = p.timeline && isLanesLayout(p.layout) && !p.steps?.length ? timelineBeats(p) : (p.steps ?? []);
474
409
  return {
475
410
  boxes: nodes(p.layout).length,
@@ -479,12 +414,8 @@ export const counts = (p) => {
479
414
  messages: st.reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
480
415
  };
481
416
  };
482
- /** Every id inside a child: its own id, and the ids of all boxes and groups below it. */
483
417
  const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
484
- /**
485
- * A group's gap. A row's default gap grows to hold the widest label of an edge between two of its children, with 8px each side:
486
- * the label sits at the middle of that gap. A gap the author sets stays as set; `flowfig check` reports it if it is too narrow.
487
- */
418
+ /** A row's default gap grows to hold its widest edge label, plus 8 px each side. */
488
419
  export function groupGap(g, edges) {
489
420
  if (g.gap != null)
490
421
  return g.gap;
package/dist/open.d.ts CHANGED
@@ -3,13 +3,10 @@ export type Opener = {
3
3
  args: string[];
4
4
  verbatim: boolean;
5
5
  };
6
- /** The command that opens a file in the default program of the system. Pure. */
7
6
  export declare function openerFor(platform: NodeJS.Platform, file: string): Opener;
8
- /** The name of the temp page: the SVG base name, each character other than a letter, a digit, - or _ as -. */
7
+ /** The temp page name: each character other than a letter, digit, - or _ becomes -. */
9
8
  export declare function pageName(svgPath: string): string;
10
- /** The line that `open`, a render with `--open` and `draw --open` print. */
11
9
  export declare const openedLine: (page: string) => string;
12
- /** Write the page to a new temp folder (and to `html`, if given), then start the opener. Returns the written paths. */
10
+ /** Write the page to a new temp folder, then start the opener. Returns the written paths. */
13
11
  export declare function openSvg(svgPath: string, html?: string): Promise<string[]>;
14
- /** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
15
12
  export declare function runOpen(argv: string[]): Promise<number>;
package/dist/open.js CHANGED
@@ -1,39 +1,35 @@
1
- // `flowfig open`: show a figure in the default browser. An .svg file often opens in an editor; an .html page opens in a browser.
2
1
  import { spawn } from 'node:child_process';
3
2
  import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
3
  import { tmpdir } from 'node:os';
5
4
  import { basename, join } from 'node:path';
6
5
  import { pageHtml } from './page.js';
7
6
  const USAGE = 'usage: flowfig open <figure.svg> [--html <path>]';
8
- /** The command that opens a file in the default program of the system. Pure. */
9
7
  export function openerFor(platform, file) {
10
8
  if (platform === 'darwin')
11
9
  return { cmd: 'open', args: [file], verbatim: false };
12
- // `start` reads its first quoted argument as a window title, so an empty title goes first.
10
+ // `start` reads its first quoted argument as a window title.
13
11
  if (platform === 'win32')
14
12
  return { cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`], verbatim: true };
15
13
  return { cmd: 'xdg-open', args: [file], verbatim: false };
16
14
  }
17
- /** The name of the temp page: the SVG base name, each character other than a letter, a digit, - or _ as -. */
15
+ /** The temp page name: each character other than a letter, digit, - or _ becomes -. */
18
16
  export function pageName(svgPath) {
19
- // The safe name also keeps cmd metacharacters such as & out of the Windows command line.
17
+ // The safe name keeps cmd metacharacters such as & out of the command line.
20
18
  return (basename(svgPath)
21
19
  .replace(/\.svg$/, '')
22
20
  .replace(/[^\w-]/g, '-') + '.html');
23
21
  }
24
- /** The line that `open`, a render with `--open` and `draw --open` print. */
25
22
  export const openedLine = (page) => `${page} — opened in the default browser`;
26
- /** Write the page to a new temp folder (and to `html`, if given), then start the opener. Returns the written paths. */
23
+ /** Write the page to a new temp folder, then start the opener. Returns the written paths. */
27
24
  export async function openSvg(svgPath, html) {
28
25
  const page = pageHtml(readFileSync(svgPath, 'utf8'), basename(svgPath));
29
- // A fresh private folder (mode 0700): a fixed shared path lets another user plant a symlink there.
26
+ // A fixed shared path lets another user plant a symlink.
30
27
  const dir = mkdtempSync(join(tmpdir(), 'flowfig-open-'));
31
- // The page stays after the exit: the browser reads it after the CLI stops.
28
+ // The browser reads the page after the CLI stops, so keep it.
32
29
  const temp = join(dir, pageName(svgPath));
33
30
  writeFileSync(temp, page);
34
31
  if (html)
35
32
  writeFileSync(html, page);
36
- // FLOWFIG_OPENER replaces the system opener, so the tests start no browser.
37
33
  const o = process.env.FLOWFIG_OPENER
38
34
  ? { cmd: process.env.FLOWFIG_OPENER, args: [temp], verbatim: false }
39
35
  : openerFor(process.platform, temp);
@@ -44,7 +40,6 @@ export async function openSvg(svgPath, html) {
44
40
  });
45
41
  return html ? [temp, html] : [temp];
46
42
  }
47
- /** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
48
43
  export async function runOpen(argv) {
49
44
  const fail = (message, code) => (console.error(message), code);
50
45
  const args = [...argv];
package/dist/page.d.ts CHANGED
@@ -1,2 +1 @@
1
- /** The page: the figure SVG inline, centered, scaled down to the window width. The title is escaped. */
2
1
  export declare function pageHtml(svg: string, title: string): string;
package/dist/page.js CHANGED
@@ -1,8 +1,6 @@
1
- // The page that holds a figure SVG: `flowfig open` shows it in the default browser, and `flowfig gif` captures it. Pure.
2
1
  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3
- /** The page: the figure SVG inline, centered, scaled down to the window width. The title is escaped. */
4
2
  export function pageHtml(svg, title) {
5
- // Element selectors only: the figure styles use class names, so the two sets of rules do not meet.
3
+ // Use element selectors only: figure styles use class names.
6
4
  return `<!doctype html>
7
5
  <meta charset="utf-8">
8
6
  <title>${esc(title)}</title>
package/dist/picker.d.ts CHANGED
@@ -23,11 +23,9 @@ export type Picked = {
23
23
  ids: Set<string>;
24
24
  mcp: boolean;
25
25
  };
26
- /** The banner lines. A terminal narrower than the banner gets the plain version line. */
26
+ /** A terminal narrower than the banner gets the plain version line. */
27
27
  export declare function banner({ color, width }: Screen): string[];
28
- /** The picker screen, one string per line. */
29
28
  export declare function screen(s: PickState, o: Screen): string[];
30
- /** One key press. Returns the next state, or `confirm` or `cancel`. */
31
29
  export declare function keyStep(s: PickState, k: Key): PickState | 'confirm' | 'cancel';
32
- /** Run the picker on the terminal. Returns `undefined` on cancel, or `'fallback'` if the terminal has no raw mode. */
30
+ /** Returns `undefined` on cancel, or `'fallback'` if the terminal has no raw mode. */
33
31
  export declare function runPicker(rows: PickRow[], start: Set<string>, mcp: boolean): Promise<Picked | undefined | 'fallback'>;
package/dist/picker.js CHANGED
@@ -1,4 +1,3 @@
1
- // The arrow-key picker for `flowfig init`. The screen and the keys are pure, so the tests need no TTY.
2
1
  import { emitKeypressEvents } from 'node:readline';
3
2
  import { VERSION } from './version.js';
4
3
  const BANNER = [
@@ -10,26 +9,22 @@ const BANNER = [
10
9
  '╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═════╝ ',
11
10
  ];
12
11
  const BANNER_WIDTH = Math.max(...BANNER.map((l) => l.length));
13
- const NOT = '(not detected)'.length; // the width of the detected column
12
+ const NOT = '(not detected)'.length;
14
13
  const HELP = '↑↓ move · space toggle · a all · enter confirm · esc cancel';
15
14
  const SHADOW = '38;2;43;93;143';
16
15
  const ansi = (on, code, text) => (on ? `\x1b[${code}m${text}\x1b[0m` : text);
17
- /** Row `i` of `n`: the blue gradient from #4da3ff to #0074d9. */
18
16
  const blue = (i, n) => {
19
17
  const mix = (a, b) => Math.round(a + ((b - a) * i) / (n - 1));
20
18
  return `38;2;${mix(0x4d, 0x00)};${mix(0xa3, 0x74)};${mix(0xff, 0xd9)}`;
21
19
  };
22
- /** The banner lines. A terminal narrower than the banner gets the plain version line. */
20
+ /** A terminal narrower than the banner gets the plain version line. */
23
21
  export function banner({ color, width }) {
24
22
  const ver = `flowfig ${VERSION}`;
25
23
  if (width < BANNER_WIDTH)
26
24
  return [ansi(color, '1;38;2;77;163;255', ver)];
27
- const art = BANNER.map((l, i) =>
28
- // each run of one character type gets its own colour: the block letters take the gradient, the shadow takes dim blue
29
- l.replace(/█+|[╗║╚═╔╝]+/g, (run) => ansi(color, run[0] === '█' ? blue(i, BANNER.length - 1) : SHADOW, run)));
25
+ const art = BANNER.map((l, i) => l.replace(/█+|[╗║╚═╔╝]+/g, (run) => ansi(color, run[0] === '█' ? blue(i, BANNER.length - 1) : SHADOW, run)));
30
26
  return [...art, ansi(color, '90', ver)];
31
27
  }
32
- /** Cut the pieces `[text, colour code]` to `width` visible characters, then colour them. */
33
28
  function fit(pieces, width, color) {
34
29
  let room = width;
35
30
  let out = '';
@@ -42,9 +37,9 @@ function fit(pieces, width, color) {
42
37
  }
43
38
  return out;
44
39
  }
45
- /** The picker screen, one string per line. */
46
40
  export function screen(s, o) {
47
- const w = Math.max(o.width - 1, 1); // the last column stays free, so no row wraps
41
+ // Leave the last column free, so no row wraps.
42
+ const w = Math.max(o.width - 1, 1);
48
43
  const pad = Math.max(...s.rows.map((r) => r.name.length), 10);
49
44
  const mark = (i) => (i === s.cursor ? '›' : ' ');
50
45
  const rows = s.rows.map((r, i) => fit([
@@ -70,9 +65,8 @@ export function screen(s, o) {
70
65
  fit([[HELP, '90']], w, o.color),
71
66
  ];
72
67
  }
73
- /** One key press. Returns the next state, or `confirm` or `cancel`. */
74
68
  export function keyStep(s, k) {
75
- const last = s.rows.length; // the MCP row
69
+ const last = s.rows.length;
76
70
  const n = last + 1;
77
71
  if (k.name === 'escape' || k.name === 'q' || (k.ctrl && k.name === 'c'))
78
72
  return 'cancel';
@@ -87,14 +81,17 @@ export function keyStep(s, k) {
87
81
  return { ...s, mcp: !s.mcp };
88
82
  const id = s.rows[s.cursor].id;
89
83
  const sel = new Set(s.sel);
90
- sel.has(id) ? sel.delete(id) : sel.add(id);
84
+ if (sel.has(id))
85
+ sel.delete(id);
86
+ else
87
+ sel.add(id);
91
88
  return { ...s, sel };
92
89
  }
93
90
  if (k.name === 'a')
94
91
  return { ...s, sel: s.sel.size === last ? new Set() : new Set(s.rows.map((r) => r.id)) };
95
92
  return s;
96
93
  }
97
- /** Run the picker on the terminal. Returns `undefined` on cancel, or `'fallback'` if the terminal has no raw mode. */
94
+ /** Returns `undefined` on cancel, or `'fallback'` if the terminal has no raw mode. */
98
95
  export function runPicker(rows, start, mcp) {
99
96
  const { stdin, stdout } = process;
100
97
  if (!stdin.isTTY || typeof stdin.setRawMode !== 'function')
package/dist/png.js CHANGED
@@ -1,4 +1,4 @@
1
- // Read the PNG screenshots of the capture browser. Only what Chrome writes: 8-bit RGB or RGBA, no interlace.
1
+ // Reads only what Chrome writes: 8-bit RGB or RGBA, no interlace.
2
2
  import { inflateSync } from 'node:zlib';
3
3
  const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
4
4
  export function decodePng(png) {
@@ -7,7 +7,6 @@ export function decodePng(png) {
7
7
  const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
8
8
  let width = 0, height = 0, bpp = 0;
9
9
  const idat = [];
10
- // The CRC of each chunk is not checked: the PNG comes from the browser over a local pipe.
11
10
  for (let at = 8; at + 8 <= png.length;) {
12
11
  const len = view.getUint32(at);
13
12
  const kind = String.fromCharCode(...png.subarray(at + 4, at + 8));
@@ -36,11 +35,12 @@ export function decodePng(png) {
36
35
  if (raw.length < height * (stride + 1))
37
36
  throw new Error('not a PNG: the image data is too short');
38
37
  const data = new Uint8Array(width * height * 4);
38
+ const data32 = new Uint32Array(data.buffer);
39
39
  let prev = new Uint8Array(stride);
40
40
  for (let y = 0; y < height; y++) {
41
41
  const filter = raw[y * (stride + 1)];
42
42
  const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
43
- // Undo the row filter in place. A Uint8Array write keeps the low 8 bits, as the PNG standard needs.
43
+ // A Uint8Array write keeps the low 8 bits, as the PNG standard needs.
44
44
  if (filter === 1)
45
45
  for (let x = bpp; x < stride; x++)
46
46
  row[x] += row[x - bpp];
@@ -53,17 +53,16 @@ export function decodePng(png) {
53
53
  else if (filter === 4)
54
54
  for (let x = 0; x < stride; x++) {
55
55
  const a = x >= bpp ? row[x - bpp] : 0, b = prev[x], c = x >= bpp ? prev[x - bpp] : 0;
56
- const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
56
+ const p = a + b - c, pa = p > a ? p - a : a - p, pb = p > b ? p - b : b - p, pc = p > c ? p - c : c - p;
57
57
  row[x] += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
58
58
  }
59
59
  else if (filter !== 0)
60
60
  throw new Error(`unsupported PNG: filter ${filter}`);
61
- for (let x = 0, o = y * width * 4; x < stride; x += bpp, o += 4) {
62
- data[o] = row[x];
63
- data[o + 1] = row[x + 1];
64
- data[o + 2] = row[x + 2];
65
- data[o + 3] = bpp === 4 ? row[x + 3] : 255;
66
- }
61
+ if (bpp === 4)
62
+ data.set(row, y * stride);
63
+ else
64
+ for (let x = 0, o = y * width; x < stride; x += 3)
65
+ data32[o++] = (row[x + 2] << 16) | (row[x + 1] << 8) | row[x] | 0xff000000;
67
66
  prev = row;
68
67
  }
69
68
  return { width, height, data };
package/dist/rail.d.ts CHANGED
@@ -29,7 +29,6 @@ export type RailRow = {
29
29
  step: number;
30
30
  label: string;
31
31
  messages: number;
32
- /** The divider: it starts after the open label or after the folded label, and ends before the counter. */
33
32
  line: {
34
33
  open: number;
35
34
  folded: number;
@@ -58,7 +57,7 @@ export type RailState = {
58
57
  };
59
58
  export type Rail = {
60
59
  width: number;
61
- /** The y where the first row starts: below the band-label line (if a band exists) and the column labels. */
60
+ /** The y where the first row starts. */
62
61
  head: number;
63
62
  height: number;
64
63
  columns: RailColumn[];
@@ -72,7 +71,6 @@ export type Rail = {
72
71
  folds: boolean;
73
72
  states: RailState[][];
74
73
  };
75
- /** The folded phase text. Both renderers and the layout use it, so the measured width matches the drawn width. */
76
74
  export declare const foldedLabel: (row: {
77
75
  label: string;
78
76
  messages: number;