flowfig 0.5.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,31 +48,21 @@ export const playheadItem = (items, beats, i) => {
62
48
  }
63
49
  return undefined;
64
50
  };
65
- /** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
51
+ export const loopStartItem = (items, steps) => playheadItem(items, (steps[0]?.flow ?? []).slice(0, 1).map(toBeat), 0);
66
52
  export const outsideLabelRect = (bar, label) => ({
67
53
  x: bar.x + bar.w + 6,
68
54
  y: bar.y,
69
55
  w: textWidth(label, 13),
70
56
  h: bar.h,
71
57
  });
72
- /**
73
- * The time span in which the playhead label shows its date. The label waits for the line to arrive: it stays hidden for
74
- * 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.
75
- */
76
58
  export const labelSpan = (t0, end, ramp) => [Math.min(t0 + ramp, end), end];
77
- /** True if a playhead date label at x would overlap the "today" label, so it moves to the top row. Widths are in px. */
78
59
  export const dateLabelRaised = (x, today, w, todayW) => today != null && x + 3 < today - 3 && x + 3 + w > today - 3 - todayW;
79
- /** The hold at the end of each step, before the next step starts. */
80
60
  export const STEP_HOLD_MS = 2000;
81
61
  export const nodes = (g) => g.children.flatMap((c) => (isGroup(c) ? nodes(c) : [c]));
82
- /** Lane layout constants, shared by both renderers: the gap between time columns, the padding inside a lane, and the gap between lanes. */
83
62
  export const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
84
- /** True if the layout can draw as lanes: a `column` group whose every child is a labeled group with boxes only. */
85
63
  export const isLanesLayout = (g) => g.direction === 'column' && g.children.every((c) => isGroup(c) && c.label != null && c.children.every((k) => !isGroup(k)));
86
- /** True if `at` is a usable time column: an integer of 0 or more. */
87
64
  export const validAt = (at) => typeof at === 'number' && Number.isInteger(at) && at >= 0;
88
- /** 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),
89
- * 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. */
90
66
  export function laneColumns(fig) {
91
67
  const byId = new Map(fig.edges.map((e) => [edgeId(e), e]));
92
68
  const cols = new Map();
@@ -110,11 +86,8 @@ export function laneColumns(fig) {
110
86
  cols.set(n.id, n.at);
111
87
  return cols;
112
88
  }
113
- /** The side of an SVG frame, and the width limits of a box with no card. The lane split measures with them. */
114
89
  export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
115
- /** The gap between two blocks of wrapped lanes. */
116
90
  export const LANE_BLOCK_GAP = 40;
117
- /** 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. */
118
91
  export const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
119
92
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
120
93
  export function nodeWidth(item, carded) {
@@ -126,11 +99,8 @@ export function nodeWidth(item, carded) {
126
99
  const sub = textWidth(str(item.sub), 12) + 32;
127
100
  return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, label, sub));
128
101
  }
129
- /** 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. */
130
102
  export const diamondRoom = (w, h, far) => w * Math.max(0, 1 - far / (h / 2)) - 8;
131
- /** 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
132
- * 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
133
- * 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. */
134
104
  export function diamondLines(item, w) {
135
105
  const sub = item.sub ? str(item.sub) : '';
136
106
  for (let n = sub ? 1 : 0;; n++) {
@@ -147,8 +117,7 @@ export function diamondLines(item, w) {
147
117
  };
148
118
  }
149
119
  }
150
- /** The lane copies (`laneIndex@block`) that hold a stub end with no place clear of every edge path. A renderer grows each by
151
- * 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. */
152
121
  export function tightCopies(lanes, ends, edges, routed) {
153
122
  const laneOf = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
154
123
  const out = new Set();
@@ -164,30 +133,21 @@ export function tightCopies(lanes, ends, edges, routed) {
164
133
  }
165
134
  return out;
166
135
  }
167
- /** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
168
136
  export const laneGutter = (lanes) => Math.max(0, ...lanes.map((l) => textWidth(str(l.label).toUpperCase(), 12))) + FRAME_SIDE * 2;
169
- /** The block of a time column, from the block starts of a plan. */
170
137
  export const laneBlock = (starts, c) => {
171
138
  let k = 0;
172
139
  while (k < starts.length - 2 && c >= starts[k + 1])
173
140
  k++;
174
141
  return k;
175
142
  };
176
- /** The pill texts of a cross-block edge: "label → Target" at the source, "from Source" at the target, and "→ Target", a
177
- * shorter source text for a pill that has no place for the long one. */
178
143
  export const stubTexts = (label, from, to) => [
179
144
  `${str(label) ? str(label) + ' ' : ''}→ ${str(to)}`,
180
145
  `from ${str(from)}`,
181
146
  ...(str(label) ? [`→ ${str(to)}`] : []),
182
147
  ];
183
148
  /**
184
- * 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
185
- * 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.
186
- * 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.
187
- * If the wrap cannot make the text readable, the lanes do not wrap: the smallest font is under `minText`, or `floor` (the least
188
- * width of the rest of the figure, such as the rail) is already too wide.
189
- * 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
190
- * 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.
191
151
  */
