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/svg.js CHANGED
@@ -8,15 +8,15 @@
8
8
  // Wrapping is therefore approximate; `flowfig check` uses the same measure, and the player check measures real text.
9
9
  import { route } from './geometry.js';
10
10
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
11
- import { textWidth } from './text.js';
11
+ import { textWidth, wrap } from './text.js';
12
12
  import { checkScene, checkSpec, checkTheme } from './check.js';
13
13
  export { parseSource, links } from './source.js';
14
14
  export { diff, formatDiff } from './diff.js';
15
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, toBeat, beatMs, STEP_HOLD_MS, } from './model.js';
15
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, lanePlan, STUB_ROOM, laneBlock, laneEnd, tightCopies, nodeWidth, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
16
16
  export { LIGHT, DARK } from './model.js';
17
17
  const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
18
- const LABEL_LINE = 18, SUB_LINE = 15, NODE_MIN_W = 100, NODE_MAX_W = 190;
19
- const FRAME_TOP = 37, FRAME_SIDE = 18, FRAME_BOTTOM = 18;
18
+ const LABEL_LINE = 18, SUB_LINE = 15;
19
+ const FRAME_TOP = 37, FRAME_BOTTOM = 18;
20
20
  // The card-on fills are fixed colors that approximate the player's 8% accent tint. The active tint is the player's color-mix.
21
21
  const vars = (t, cardOn) => `--accent:${t.accent}; --fg:${t.fg}; --muted:${t.muted}; --bg:${t.bg}; --surface:${t.surface}; --border:${t.border}; --card-on:${cardOn}; --tint:color-mix(in srgb, var(--accent) 10%, var(--surface));`;
22
22
  /** What a card actually holds: rows, or its text. Only text survives outside React. */
@@ -26,25 +26,6 @@ const n2 = (v) => Math.round(v * 10) / 10;
26
26
  // Keyframe times are fractions of a loop that can last a minute: one decimal would round a 0.7 s hop away, and the packet would jump.
27
27
  const n4 = (v) => Math.round(v * 10000) / 10000;
28
28
  const pct = (v) => Math.round(v * 10000) / 100 + '%';
29
- /** Break a string into lines that fit `width`, keeping the newlines it already has. */
30
- function wrap(s, width, fontSize, mono = false) {
31
- const out = [];
32
- for (const para of s.split('\n')) {
33
- let line = '';
34
- for (const word of para.split(' ')) {
35
- if (!line)
36
- line = word;
37
- else if (textWidth(line + ' ' + word, fontSize, mono) <= width)
38
- line += ' ' + word;
39
- else {
40
- out.push(line);
41
- line = word;
42
- }
43
- }
44
- out.push(line);
45
- }
46
- return out;
47
- }
48
29
  /** A tag pill. The tag is drawn in bold capitals with letter spacing, so the width counts both. */
49
30
  const pillW = (tag) => textWidth(tag.toUpperCase(), 9) + tag.length * 0.27 + 8;
50
31
  /** A card's rows, wrapped for `width`, with the height they need. */
@@ -69,23 +50,16 @@ function layoutCard(c, width) {
69
50
  const height = rows.reduce((h, r) => h + (r.heads ? LINE : 0) + r.lines.length * LINE, 0) + ROW_GAP * Math.max(0, rows.length - 1) + CARD_PAD * 2;
70
51
  return { rows, height };
71
52
  }
