flowfig 0.3.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.
package/dist/model.js CHANGED
@@ -1,4 +1,4 @@
1
- import { textWidth } from './text.js';
1
+ import { textWidth, wrap } from './text.js';
2
2
  export const toBeat = (b) => {
3
3
  const isBeat = typeof b === 'object' && !Array.isArray(b) && !('edge' in b);
4
4
  const { edges, ...rest } = isBeat ? b : { edges: b };
@@ -53,18 +53,430 @@ export const readMs = (say) => {
53
53
  };
54
54
  /** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
55
55
  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
+ export const playheadItem = (items, beats, i) => {
58
+ for (let k = Math.min(i, beats.length - 1); k >= 0; k--) {
59
+ const id = (beats[k].focus ?? []).find((f) => items.some((x) => x.id === f));
60
+ if (id != null)
61
+ return items.find((x) => x.id === id);
62
+ }
63
+ return undefined;
64
+ };
65
+ /** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
66
+ export const outsideLabelRect = (bar, label) => ({
67
+ x: bar.x + bar.w + 6,
68
+ y: bar.y,
69
+ w: textWidth(label, 13),
70
+ h: bar.h,
71
+ });
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
+ 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
+ export const dateLabelRaised = (x, today, w, todayW) => today != null && x + 3 < today - 3 && x + 3 + w > today - 3 - todayW;
56
79
  /** The hold at the end of each step, before the next step starts. */
57
80
  export const STEP_HOLD_MS = 2000;
58
81
  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
+ 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
+ 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
+ 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. */
90
+ export function laneColumns(fig) {
91
+ const byId = new Map(fig.edges.map((e) => [edgeId(e), e]));
92
+ const cols = new Map();
93
+ const seen = (id) => {
94
+ if (!cols.has(id))
95
+ cols.set(id, cols.size);
96
+ };
97
+ for (const s of fig.steps ?? [])
98
+ for (const b of s.flow.map(toBeat))
99
+ for (const h of b.hops) {
100
+ const e = byId.get(h.edge);
101
+ if (!e)
102
+ continue;
103
+ for (const id of h.back ? [e.to, e.from] : [e.from, e.to])
104
+ seen(id);
105
+ }
106
+ for (const n of nodes(fig.layout))
107
+ seen(n.id);
108
+ for (const n of nodes(fig.layout))
109
+ if (validAt(n.at))
110
+ cols.set(n.id, n.at);
111
+ return cols;
112
+ }
113
+ /** The side of an SVG frame, and the width limits of a box with no card. The lane split measures with them. */
114
+ export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
115
+ /** The gap between two blocks of wrapped lanes. */
116
+ 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
+ export const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
119
+ /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
120
+ export function nodeWidth(item, carded) {
121
+ if (item.width != null)
122
+ return item.width;
123
+ if (carded)
124
+ return CARD_WIDTH;
125
+ const label = textWidth(str(item.label), 14) + 32;
126
+ const sub = textWidth(str(item.sub), 12) + 32;
127
+ return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, label, sub));
128
+ }
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
+ 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. */
134
+ export function diamondLines(item, w) {
135
+ const sub = item.sub ? str(item.sub) : '';
136
+ for (let n = sub ? 1 : 0;; n++) {
137
+ const h = 62 + 15 * n;
138
+ const dy0 = n ? -2 - 7.5 * (n - 1) : 5;
139
+ const at = (dy, size) => Math.max(Math.abs(dy - 0.75 * size), dy + 0.25 * size);
140
+ const last = n ? at(dy0 + 15 * n, 12) : 0;
141
+ const lines = n ? wrap(sub, diamondRoom(w, h, last), 12) : [];
142
+ if (lines.length <= n || n >= 6)
143
+ return {
144
+ h,
145
+ label: { dy: dy0, far: at(dy0, 14) },
146
+ subs: lines.map((text, k) => ({ text, dy: dy0 + 15 * (k + 1), far: at(dy0 + 15 * (k + 1), 12) })),
147
+ };
148
+ }
149
+ }
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. */
152
+ export function tightCopies(lanes, ends, edges, routed) {
153
+ const laneOf = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
154
+ const out = new Set();
155
+ for (const r of routed) {
156
+ const e = edges.find((x) => edgeId(x) === r.id);
157
+ const [fb, tb] = ends.get(r.id) ?? [];
158
+ if (!e || !r.stub)
159
+ continue;
160
+ if (r.stub.tight[0] && fb != null)
161
+ out.add(`${laneOf(e.from)}@${fb}`);
162
+ if (r.stub.tight[1] && tb != null)
163
+ out.add(`${laneOf(e.to)}@${tb}`);
164
+ }
165
+ return out;
166
+ }
167
+ /** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
168
+ 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
+ export const laneBlock = (starts, c) => {
171
+ let k = 0;
172
+ while (k < starts.length - 2 && c >= starts[k + 1])
173
+ k++;
174
+ return k;
175
+ };
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
+ export const stubTexts = (label, from, to) => [
179
+ `${str(label) ? str(label) + ' ' : ''}→ ${str(to)}`,
180
+ `from ${str(from)}`,
181
+ ...(str(label) ? [`→ ${str(to)}`] : []),
182
+ ];
183
+ /**
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.
191
+ */
192
+ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor = 0 } = {}) {
193
+ const cols = laneColumns(fig);
194
+ const n = Math.max(-1, ...cols.values()) + 1;
195
+ const shown = (fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => Object.entries(toBeat(b).show ?? {})));
196
+ const carded = new Set(shown.map(([id]) => id));
197
+ const mono = shown.some(([, c]) => isRows(c) && c.some((r) => r.mono));
198
+ const colW = Array.from({ length: n }, () => 0);
199
+ const byId = new Map(nodes(fig.layout).map((b) => [b.id, b]));
200
+ for (const b of byId.values()) {
201
+ const c = cols.get(b.id);
202
+ colW[c] = Math.max(colW[c], nodeWidth(b, carded.has(b.id)) + (b.shape === 'decision' ? 70 : 0));
203
+ }
204
+ const font = mono ? 10.5 : 11;
205
+ const limit = (width * font) / minText;
206
+ const room = limit - padding * 2;
207
+ const lanes = fig.layout.children;
208
+ const laneOf = new Map(lanes.filter((l) => l.id != null).map((l) => [l.id, l]));
209
+ const gutter = laneGutter(lanes);
210
+ const plan = (count) => {
211
+ // count blocks share the n columns evenly; the first n % count blocks take one column more.
212
+ const starts = [0];
213
+ for (let k = 0; k < count; k++)
214
+ starts.push(starts[k] + Math.floor(n / count) + (k < n % count ? 1 : 0));
215
+ const colBlock = (c) => laneBlock(starts, c);
216
+ const blockOf = (id) => colBlock(cols.get(id));
217
+ const held = [...new Set([...byId.keys()].map(blockOf))].sort((a, b) => a - b);
218
+ const blocks = held.length ? held : [0];
219
+ 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
+ const showing = (l) => wrapped ? [...new Set(l.children.map((b) => blockOf(b.id)))].sort((a, b) => a - b) : blocks;
222
+ const stubs = new Map();
223
+ const ends = new Map();
224
+ const lost = [];
225
+ fig.edges.forEach((e) => {
226
+ const id = edgeId(e);
227
+ const [fl, tl] = [laneOf.get(e.from), laneOf.get(e.to)];
228
+ if (!(byId.has(e.from) || fl) || !(byId.has(e.to) || tl))
229
+ 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
+ let miss = false;
232
+ const near = (l, k, after) => {
233
+ const ks = showing(l);
234
+ const hit = after ? ks.find((b) => b >= k) : ks.findLast((b) => b <= k);
235
+ if (hit != null)
236
+ return hit;
237
+ miss = true;
238
+ return after ? ks.at(-1) : ks[0];
239
+ };
240
+ let fb, tb;
241
+ if (!fl)
242
+ fb = blockOf(e.from);
243
+ if (!tl)
244
+ tb = blockOf(e.to);
245
+ if (fl && tl)
246
+ fb = showing(fl)[0];
247
+ if (fl && !tl)
248
+ fb = near(fl, tb, false);
249
+ if (tl)
250
+ tb = near(tl, fb, true);
251
+ if (fb == null || tb == null)
252
+ return void (wrapped && lost.push(id));
253
+ if (miss)
254
+ lost.push(id);
255
+ ends.set(id, [fb, tb]);
256
+ if (fb === tb)
257
+ return;
258
+ const texts = stubTexts(e.label, (byId.get(e.from) ?? fl).label, (byId.get(e.to) ?? tl).label);
259
+ stubs.set(id, texts);
260
+ });
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.
264
+ const label = Array.from({ length: n }, () => 0);
265
+ for (const e of fig.edges) {
266
+ const [a, b] = [cols.get(e.from), cols.get(e.to)];
267
+ if (e.label == null || a == null || b == null || Math.abs(a - b) !== 1 || colBlock(a) !== colBlock(b))
268
+ continue;
269
+ const w = labelPillW(str(e.label));
270
+ if (w - 2 > LANE_GAP)
271
+ label[Math.min(a, b)] = Math.max(label[Math.min(a, b)], w + 8);
272
+ }
273
+ const gaps = colW.map((_, c) => (c === starts[colBlock(c) + 1] - 1 ? FRAME_SIDE : Math.max(LANE_GAP, label[c])));
274
+ const per = Math.max(...starts.slice(1).map((e, k) => e - starts[k]));
275
+ 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
+ const want = (text) => STUB + labelPillW(text) + STUB_CLEAR;
279
+ const fit = blocks.every((k) => widthOf(starts[k], starts[k + 1], gaps, 0) <= room);
280
+ const out = new Map(), inn = new Map();
281
+ const roomOut = new Set(), roomIn = new Set();
282
+ for (const [id, texts] of stubs) {
283
+ const e = fig.edges.find((x) => edgeId(x) === id);
284
+ const [a, b] = [cols.get(e.from), cols.get(e.to)];
285
+ if (a != null)
286
+ out.set(a, Math.max(out.get(a) ?? 0, want(texts[0])));
287
+ if (b != null)
288
+ inn.set(b, Math.max(inn.get(b) ?? 0, want(texts[1])));
289
+ }
290
+ for (const k of blocks) {
291
+ const [c0, end] = [starts[k], starts[k + 1]];
292
+ let free = room - widthOf(c0, end, gaps, 0);
293
+ const grow = (extra, apply) => {
294
+ if (extra > 0 && extra <= free) {
295
+ free -= extra;
296
+ apply();
297
+ }
298
+ };
299
+ grow(inn.get(c0) ?? 0, () => (lead[k] = inn.get(c0)));
300
+ if (!inn.has(c0) || lead[k])
301
+ roomIn.add(c0);
302
+ for (let c = c0; c < end; c++) {
303
+ const need = (out.get(c) ?? 0) + (c < end - 1 ? (inn.get(c + 1) ?? 0) : 0);
304
+ grow(need - gaps[c], () => (gaps[c] = need));
305
+ if (gaps[c] >= need)
306
+ roomOut.add(c).add(-1 - (c + 1)); // -1 - c marks room before column c
307
+ }
308
+ }
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
+ const laneIndex = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
311
+ const tall = new Set();
312
+ for (const [id] of stubs) {
313
+ const e = fig.edges.find((x) => edgeId(x) === id);
314
+ const [a, b] = [cols.get(e.from), cols.get(e.to)];
315
+ const [fb, tb] = ends.get(id);
316
+ if (a == null || !roomOut.has(a))
317
+ tall.add(`${laneIndex(e.from)}@${fb}`);
318
+ if (b == null || !(b === starts[tb] ? roomIn.has(b) : roomOut.has(-1 - b)))
319
+ tall.add(`${laneIndex(e.to)}@${tb}`);
320
+ }
321
+ const laneOfBox = new Map(lanes.flatMap((l) => l.children.map((b) => [b.id, l])));
322
+ const around = new Set(fig.edges
323
+ .filter((e) => {
324
+ const [a, b, l] = [cols.get(e.from), cols.get(e.to), laneOfBox.get(e.from)];
325
+ if (e.around || a == null || b == null || !l || l !== laneOfBox.get(e.to) || colBlock(a) !== colBlock(b))
326
+ return false;
327
+ return l.children.some((x) => cols.get(x.id) > Math.min(a, b) && cols.get(x.id) < Math.max(a, b));
328
+ })
329
+ .map(edgeId));
330
+ return [{ cols, starts, per, blocks, gutter, gaps, lead, ends, stubs, around, tall, lost }, fit];
331
+ };
332
+ 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
+ if (font < minText || floor > limit)
335
+ 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
+ for (let count = 1; count <= Math.max(1, Math.floor(n / 2)); count++) {
339
+ const [p, fit] = plan(count);
340
+ if (fit)
341
+ return p;
342
+ }
343
+ return plan(1)[0];
344
+ }
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
+ */
350
+ export function laneEnd(plan, edge, id, start, band, rects) {
351
+ const k = plan.ends.get(edge)?.[start ? 0 : 1];
352
+ const b = k == null ? undefined : band(id, k);
353
+ if (plan.blocks.length < 2 || plan.cols.has(id) || !b)
354
+ return id;
355
+ const texts = plan.stubs.get(edge);
356
+ const key = texts ? `${edge}@${start ? 's' : 't'}` : `${id}@${k}`;
357
+ const x = start ? b.x + b.w - (STUB + labelPillW(texts?.[0] ?? '') + STUB_CLEAR) : b.x + plan.gutter + plan.lead[k];
358
+ rects[key] = texts ? { x, y: b.y, w: 0, h: b.h } : b;
359
+ return key;
360
+ }
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
+ 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
+ export const dayOf = (iso) => {
366
+ const m = typeof iso === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso) : null;
367
+ if (!m)
368
+ return null;
369
+ const t = Date.UTC(+m[1], +m[2] - 1, +m[3]);
370
+ return new Date(t).toISOString().slice(0, 10) === iso ? t / 86400000 : null;
371
+ };
372
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
373
+ const dateOf = (day) => new Date(day * 86400000);
374
+ // Day 0 is a Thursday, so this is 0 for Monday and 6 for Sunday.
375
+ 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. */
377
+ const isoWeek = (day) => {
378
+ const thu = day - weekday(day) + 3;
379
+ const jan1 = Date.UTC(dateOf(thu).getUTCFullYear(), 0, 1) / 86400000;
380
+ return Math.floor((thu - jan1) / 7) + 1;
381
+ };
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
+ const datedItems = (fig) => {
385
+ const out = [];
386
+ fig.layout.children.forEach((c, track) => nodes(isGroup(c) ? c : { children: [c] }).forEach((n) => {
387
+ const from = n.from == null ? null : dayOf(n.from);
388
+ if (from == null)
389
+ return;
390
+ const to = n.to == null ? null : dayOf(n.to);
391
+ out.push({ id: n.id, track, from, to: to != null && to >= from ? to : null, label: n });
392
+ }));
393
+ return out;
394
+ };
395
+ /** Where each dated item, tick and the today line sit on the axis, `axisWidth` px wide. Both renderers call it. */
396
+ export function timelineLayout(fig, axisWidth) {
397
+ const items = datedItems(fig);
398
+ const today = fig.today == null ? null : dayOf(fig.today);
399
+ const days = [...items.flatMap((i) => [i.from, i.to ?? i.from]), ...(today == null ? [] : [today])];
400
+ const first = days.length ? Math.min(...days) : 0;
401
+ const last = days.length ? Math.max(...days) : 0;
402
+ const start = first - weekday(first);
403
+ let end = last + (6 - weekday(last));
404
+ 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.
406
+ for (let pass = 0; pass < 4; pass++) {
407
+ const over = Math.max(0, ...items.map((i) => {
408
+ const x = i.to != null ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
409
+ const w = i.to != null ? Math.max(TL_MIN_BAR, px(i.to + 1) - px(i.from)) : TL_DIAMOND;
410
+ const need = textWidth(str(i.label.label), 13);
411
+ return i.to != null && need + 16 <= w ? 0 : x + w + 6 + need - axisWidth;
412
+ }));
413
+ if (over <= 0)
414
+ break;
415
+ end += Math.ceil(over / (axisWidth / (end - start + 1)) / 7) * 7;
416
+ }
417
+ const ticks = [];
418
+ if ((end - start + 1) / 7 <= 16)
419
+ for (let d = start; d <= end; d += 7)
420
+ ticks.push({ x: px(d), label: `W${isoWeek(d)}` });
421
+ else
422
+ for (let d = start; d <= end; d++)
423
+ if (dateOf(d).getUTCDate() === 1)
424
+ ticks.push({ x: px(d), label: MONTHS[dateOf(d).getUTCMonth()] });
425
+ const rows = fig.layout.children.map(() => 1);
426
+ 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
429
+ for (const i of [...items].sort((a, b) => a.from - b.from)) {
430
+ const bar = i.to != null;
431
+ const x = bar ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
432
+ const w = bar ? Math.max(TL_MIN_BAR, px(i.to + 1) - px(i.from)) : TL_DIAMOND;
433
+ const need = textWidth(str(i.label.label), 13);
434
+ const labelInside = bar && need + 16 <= w;
435
+ let row = busy[i.track].findIndex((until) => until + GAP <= x);
436
+ if (row < 0)
437
+ row = busy[i.track].length;
438
+ busy[i.track][row] = labelInside ? x + w : x + w + 6 + need;
439
+ rows[i.track] = Math.max(rows[i.track], row + 1);
440
+ placed.push({ id: i.id, track: i.track, row, x, w, milestone: !bar, labelInside, date: fmt(i.from) });
441
+ }
442
+ // Keep the layout order in the result.
443
+ const order = new Map(items.map((i, k) => [i.id, k]));
444
+ placed.sort((a, b) => order.get(a.id) - order.get(b.id));
445
+ return {
446
+ start,
447
+ end,
448
+ ticks,
449
+ items: placed,
450
+ rows,
451
+ today: items.length && today != null ? px(today) : null,
452
+ last: items.length ? px(last) : null,
453
+ lastDate: fmt(last),
454
+ };
455
+ }
456
+ 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". */
458
+ export function timelineBeats(fig) {
459
+ const items = datedItems(fig).sort((a, b) => a.from - b.from);
460
+ const flow = items.map((i, k) => ({
461
+ focus: [i.id],
462
+ light: items.slice(0, k + 1).map((p) => p.id),
463
+ say: `${str(i.label.label)}, ${fmt(i.from)}${i.to != null ? ` to ${fmt(i.to)}` : ''}${i.label.sub != null ? ` · ${str(i.label.sub)}` : ''}`,
464
+ }));
465
+ return [{ label: 'timeline', flow }];
466
+ }
59
467
  const labeledGroups = (g) => (g.label ? 1 : 0) + g.children.reduce((n, c) => n + (isGroup(c) ? labeledGroups(c) : 0), 0);