192
152
  export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor = 0 } = {}) {
193
153
  const cols = laneColumns(fig);
@@ -208,7 +168,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
208
168
  const laneOf = new Map(lanes.filter((l) => l.id != null).map((l) => [l.id, l]));
209
169
  const gutter = laneGutter(lanes);
210
170
  const plan = (count) => {
211
- // count blocks share the n columns evenly; the first n % count blocks take one column more.
212
171
  const starts = [0];
213
172
  for (let k = 0; k < count; k++)
214
173
  starts.push(starts[k] + Math.floor(n / count) + (k < n % count ? 1 : 0));
@@ -217,7 +176,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
217
176
  const held = [...new Set([...byId.keys()].map(blockOf))].sort((a, b) => a - b);
218
177
  const blocks = held.length ? held : [0];
219
178
  const wrapped = blocks.length > 1;
220
- // The blocks that show a lane: a wrapped block shows only the lanes with a box in it.
221
179
  const showing = (l) => wrapped ? [...new Set(l.children.map((b) => blockOf(b.id)))].sort((a, b) => a - b) : blocks;
222
180
  const stubs = new Map();
223
181
  const ends = new Map();
@@ -227,7 +185,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
227
185
  const [fl, tl] = [laneOf.get(e.from), laneOf.get(e.to)];
228
186
  if (!(byId.has(e.from) || fl) || !(byId.has(e.to) || tl))
229
187
  return;
230
- // 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.
231
188
  let miss = false;
232
189
  const near = (l, k, after) => {
233
190
  const ks = showing(l);
@@ -258,9 +215,7 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
258
215
  const texts = stubTexts(e.label, (byId.get(e.from) ?? fl).label, (byId.get(e.to) ?? tl).label);
259
216
  stubs.set(id, texts);
260
217
  });
261
- // 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.
262
- // 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
263
- // 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.
264
219
  const label = Array.from({ length: n }, () => 0);
265
220
  for (const e of fig.edges) {
266
221
  const [a, b] = [cols.get(e.from), cols.get(e.to)];
@@ -273,8 +228,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
273
228
  const gaps = colW.map((_, c) => (c === starts[colBlock(c) + 1] - 1 ? FRAME_SIDE : Math.max(LANE_GAP, label[c])));
274
229
  const per = Math.max(...starts.slice(1).map((e, k) => e - starts[k]));
275
230
  const lead = Array.from({ length: count }, () => 0);
276
- // 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
277
- // block count never depends on a pill. A pill with no room finds a free place in route() (geometry.ts).
278
231
  const want = (text) => STUB + labelPillW(text) + STUB_CLEAR;
279
232
  const fit = blocks.every((k) => widthOf(starts[k], starts[k + 1], gaps, 0) <= room);
280
233
  const out = new Map(), inn = new Map();
@@ -303,10 +256,9 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
303
256
  const need = (out.get(c) ?? 0) + (c < end - 1 ? (inn.get(c + 1) ?? 0) : 0);
304
257
  grow(need - gaps[c], () => (gaps[c] = need));
305
258
  if (gaps[c] >= need)
306
- roomOut.add(c).add(-1 - (c + 1)); // -1 - c marks room before column c
259
+ roomOut.add(c).add(-1 - (c + 1));
307
260
  }
308
261
  }
309
- // 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.
310
262
  const laneIndex = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
311
263
  const tall = new Set();
312
264
  for (const [id] of stubs) {
@@ -330,11 +282,8 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
330
282
  return [{ cols, starts, per, blocks, gutter, gaps, lead, ends, stubs, around, tall, lost }, fit];
331
283
  };
332
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);
333
- // The wrap only narrows the lanes. If the text stays too small at any width, keep one block, and check reports small-text.
334
285
  if (font < minText || floor > limit)
335
286
  return plan(1)[0];
336
- // 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.
337
- // The fit uses the boxes and the label gaps only, before a pill takes free room.
338
287
  for (let count = 1; count <= Math.max(1, Math.floor(n / 2)); count++) {
339
288
  const [p, fit] = plan(count);
340
289
  if (fit)
@@ -342,11 +291,7 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
342
291
  }
343
292
  return plan(1)[0];
344
293
  }