72
- function nodeWidth(item, carded) {
73
- if (item.width != null)
74
- return item.width;
75
- if (carded)
76
- return CARD_WIDTH;
77
- const label = textWidth(str(item.label), 14) + 32;
78
- const sub = textWidth(str(item.sub), 12) + 32;
79
- return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, label, sub));
80
- }
81
53
  function size(item, s) {
82
54
  if (!isGroup(item)) {
83
55
  const contents = s.cards.get(item.id);
84
- const w = nodeWidth(item, contents != null);
56
+ const w = nodeWidth(item, contents != null) + (item.shape === 'decision' ? 70 : 0);
85
57
  const top = item.shape === 'store' ? 24 : 10;
86
58
  const card = contents ? 8 + s.cardH.get(item.id) : 0;
87
59
  const h = Math.max(top + LABEL_LINE + (item.sub ? SUB_LINE : 0) + card + 10, s.minH(item.id));
88
- return item.shape === 'decision' ? { w: w + 70, h: h + 24 } : { w, h };
60
+ if (item.shape !== 'decision')
61
+ return { w, h };
62
+ return { w, h: contents ? h + 24 : Math.max(diamondLines(item, w).h, s.minH(item.id) + 24) };
89
63
  }
90
64
  const kids = item.children.map((c) => size(c, s));
91
65
  const gap = s.gap(item);
@@ -94,7 +68,92 @@ function size(item, s) {
94
68
  const inner = item.direction === 'column' ? { w: across, h: along } : { w: along, h: across };
95
69
  return item.label != null ? { w: inner.w + FRAME_SIDE * 2, h: inner.h + FRAME_TOP + FRAME_BOTTOM } : inner;
96
70
  }
71
+ /** Swimlanes: the bands span the width, the label sits in a left gutter, and a box sits at its time column. Columns past
72
+ * the plan wrap into blocks under the first. A block holds only the lanes with a box in it; all bands share one width. */
73
+ function placeLanes(fig, x, y, s, out) {
74
+ const lanes = fig.layout.children;
75
+ const { cols, starts, gaps, lead, blocks } = s.plan;
76
+ const colW = Array.from({ length: Math.max(-1, ...cols.values()) + 1 }, () => 0);
77
+ for (const lane of lanes)
78
+ for (const b of lane.children)
79
+ colW[cols.get(b.id)] = Math.max(colW[cols.get(b.id)], size(b, s).w);
80
+ const gutter = gutterOf(lanes);
81
+ const colsOf = (k) => colW.slice(starts[k], starts[k + 1]);
82
+ const colX = colW.map((_, c) => {
83
+ const k = laneBlock(starts, c);
84
+ return (gutter +
85
+ lead[k] +
86
+ colsOf(k)
87
+ .slice(0, c - starts[k])
88
+ .reduce((a, w, i) => a + w + gaps[starts[k] + i], 0));
89
+ });
90
+ // A figure with no box has no column: its band keeps the frame side on the right.
91
+ const width = Math.max(...blocks.map((k) => {
92
+ const ws = colsOf(k);
93
+ return (gutter +
94
+ lead[k] +
95
+ ws.reduce((a, w) => a + w, 0) +
96
+ gaps.slice(starts[k], starts[k] + ws.length - 1).reduce((a, g) => a + g, 0) +
97
+ (gaps[starts[k] + ws.length - 1] ?? FRAME_SIDE));
98
+ }));
99
+ const topAt = out.length;
100
+ out.push({ x, y, w: width, h: 0, item: fig.layout });
101
+ let ly = y;
102
+ blocks.forEach((bk, i) => {
103
+ if (i)
104
+ ly += LANE_BLOCK_GAP - LANE_ROW_GAP;
105
+ for (const lane of lanes) {
106
+ const kids = lane.children.filter((b) => laneBlock(starts, cols.get(b.id)) === bk).map((b) => ({ b, ...size(b, s) }));
107
+ // A wrapped block draws only its own lanes; one block draws every lane, an empty one too.
108
+ if (blocks.length > 1 && !kids.length)
109
+ continue;
110
+ const inner = Math.max(LABEL_LINE + 20, ...kids.map((k) => k.h));
111
+ const h = inner + LANE_PAD * 2 + (s.tall.has(`${lanes.indexOf(lane)}@${bk}`) ? STUB_ROOM : 0);
112
+ out.push({ x, y: ly, w: width, h, item: lane, lane: true, block: bk });
113
+ for (const k of kids)
114
+ out.push({ x: x + colX[cols.get(k.b.id)], y: ly + LANE_PAD + (inner - k.h) / 2, w: k.w, h: k.h, item: k.b });
115
+ ly += h + LANE_ROW_GAP;
116
+ }
117
+ });
118
+ out[topAt].h = ly - LANE_ROW_GAP - y;
119
+ }
120
+ /** A timeline: the axis strip on top, one band per track, and the bars and milestones at the shared layout x. */
121
+ function placeTimeline(fig, x, y, out) {
122
+ const lanes = fig.layout.children;
123
+ const gutter = gutterOf(lanes);
124
+ const lay = timelineLayout(fig, TL_AXIS_W);
125
+ const width = gutter + TL_AXIS_W + FRAME_SIDE;
126
+ const topAt = out.length;
127
+ out.push({ x, y, w: width, h: 0, item: fig.layout });
128
+ let ly = y + TL_AXIS_H;
129
+ lanes.forEach((lane, track) => {
130
+ const rows = lay.rows[track];
131
+ const h = rows * TL_BAR_H + (rows - 1) * TL_ROW_GAP + LANE_PAD * 2;
132
+ out.push({ x, y: ly, w: width, h, item: lane, lane: true });
133
+ for (const it of lay.items) {
134
+ if (it.track !== track)
135
+ continue;
136
+ const node = lane.children.find((k) => !isGroup(k) && k.id === it.id);
137
+ const top = ly + LANE_PAD + it.row * (TL_BAR_H + TL_ROW_GAP);
138
+ const bh = it.milestone ? it.w : TL_BAR_H;
139
+ out.push({
140
+ x: x + gutter + it.x,
141
+ y: top + (TL_BAR_H - bh) / 2,
142
+ w: it.w,
143
+ h: bh,
144
+ item: node,
145
+ tl: { milestone: it.milestone, labelInside: it.labelInside },
146
+ });
147
+ }
148
+ ly += h + LANE_ROW_GAP;
149
+ });
150
+ out[topAt].h = ly - LANE_ROW_GAP - y;
151
+ }
97
152
  function place(item, x, y, s, out) {
153
+ if (item === s.fig.layout && s.fig.timeline && isLanesLayout(s.fig.layout))
154
+ return placeTimeline(s.fig, x, y, out);
155
+ if (item === s.fig.layout && s.fig.lanes && isLanesLayout(s.fig.layout))
156
+ return placeLanes(s.fig, x, y, s, out);
98
157
  const { w, h } = size(item, s);
99
158
  out.push({ x, y, w, h, item });
100
159
  if (!isGroup(item))
@@ -126,7 +185,10 @@ const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helv
126
185
  export function render(fig, opts = {}) {
127
186
  const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
128
187
  const pad = opts.padding ?? 24;
129
- const steps = fig.steps ?? [];
188
+ const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
189
+ // A timeline with no steps of its own walks its dated items in date order.
190
+ const synthetic = tl != null && !fig.steps?.length;
191
+ const steps = synthetic ? timelineBeats(fig) : (fig.steps ?? []);
130
192
  const beats = steps.map((s) => s.flow.map(toBeat));
131
193
  // Every content a box will ever show, so its card can be sized to the biggest one up front.
132
194
  const cards = new Map();
@@ -144,7 +206,9 @@ export function render(fig, opts = {}) {
144
206
  return n > 2 ? n * 30 : 0;
145
207
  };
146
208
  const cardH = new Map();
147
- const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges) };
209
+ const lanes = !tl && fig.lanes && isLanesLayout(fig.layout);
210
+ const plan = lanes ? planFor(fig, opts) : null;
211
+ const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges), fig, plan, tall: new Set(plan?.tall) };
148
212
  for (const [id, contents] of cards) {
149
213
  const width = CARD_WIDTH; // refined below once the node's own width is known
150
214
  const widths = [width];
@@ -152,7 +216,7 @@ export function render(fig, opts = {}) {
152
216
  }
153
217
  const placed = [];
154
218
  place(fig.layout, pad, pad, sizes, placed);
155
- const nodes = placed.filter((p) => !isGroup(p.item));
219
+ let nodes = placed.filter((p) => !isGroup(p.item));
156
220
  // A node's own width can differ from CARD_WIDTH (`width` in the spec), so re-measure once placed.
157
221
  for (const p of nodes) {
158
222
  if (!cards.has(p.item.id))
@@ -160,25 +224,77 @@ export function render(fig, opts = {}) {
160
224
  const inner = p.w - 20;
161
225
  cardH.set(p.item.id, Math.max(...cards.get(p.item.id).map((c) => layoutCard(c, inner).height), LINE + CARD_PAD * 2));
162
226
  }
163
- placed.length = 0;
164
- place(fig.layout, pad, pad, sizes, placed);
165
227
  const rects = {};
166
- for (const p of placed)
167
- if (p.item.id)
168
- rects[p.item.id] = p;
169
- const tips = new Set(placed.filter((p) => !isGroup(p.item) && p.item.shape === 'decision').map((p) => p.item.id));
228
+ const tips = new Set(placed.filter((p) => !isGroup(p.item) && (p.item.shape === 'decision' || p.tl?.milestone)).map((p) => p.item.id));
170
229
  const ids = fig.edges.map(edgeId);
171
- const routed = route(fig.edges.map((e, i) => ({ id: ids[i], from: e.from, to: e.to, around: e.around })), rects, tips);
230
+ // Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane gutters.
231
+ const stubs = plan?.stubs ?? new Map();
232
+ const gutter = plan ? gutterOf(fig.layout.children) : 0;
233
+ const band = (id, k) => placed.find((p) => p.lane && p.item.id === id && p.block === k);
234
+ // The band of an edge end, in the block of that end: the band of the box's lane, or of the lane itself.
235
+ const laneGroups = fig.layout.children;
236
+ const bandOf = (eid, id, k) => {
237
+ const block = plan?.ends.get(eid)?.[k];
238
+ const lane = laneGroups.find((l) => l.id === id || l.children.some((b) => b.id === id));
239
+ return placed.find((p) => p.lane && p.item === lane && p.block === block);
240
+ };
241
+ const end = (eid, id, start) => (plan ? laneEnd(plan, eid, id, start, band, rects) : id);
242
+ // Place and route. A stub end with no place clear of every edge path makes its lane copy taller, then both run again.
243
+ const go = () => {
244
+ placed.length = 0;
245
+ place(fig.layout, pad, pad, sizes, placed);
246
+ for (const p of placed)
247
+ if (p.item.id)
248
+ rects[p.item.id] = p;
249
+ return route(fig.edges.map((e, i) => ({
250
+ id: ids[i],
251
+ from: end(ids[i], e.from, true),
252
+ to: end(ids[i], e.to, false),
253
+ around: e.around ?? (plan?.around.has(ids[i]) ? 'below' : undefined),
254
+ ...(tl && { sides: ['r', 'l'], elbow: true }),
255
+ ...(stubs.has(ids[i]) && { stub: stubs.get(ids[i]).map(labelPillW), bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)] }),
256
+ ...(stubs.size && e.label != null && { labelW: labelPillW(str(e.label)) }),
257
+ })), rects, tips, stubs.size
258
+ ? placed.flatMap((p) => (!isGroup(p.item) ? [p] : p.lane ? [{ x: p.x, y: p.y, w: gutter, h: p.h }] : []))
259
+ : placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))),
260
+ // A stub pill stays in the lanes, right of the gutter.
261
+ stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined);
262
+ };
263
+ let routed = go();
264
+ for (let pass = 0; plan && pass < 3; pass++) {
265
+ const more = [...tightCopies(laneGroups, plan.ends, fig.edges, routed)].filter((k) => !sizes.tall.has(k));
266
+ if (!more.length)
267
+ break;
268
+ more.forEach((k) => sizes.tall.add(k));
269
+ routed = go();
270
+ }
271
+ nodes = placed.filter((p) => !isGroup(p.item));
172
272
  const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