60
468
  /** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
61
- export const counts = (p) => ({
62
- boxes: nodes(p.layout).length,
63
- groups: labeledGroups(p.layout),
64
- edges: p.edges.length,
65
- steps: (p.steps ?? []).length,
66
- messages: (p.steps ?? []).reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
67
- });
469
+ export const counts = (p) => {
470
+ // A timeline with no steps plays one synthetic step.
471
+ const st = p.timeline && isLanesLayout(p.layout) && !p.steps?.length ? timelineBeats(p) : (p.steps ?? []);
472
+ return {
473
+ boxes: nodes(p.layout).length,
474
+ groups: labeledGroups(p.layout),
475
+ edges: p.edges.length,
476
+ steps: st.length,
477
+ messages: st.reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
478
+ };
479
+ };
68
480
  /** Every id inside a child: its own id, and the ids of all boxes and groups below it. */
69
481
  const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
70
482
  /**
package/dist/open.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export type Opener = {
2
+ cmd: string;
3
+ args: string[];
4
+ verbatim: boolean;
5
+ };
6
+ /** The command that opens a file in the default program of the system. Pure. */
7
+ 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 -. */
9
+ export declare function pageName(svgPath: string): string;
10
+ /** The line that `open`, a render with `--open` and `draw --open` print. */
11
+ 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. */
13
+ export declare function openSvg(svgPath: string, html?: string): Promise<string[]>;
14
+ /** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
15
+ export declare function runOpen(argv: string[]): Promise<number>;
package/dist/open.js ADDED
@@ -0,0 +1,82 @@
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
+ import { spawn } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, join } from 'node:path';
6
+ import { pageHtml } from './page.js';
7
+ 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
+ export function openerFor(platform, file) {
10
+ if (platform === 'darwin')
11
+ return { cmd: 'open', args: [file], verbatim: false };
12
+ // `start` reads its first quoted argument as a window title, so an empty title goes first.
13
+ if (platform === 'win32')
14
+ return { cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`], verbatim: true };
15
+ return { cmd: 'xdg-open', args: [file], verbatim: false };
16
+ }
17
+ /** The name of the temp page: the SVG base name, each character other than a letter, a digit, - or _ as -. */
18
+ export function pageName(svgPath) {
19
+ // The safe name also keeps cmd metacharacters such as & out of the Windows command line.
20
+ return (basename(svgPath)
21
+ .replace(/\.svg$/, '')
22
+ .replace(/[^\w-]/g, '-') + '.html');
23
+ }
24
+ /** The line that `open`, a render with `--open` and `draw --open` print. */
25
+ 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. */
27
+ export async function openSvg(svgPath, html) {
28
+ 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.
30
+ const dir = mkdtempSync(join(tmpdir(), 'flowfig-open-'));
31
+ // The page stays after the exit: the browser reads it after the CLI stops.
32
+ const temp = join(dir, pageName(svgPath));
33
+ writeFileSync(temp, page);
34
+ if (html)
35
+ writeFileSync(html, page);
36
+ // FLOWFIG_OPENER replaces the system opener, so the tests start no browser.
37
+ const o = process.env.FLOWFIG_OPENER
38
+ ? { cmd: process.env.FLOWFIG_OPENER, args: [temp], verbatim: false }
39
+ : openerFor(process.platform, temp);
40
+ await new Promise((resolve, reject) => {
41
+ const child = spawn(o.cmd, o.args, { detached: true, windowsHide: true, stdio: 'ignore', windowsVerbatimArguments: o.verbatim });
42
+ child.once('spawn', () => (child.unref(), resolve()));
43
+ child.once('error', (e) => reject(new Error(`${e.message}. Open ${temp} in a browser.`)));
44
+ });
45
+ return html ? [temp, html] : [temp];
46
+ }
47
+ /** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
48
+ export async function runOpen(argv) {
49
+ const fail = (message, code) => (console.error(message), code);
50
+ const args = [...argv];
51
+ let html;
52
+ const at = args.indexOf('--html');
53
+ if (at !== -1) {
54
+ html = args.splice(at, 2)[1];
55
+ if (!html || html.startsWith('-'))
56
+ return fail('--html needs a path', 2);
57
+ }
58
+ const bad = args.find((a) => a.startsWith('-'));
59
+ if (bad)
60
+ return fail(`unknown flag ${bad}`, 2);
61
+ const [path] = args;
62
+ if (!path)
63
+ return fail(USAGE, 2);
64
+ if (!path.endsWith('.svg'))
65
+ return fail(`${path}: expected a .svg path`, 2);
66
+ try {
67
+ readFileSync(path);
68
+ }
69
+ catch (e) {
70
+ return fail(`${path}: ${e.message}`, 2);
71
+ }
72
+ try {
73
+ const [page, copy] = await openSvg(path, html);
74
+ console.log(openedLine(page));
75
+ if (copy)
76
+ console.log(copy);
77
+ return 0;
78
+ }
79
+ catch (e) {
80
+ return fail(`open: ${e.message}`, 1);
81
+ }
82
+ }
package/dist/page.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** The page: the figure SVG inline, centered, scaled down to the window width. The title is escaped. */
2
+ export declare function pageHtml(svg: string, title: string): string;
package/dist/page.js ADDED
@@ -0,0 +1,16 @@
1
+ // The page that holds a figure SVG: `flowfig open` shows it in the default browser, and `flowfig gif` captures it. Pure.
2
+ 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
+ export function pageHtml(svg, title) {
5
+ // Element selectors only: the figure styles use class names, so the two sets of rules do not meet.
6
+ return `<!doctype html>
7
+ <meta charset="utf-8">
8
+ <title>${esc(title)}</title>
9
+ <style>
10
+ :root { color-scheme: light dark }
11
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: Canvas }
12
+ svg { display: block; max-width: 100%; height: auto }
13
+ </style>
14
+ ${svg}
15
+ `;
16
+ }
@@ -0,0 +1,33 @@
1
+ export type PickRow = {
2
+ id: string;
3
+ name: string;
4
+ detected: boolean;
5
+ files: string[];
6
+ };
7
+ export type PickState = {
8
+ rows: PickRow[];
9
+ sel: Set<string>;
10
+ mcp: boolean;
11
+ cursor: number;
12
+ };
13
+ export type Key = {
14
+ name?: string;
15
+ ctrl?: boolean;
16
+ sequence?: string;
17
+ };
18
+ export type Screen = {
19
+ color: boolean;
20
+ width: number;
21
+ };
22
+ export type Picked = {
23
+ ids: Set<string>;
24
+ mcp: boolean;
25
+ };
26
+ /** The banner lines. A terminal narrower than the banner gets the plain version line. */
27
+ export declare function banner({ color, width }: Screen): string[];
28
+ /** The picker screen, one string per line. */
29
+ export declare function screen(s: PickState, o: Screen): string[];
30
+ /** One key press. Returns the next state, or `confirm` or `cancel`. */
31
+ 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. */
33
+ export declare function runPicker(rows: PickRow[], start: Set<string>, mcp: boolean): Promise<Picked | undefined | 'fallback'>;