345
- /**
346
- * 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
347
- * 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
348
- * 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`.
349
- */
294
+ /** The rect id that an edge end routes to in wrapped lanes. A stub end is a point in the band. */
350
295
  export function laneEnd(plan, edge, id, start, band, rects) {
351
296
  const k = plan.ends.get(edge)?.[start ? 0 : 1];
352
297
  const b = k == null ? undefined : band(id, k);
@@ -358,10 +303,7 @@ export function laneEnd(plan, edge, id, start, band, rects) {
358
303
  rects[key] = texts ? { x, y: b.y, w: 0, h: b.h } : b;
359
304
  return key;
360
305
  }
361
- /** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
362
- * the axis strip height and the axis width of the SVG. */
363
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;
364
- /** 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. */
365
307
  export const dayOf = (iso) => {
366
308
  const m = typeof iso === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso) : null;
367
309
  if (!m)
@@ -373,14 +315,12 @@ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', '
373
315
  const dateOf = (day) => new Date(day * 86400000);
374
316
  // Day 0 is a Thursday, so this is 0 for Monday and 6 for Sunday.
375
317
  const weekday = (day) => (((day + 3) % 7) + 7) % 7;
376
- /** 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.
377
319
  const isoWeek = (day) => {
378
320
  const thu = day - weekday(day) + 3;
379
321
  const jan1 = Date.UTC(dateOf(thu).getUTCFullYear(), 0, 1) / 86400000;
380
322
  return Math.floor((thu - jan1) / 7) + 1;
381
323
  };
382
- /** The boxes with a real `from`, in layout order, with their track (the index of the top-level child) and their days.
383
- * A `to` that is not a date, or is before `from`, makes the box a milestone; `checkSpec` reports it. */
384
324
  const datedItems = (fig) => {
385
325
  const out = [];
386
326
  fig.layout.children.forEach((c, track) => nodes(isGroup(c) ? c : { children: [c] }).forEach((n) => {
@@ -402,7 +342,7 @@ export function timelineLayout(fig, axisWidth) {
402
342
  const start = first - weekday(first);
403
343
  let end = last + (6 - weekday(last));
404
344
  const px = (day) => ((day - start) / (end - start + 1)) * axisWidth;
405
- // 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.
406
346
  for (let pass = 0; pass < 4; pass++) {
407
347
  const over = Math.max(0, ...items.map((i) => {
408
348
  const x = i.to != null ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
@@ -424,22 +364,21 @@ export function timelineLayout(fig, axisWidth) {
424
364
  ticks.push({ x: px(d), label: MONTHS[dateOf(d).getUTCMonth()] });
425
365
  const rows = fig.layout.children.map(() => 1);
426
366
  const placed = [];
427
- const busy = fig.layout.children.map(() => []); // per track: the px end of the latest item in each row, with its label
428
- 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;
429
369
  for (const i of [...items].sort((a, b) => a.from - b.from)) {
430
370
  const bar = i.to != null;
431
371
  const x = bar ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
432
372
  const w = bar ? Math.max(TL_MIN_BAR, px(i.to + 1) - px(i.from)) : TL_DIAMOND;
433
373
  const need = textWidth(str(i.label.label), 13);
434
374
  const labelInside = bar && need + 16 <= w;
435
- let row = busy[i.track].findIndex((until) => until + GAP <= x);
375
+ let row = rowEnd[i.track].findIndex((until) => until + MIN_ITEM_GAP <= x);
436
376
  if (row < 0)
437
- row = busy[i.track].length;
438
- 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;
439
379
  rows[i.track] = Math.max(rows[i.track], row + 1);
440
380
  placed.push({ id: i.id, track: i.track, row, x, w, milestone: !bar, labelInside, date: fmt(i.from) });
441
381
  }
442
- // Keep the layout order in the result.
443
382
  const order = new Map(items.map((i, k) => [i.id, k]));
444
383
  placed.sort((a, b) => order.get(a.id) - order.get(b.id));
445
384
  return {
@@ -454,7 +393,7 @@ export function timelineLayout(fig, axisWidth) {
454
393
  };
455
394
  }
456
395
  const fmt = (d) => `${dateOf(d).getUTCDate()} ${MONTHS[dateOf(d).getUTCMonth()]}`;
457
- /** 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. */
458
397
  export function timelineBeats(fig) {
459
398
  const items = datedItems(fig).sort((a, b) => a.from - b.from);
460
399
  const flow = items.map((i, k) => ({
@@ -465,9 +404,7 @@ export function timelineBeats(fig) {
465
404
  return [{ label: 'timeline', flow }];
466
405
  }
467
406
  const labeledGroups = (g) => (g.label ? 1 : 0) + g.children.reduce((n, c) => n + (isGroup(c) ? labeledGroups(c) : 0), 0);
468
- /** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
469
407
  export const counts = (p) => {
470
- // A timeline with no steps plays one synthetic step.
471
408
  const st = p.timeline && isLanesLayout(p.layout) && !p.steps?.length ? timelineBeats(p) : (p.steps ?? []);
472
409
  return {
473
410
  boxes: nodes(p.layout).length,
@@ -477,12 +414,8 @@ export const counts = (p) => {
477
414
  messages: st.reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
478
415
  };
479
416
  };
480
- /** Every id inside a child: its own id, and the ids of all boxes and groups below it. */
481
417
  const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
482
- /**
483
- * 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:
484
- * 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.
485
- */
418
+ /** A row's default gap grows to hold its widest edge label, plus 8 px each side. */
486
419
  export function groupGap(g, edges) {
487
420
  if (g.gap != null)
488
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;