173
273
  // What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
174
274
  const fonts = [14, ...(steps.length ? [13.5] : [])];
175
275
  const sceneBoxes = nodes.map((p) => {
176
- const room = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
177
- const texts = [{ text: str(p.item.label), fontSize: 14, room }];
178
- if (p.item.sub) {
179
- texts.push({ text: str(p.item.sub), fontSize: 12, room });
180
- fonts.push(12);
276
+ if (p.tl) {
277
+ // A label inside the bar has the bar as room; a label beside the bar has the space to the next item of its row, or to the band edge.
278
+ const label = str(p.item.label);
279
+ const mine = tl.items.find((i) => i.id === p.item.id);
280
+ const next = tl.items.filter((i) => i.track === mine.track && i.row === mine.row && i.x > mine.x).map((i) => p.x - mine.x + i.x - 8);
281
+ const limit = Math.min(placed[0].x + placed[0].w - FRAME_SIDE, ...next);
282
+ const room = p.tl.labelInside ? p.w - 16 : limit - (p.x + p.w) - 6;
283
+ fonts.push(13);
284
+ return { id: p.item.id, rect: { x: p.x, y: p.y, w: p.w, h: p.h }, texts: [{ text: label, fontSize: 13, room }] };
181
285
  }
286
+ const dia = p.item.shape === 'decision' && !cards.has(p.item.id);
287
+ const dl = dia ? diamondLines(p.item, p.w) : null;
288
+ const room = (far) => diamondRoom(p.w, p.h, far);
289
+ const flat = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
290
+ const texts = [{ text: str(p.item.label), fontSize: 14, room: dl ? room(dl.label.far) : flat }];
291
+ if (dl)
292
+ for (const l of dl.subs)
293
+ texts.push({ text: l.text, fontSize: 12, room: room(l.far) });
294
+ else if (p.item.sub)
295
+ texts.push({ text: str(p.item.sub), fontSize: 12, room: flat });
296
+ if (p.item.sub)
297
+ fonts.push(12);
182
298
  for (const c of cards.get(p.item.id) ?? [])
183
299
  for (const r of layoutCard(c, p.w - 20).rows) {
184
300
  const fontSize = r.row.mono ? 10.5 : 11;
@@ -225,7 +341,12 @@ export function render(fig, opts = {}) {
225
341
  const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
226
342
  const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
227
343
  const litNodes = segs.map((seg, i) => {
228
- const on = new Set([...(steps[seg.si].nodes ?? []), ...Object.keys(shownAt[i]), ...(beats[seg.si][seg.bi].light ?? [])]);
344
+ const on = new Set([
345
+ ...(steps[seg.si].nodes ?? []),
346
+ ...Object.keys(shownAt[i]),
347
+ ...(beats[seg.si][seg.bi].light ?? []),
348
+ ...(beats[seg.si][seg.bi].focus ?? []),
349
+ ]);
229
350
  fig.edges.forEach((e, ei) => {
230
351
  if (litEdges[i].has(ids[ei]))
231
352
  on.add(e.from).add(e.to);
@@ -277,6 +398,7 @@ export function render(fig, opts = {}) {
277
398
  * FADE is in timeline seconds, which are wall seconds.
278
399
  */
279
400
  const FADE = 0.4;
401
+ const RAMP = 0.4; // the today line moves in 400 ms, in wall seconds like FADE
280
402
  // A box tone is a permanent state: a 1 px border and a light tint in the off and trail looks. The active look uses the hop tone, else the box tone.
281
403
  const looks = (look, boxTone, hopTone) => {
282
404
  const tint = boxTone ? toneTint(boxTone, 'var(--bg)') : 'var(--bg)';
@@ -295,7 +417,9 @@ export function render(fig, opts = {}) {
295
417
  if (!litNodes[i].has(id))
296
418
  return void pieces.push({ a: s.t0, b: s.t1, look: 'off' });
297
419
  const here = hops.filter((h) => h.dest === id && h.si === s.si && h.bi === s.bi);
298
- const arrive = Math.min(...here.map((h) => h.t1), s.t1);
420
+ // A focused box turns active at the beat start; in a timeline, when the today line arrives.
421
+ const focused = beats[s.si][s.bi].focus?.includes(id);
422
+ const arrive = Math.min(...here.map((h) => h.t1), focused ? Math.min(s.t0 + (tl ? RAMP : 0), s.t1) : s.t1, s.t1);
299
423
  if (arrive > s.t0)
300
424
  pieces.push({ a: s.t0, b: arrive, look: 'trail' });
301
425
  if (arrive < s.t1)
@@ -330,18 +454,31 @@ export function render(fig, opts = {}) {
330
454
  return '';
331
455
  const on = item.id ? segs.map((_, i) => litNodes[i].has(item.id)) : [];
332
456
  return (`<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="14" fill="var(--surface)" stroke="var(--border)"` +
333
- cls(anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
334
- `/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
457
+ cls(p.lane && 'lane', anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
458
+ `/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.lane ? p.y + p.h / 2 + 4 : p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
335
459
  }
336
460
  const bt = item.tone && TONES[item.tone];
337
461
  const stroke = cls(boxAnim(item.id, true, bt));
462
+ if (p.tl) {
463
+ const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
464
+ const stroke0 = bt ?? 'var(--border)';
465
+ const label = str(item.label);
466
+ const inside = p.tl.labelInside;
467
+ const cx = p.x + p.w / 2, cy = p.y + p.h / 2;
468
+ const shape = p.tl.milestone
469
+ ? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(cy)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(cy)}" fill="${fill0}" stroke="${stroke0}"${stroke}/>`
470
+ : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="6" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
471
+ const tx = inside ? p.x + 8 : p.x + p.w + 6;
472
+ return shape + `<text x="${n2(tx)}" y="${n2(cy + 4.5)}" class="bar">${esc(label)}</text>`;
473
+ }
338
474
  const rim = item.shape === 'store' ? cls(boxAnim(item.id, false, bt)) : '';
339
475
  const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
340
476
  const stroke0 = bt ?? 'var(--border)';
341
477
  const cx = p.x + p.w / 2;
342
478
  const contents = cards.get(item.id);
343
479
  const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
344
- const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (item.sub ? -2 : 5);
480
+ const dl = item.shape === 'decision' && !contents ? diamondLines(item, p.w) : null;
481
+ const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (dl ? dl.label.dy : item.sub ? -2 : 5);
345
482
  const shape = item.shape === 'decision'
346
483
  ? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(p.y + p.h / 2)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(p.y + p.h / 2)}" fill="${fill0}" stroke="${stroke0}"${stroke}/>`
347
484
  : item.shape === 'store'
@@ -349,8 +486,20 @@ export function render(fig, opts = {}) {
349
486
  `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 0 ${n2(p.w)} 0" fill="none" stroke="${stroke0}"${rim}/>`
350
487
  : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
351
488
  const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
352
- const sub = item.sub ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>` : '';
353
- return shape + label + sub + (contents ? card(p, cardTop, contents) : '');
489
+ const sub = dl
490
+ ? dl.subs.map((l) => `<text x="${n2(cx)}" y="${n2(p.y + p.h / 2 + l.dy)}" class="sub">${esc(l.text)}</text>`).join('')
491
+ : item.sub
492
+ ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>`
493
+ : '';
494
+ // The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
495
+ const dot = bt ?? 'var(--accent)';
496
+ const my = n2(p.y + p.h / 2);
497
+ const mark = item.mark === 'start'
498
+ ? `<circle cx="${n2(p.x - 12)}" cy="${my}" r="5" fill="${dot}"/>`
499
+ : item.mark === 'end'
500
+ ? `<circle cx="${n2(p.x + p.w + 12)}" cy="${my}" r="6.25" fill="none" stroke="${dot}" stroke-width="1.5"/><circle cx="${n2(p.x + p.w + 12)}" cy="${my}" r="4" fill="${dot}"/>`
501
+ : '';
502
+ return shape + label + sub + mark + (contents ? card(p, cardTop, contents) : '');
354
503
  });
355
504
  /** The dashed content card: one group per content it will ever hold, each visible on its own beats. */
356
505
  function card(p, top, contents) {
@@ -421,20 +570,30 @@ export function render(fig, opts = {}) {
421
570
  const lit = cls(toned
422
571
  ? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
423
572
  : anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
424
- const path = `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
573
+ // A cross-block edge draws its two stubs; its full path only guides the packet, so the packet jumps between the blocks.
574
+ const path = r.stub
575
+ ? r.stub.parts
576
+ .map((d) => `<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
577
+ .join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
578
+ : `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
425
579
  // A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
426
- const label = e.label == null
427
- ? ''
428
- : (() => {
429
- const lw = labelPillW(str(e.label));
430
- labelRects[r.id] = { x: r.mid.x - lw / 2, y: r.mid.y - 9, w: lw, h: 18 };
431
- fonts.push(11);
432
- return (`<rect x="${n2(r.mid.x - lw / 2)}" y="${n2(r.mid.y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
433
- cls(toned
434
- ? frames(on.map((x, i) => x ? `fill: ${tone[i] ? toneFill(col[i]) : col[i]}; stroke: ${col[i]}` : 'fill: var(--bg); stroke: var(--border)'), 'l', 'fill: var(--bg); stroke: var(--border)')
435
- : anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
436
- `/><text x="${n2(r.mid.x)}" y="${n2(r.mid.y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(str(e.label))}</text>`);
437
- })();
580
+ const pill = (x, y, lw, text) => `<rect x="${n2(x - lw / 2)}" y="${n2(y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
581
+ cls(toned
582
+ ? frames(on.map((x, i) => x ? `fill: ${tone[i] ? toneFill(col[i]) : col[i]}; stroke: ${col[i]}` : 'fill: var(--bg); stroke: var(--border)'), 'l', 'fill: var(--bg); stroke: var(--border)')
583
+ : anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
584
+ `/><text x="${n2(x)}" y="${n2(y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(text)}</text>`;
585
+ let label = '';
586
+ if (r.stub) {
587
+ fonts.push(11);
588
+ const texts = stubs.get(r.id);
589
+ label = r.stub.pills.map((p, k) => pill(p.x + p.w / 2, p.y + 9, p.w, k === 0 && r.stub.short ? texts[2] : texts[k])).join('');
590
+ }
591
+ else if (e.label != null && !tl) {
592
+ const lw = labelPillW(str(e.label));
593
+ labelRects[r.id] = { x: r.mid.x - lw / 2, y: r.mid.y - 9, w: lw, h: 18 };
594
+ fonts.push(11);
595
+ label = pill(r.mid.x, r.mid.y, lw, str(e.label));
596
+ }
438
597
  return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
439
598
  });
440
599
  // A packet per hop: it waits offstage, crosses its edge in `speed`, rests at the end of the edge to the end of its beat, then leaves. Any `data`
@@ -459,8 +618,81 @@ export function render(fig, opts = {}) {
459
618
  `<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n4(t0)};${n4(t1)};1" keyPoints="${h.back ? '1;1;0;0' : '0;0;1;1'}" calcMode="linear">` +
460
619
  `<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
461
620
  });
621
+ // The timeline axis under the boxes, the fixed today marker and the moving playhead over them.
622
+ let axisSvg = '', todaySvg = '';
623
+ if (tl) {
624
+ const top = placed[0];
625
+ const x0 = top.x + gutterOf(fig.layout.children);
626
+ const ay = top.y + TL_AXIS_H;
627
+ fonts.push(11);
628
+ axisSvg =
629
+ `<path d="M ${n2(x0)} ${n2(ay - 1)} H ${n2(x0 + TL_AXIS_W)}" stroke="var(--border)"/>` +
630
+ tl.ticks
631
+ .map((k) => `<path d="M ${n2(x0 + k.x)} ${n2(ay - 5)} V ${n2(ay - 1)}" stroke="var(--border)"/>` +
632
+ `<text x="${n2(x0 + k.x + 3)}" y="${n2(top.y + 10)}" class="tick">${esc(k.label)}</text>`)
633
+ .join('');
634
+ const bottom = n2(top.y + top.h);
635
+ // The today marker is fixed at the figure's `today`.
636
+ if (tl.today != null) {
637
+ const tx = x0 + tl.today;
638
+ todaySvg =
639
+ `<path d="M ${n2(tx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-opacity="0.6" stroke-width="1" stroke-dasharray="3 3"/>` +
640
+ `<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
641
+ }
642
+ // The playhead moves to the start of each beat's item, then to the last date in the step hold.
643
+ if (tl.last != null && segs.length) {
644
+ const home = tl.last;
645
+ const startOf = new Map(tl.items.map((i) => [i.id, i.x + (i.milestone ? i.w / 2 : 0)]));
646
+ const dateOf = new Map(tl.items.map((i) => [i.id, i.date]));
647
+ const hold = STEP_HOLD_MS / 1000 / BASE_RATE;
648
+ const pts = [[0, home]];
649
+ const dates = []; // the label date in each time span
650
+ let at = home;
651
+ segs.forEach((s, i) => {
652
+ const item = playheadItem(tl.items, beats[s.si], s.bi);
653
+ const to = item ? startOf.get(item.id) : home;
654
+ const date = item ? dateOf.get(item.id) : tl.lastDate;
655
+ const last = i === segs.length - 1 || segs[i + 1].si !== s.si;
656
+ const end = last ? s.t1 - hold : s.t1; // the step hold starts here
657
+ pts.push([s.t0, at], [Math.min(s.t0 + RAMP, end), to], [end, to]);
658
+ const [la, lb] = labelSpan(s.t0, end, RAMP);
659
+ if (la < lb)
660
+ dates.push({ a: la, b: lb, d: date, x: to });
661
+ at = to;
662
+ if (last) {
663
+ pts.push([Math.min(end + RAMP, s.t1), home], [s.t1, home]);
664
+ const [ha, hb] = labelSpan(end, s.t1, RAMP);
665
+ if (ha < hb)
666
+ dates.push({ a: ha, b: hb, d: tl.lastDate, x: home });
667
+ at = home;
668
+ }
669
+ });
670
+ const name = `a${seen.size}`;
671
+ seen.set('playhead', name);
672
+ const kf = pts.map(([t, x]) => `${pct(t / total)} { transform: translateX(${n2(x - home)}px) }`).join(' ');
673
+ css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
674
+ const lx = x0 + home;
675
+ const labels = [...new Set(dates.map((x) => x.d))].map((d) => {
676
+ const on = dates.map((x) => x.d === d);
677
+ const key = `d${d}${on.map((x) => (x ? 1 : 0)).join('')}`;
678
+ if (!seen.has(key)) {
679
+ const nm = `a${seen.size}`;
680
+ seen.set(key, nm);
681
+ // Each span ends with a hidden frame: the label stays off while the line moves.
682
+ const fr = dates
683
+ .map((x) => `${pct(x.a / total)},${pct(x.b / total - 0.0001)} { opacity: ${x.d === d ? 1 : 0} } ${pct(x.b / total)} { opacity: 0 }`)
684
+ .join(' ');
685
+ css.push(`@keyframes ${nm} { ${fr} }\n.${nm} { animation: ${nm} ${n2(total)}s infinite step-end; }`);
686
+ }
687
+ // A label that would meet "today" moves to the tick row.
688
+ const raised = dateLabelRaised(dates.find((x) => x.d === d).x, tl.today, textWidth(d, 11), textWidth('today', 11));
689
+ return `<text x="${n2(lx + 3)}" y="${n2(top.y + (raised ? 10 : 22))}" opacity="0"${cls('today', seen.get(key))}>${esc(d)}</text>`;
690
+ });
691
+ todaySvg += `<g${cls(name)}><path d="M ${n2(lx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-width="1.5"/>${labels.join('')}</g>`;
692
+ }
693
+ }
462
694
  const bounds = placed[0];
463
- const arcs = fig.edges.some((e) => e.around);
695
+ const arcs = fig.edges.some((e, i) => (e.around || plan?.around.has(ids[i])) && !stubs.has(ids[i])); // a stub does not arc
464
696
  const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
465
697
  const mapW = Math.max(bounds.w + pad * 2, 560);
466
698
  // `rail: 'only'` drops the map, but only when the rail has a hop to draw; a figure never renders empty.
@@ -476,7 +708,7 @@ export function render(fig, opts = {}) {
476
708
  const shift = (W - (bounds.w + pad * 2)) / 2;
477
709
  const railX = rail ? (W - rail.width) / 2 : 0;
478
710
  // The step label and its narration, both switching with the beats.
479
- const labels = rail
711
+ const labels = rail || synthetic
480
712
  ? []
481
713
  : steps.map((_, si) => {
482
714
  const on = segs.map((s) => s.si === si);
@@ -579,6 +811,9 @@ svg { ${vars(t0, '#eef5fd')} }
579
811
  .row { fill: var(--fg); font-size: 11px; }
580
812
  .row.mono { font-size: 10.5px; font-family: ui-monospace, Menlo, monospace; }
581
813
  .muted { fill: var(--muted); }
814
+ .bar { fill: var(--fg); font-size: 13px; font-weight: 500; }
815
+ .tick { fill: var(--muted); font-size: 11px; }
816
+ .today { fill: var(--accent); font-size: 11px; font-weight: 600; }
582
817
  .tag { font-size: 9px; font-weight: 600; letter-spacing: .03em; text-anchor: middle; }
583
818
  .mark { fill: var(--accent); font-size: 11px; font-weight: 600; text-anchor: end; }
584
819
  .chip { fill: ${ON_ACCENT}; font-size: 11.5px; text-anchor: middle; }
@@ -595,9 +830,13 @@ ${css.join('\n')}
595
830
  ${only
596
831
  ? ''
597
832
  : `<g transform="translate(${n2(shift)} ${arcs ? 44 : 0})">
598
- ${boxes.filter(Boolean).join('\n')}
599
- ${edgeSvg.join('\n')}
833
+ ${axisSvg}
834
+ ${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}
835
+ ${tl ? edgeSvg.join('\n') : ''}
836
+ ${boxes.filter((b, i) => b && !(tl && isGroup(placed[i].item))).join('\n')}
837
+ ${tl ? '' : edgeSvg.join('\n')}
600
838
  ${packets.join('\n')}
839
+ ${todaySvg}
601
840
  </g>
602
841
  `}${railSvg}
603
842
  ${labels.join('\n')}
@@ -610,9 +849,12 @@ ${said.join('\n')}
610
849
  width: n2(W),
611
850
  boxes: only ? [] : sceneBoxes,
612
851
  edges: [
613
- ...(only ? [] : routed).map((r) => {
852
+ ...(only ? [] : routed).flatMap((r) => {
614
853
  const e = fig.edges[ids.indexOf(r.id)];
615
- return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id] };
854
+ // A cross-block edge is two scene edges: each stub with its pill.
855
+ if (r.stub)
856
+ return r.stub.pts.map((pts, k) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: r.stub.pills[k] }));
857
+ return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id], ...(tl && { behind: true }) }];
616
858
  }),
617
859
  // Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
618
860
  ...(rail?.rows ?? []).flatMap((row, i) => {
@@ -636,14 +878,30 @@ ${said.join('\n')}
636
878
  }),
637
879
  ],
638
880
  minFont: Math.min(...fonts),
881
+ ...(stubs.size && {
882
+ lanes: placed.filter((p) => p.lane).map((p) => ({ id: str(p.item.label), rect: { x: p.x, y: p.y, w: p.w, h: p.h } })),
883
+ }),
639
884
  };
640
885
  return { svg, scene };
641
886
  }
887
+ /** The lanes wrap plan at the width and text size of `opts`, or null if the figure does not draw as lanes. A rail sets a least width. */
888
+ function planFor(fig, opts) {
889
+ if (fig.timeline || !fig.lanes || !isLanesLayout(fig.layout))
890
+ return null;
891
+ const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
892
+ return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
893
+ }
642
894
  /** One self-contained animated SVG string for the figure. Needs no React and no browser. */
643
895
  export function toSvg(fig, opts = {}) {
644
896
  return render(fig, opts).svg;
645
897
  }
646
898
  /** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. A `Finding` has a `rule` name, a `severity`, the ids it names and a message. */
647
899
  export function check(fig, opts = {}) {
648
- return [...checkSpec(fig), ...checkScene(render(fig, opts).scene, opts), ...checkTheme({ ...fig.theme, ...opts.theme })];
900
+ const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
901
+ rule: 'lane-end-block',
902
+ severity: 'warning',
903
+ ids: [id],
904
+ message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
905
+ }));
906
+ return [...checkSpec(fig), ...checkScene(render(fig, opts).scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
649
907
  }
package/dist/text.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  /** The width of `s` in px at `fontSize`. */
2
2
  export declare function textWidth(s: string, fontSize: number, mono?: boolean): number;
3
+ /** Break a string into lines that fit `width`, keeping the newlines it already has. */
4
+ export declare function wrap(s: string, width: number, fontSize: number, mono?: boolean): string[];
package/dist/text.js CHANGED
@@ -11,3 +11,22 @@ export function textWidth(s, fontSize, mono = false) {
11
11
  em += FULL.test(ch) ? 1 : mono ? 0.6 : WIDE.test(ch) ? 0.85 : NARROW.test(ch) ? 0.3 : TALL.test(ch) ? 0.64 : 0.53;
12
12
  return em * fontSize;
13
13
  }
14
+ /** Break a string into lines that fit `width`, keeping the newlines it already has. */
15
+ export function wrap(s, width, fontSize, mono = false) {
16
+ const out = [];
17
+ for (const para of s.split('\n')) {
18
+ let line = '';
19
+ for (const word of para.split(' ')) {
20
+ if (!line)
21
+ line = word;
22
+ else if (textWidth(line + ' ' + word, fontSize, mono) <= width)
23
+ line += ' ' + word;
24
+ else {
25
+ out.push(line);
26
+ line = word;
27
+ }
28
+ }
29
+ out.push(line);
30
+ }
31
+ return out;
32
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
- export declare const VERSION = "0.3.0";
2
+ export declare const VERSION = "0.5.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
- export const VERSION = '0.3.0';
2
+ export const VERSION = '0.5.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowfig",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Your coding agent draws animated diagrams from your code, and flowfig checks them. One animated SVG for a GitHub README, PR or issue, and a React player. Set up Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro with npx flowfig init.",
5
5
  "license": "MIT",
6
6
  "repository": {