flowfig 0.4.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, laneColumns, LANE_GAP, LANE_PAD, LANE_ROW_GAP, 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';
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,33 +68,55 @@ 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
  }
97
- /** Swimlanes: the bands span the width, the label sits in a left gutter, and a box sits at its time column. */
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. */
98
73
  function placeLanes(fig, x, y, s, out) {
99
74
  const lanes = fig.layout.children;
100
- const cols = laneColumns(fig);
75
+ const { cols, starts, gaps, lead, blocks } = s.plan;
101
76
  const colW = Array.from({ length: Math.max(-1, ...cols.values()) + 1 }, () => 0);
102
77
  for (const lane of lanes)
103
78
  for (const b of lane.children)
104
79
  colW[cols.get(b.id)] = Math.max(colW[cols.get(b.id)], size(b, s).w);
105
80
  const gutter = gutterOf(lanes);
106
- const colX = colW.map((_, c) => gutter + colW.slice(0, c).reduce((a, w) => a + w + LANE_GAP, 0));
107
- const width = gutter + colW.reduce((a, w) => a + w, 0) + LANE_GAP * Math.max(0, colW.length - 1) + FRAME_SIDE;
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
+ }));
108
99
  const topAt = out.length;
109
100
  out.push({ x, y, w: width, h: 0, item: fig.layout });
110
101
  let ly = y;
111
- for (const lane of lanes) {
112
- const kids = lane.children.map((b) => ({ b, ...size(b, s) }));
113
- const inner = Math.max(LABEL_LINE + 20, ...kids.map((k) => k.h));
114
- const h = inner + LANE_PAD * 2;
115
- out.push({ x, y: ly, w: width, h, item: lane, lane: true });
116
- for (const k of kids)
117
- 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 });
118
- ly += h + LANE_ROW_GAP;
119
- }
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
+ });
120
118
  out[topAt].h = ly - LANE_ROW_GAP - y;
121
119
  }
122
- /** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
123
- const gutterOf = (lanes) => Math.max(0, ...lanes.map((l) => textWidth(str(l.label).toUpperCase(), 12))) + FRAME_SIDE * 2;
124
120
  /** A timeline: the axis strip on top, one band per track, and the bars and milestones at the shared layout x. */
125
121
  function placeTimeline(fig, x, y, out) {
126
122
  const lanes = fig.layout.children;
@@ -210,7 +206,9 @@ export function render(fig, opts = {}) {
210
206
  return n > 2 ? n * 30 : 0;
211
207
  };
212
208
  const cardH = new Map();
213
- const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges), fig };
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) };
214
212
  for (const [id, contents] of cards) {
215
213
  const width = CARD_WIDTH; // refined below once the node's own width is known
216
214
  const widths = [width];
@@ -218,7 +216,7 @@ export function render(fig, opts = {}) {
218
216
  }
219
217
  const placed = [];
220
218
  place(fig.layout, pad, pad, sizes, placed);
221
- const nodes = placed.filter((p) => !isGroup(p.item));
219
+ let nodes = placed.filter((p) => !isGroup(p.item));
222
220
  // A node's own width can differ from CARD_WIDTH (`width` in the spec), so re-measure once placed.
223
221
  for (const p of nodes) {
224
222
  if (!cards.has(p.item.id))
@@ -226,21 +224,51 @@ export function render(fig, opts = {}) {
226
224
  const inner = p.w - 20;
227
225
  cardH.set(p.item.id, Math.max(...cards.get(p.item.id).map((c) => layoutCard(c, inner).height), LINE + CARD_PAD * 2));
228
226
  }
229
- placed.length = 0;
230
- place(fig.layout, pad, pad, sizes, placed);
231
227
  const rects = {};
232
- for (const p of placed)
233
- if (p.item.id)
234
- rects[p.item.id] = p;
235
228
  const tips = new Set(placed.filter((p) => !isGroup(p.item) && (p.item.shape === 'decision' || p.tl?.milestone)).map((p) => p.item.id));
236
229
  const ids = fig.edges.map(edgeId);
237
- const routed = route(fig.edges.map((e, i) => ({
238
- id: ids[i],
239
- from: e.from,
240
- to: e.to,
241
- around: e.around,
242
- ...(tl && { sides: ['r', 'l'], elbow: true }),
243
- })), rects, tips, placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))));
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));
244
272
  const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
245
273
  // What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
246
274
  const fonts = [14, ...(steps.length ? [13.5] : [])];
@@ -255,12 +283,18 @@ export function render(fig, opts = {}) {
255
283
  fonts.push(13);
256
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 }] };
257
285
  }
258
- const room = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
259
- const texts = [{ text: str(p.item.label), fontSize: 14, room }];
260
- if (p.item.sub) {
261
- texts.push({ text: str(p.item.sub), fontSize: 12, room });
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)
262
297
  fonts.push(12);
263
- }
264
298
  for (const c of cards.get(p.item.id) ?? [])
265
299
  for (const r of layoutCard(c, p.w - 20).rows) {
266
300
  const fontSize = r.row.mono ? 10.5 : 11;
@@ -443,7 +477,8 @@ export function render(fig, opts = {}) {
443
477
  const cx = p.x + p.w / 2;
444
478
  const contents = cards.get(item.id);
445
479
  const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
446
- 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);
447
482
  const shape = item.shape === 'decision'
448
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}/>`
449
484
  : item.shape === 'store'
@@ -451,7 +486,11 @@ export function render(fig, opts = {}) {
451
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}/>`
452
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}/>`;
453
488
  const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
454
- const sub = item.sub ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>` : '';
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
+ : '';
455
494
  // The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
456
495
  const dot = bt ?? 'var(--accent)';
457
496
  const my = n2(p.y + p.h / 2);
@@ -531,20 +570,30 @@ export function render(fig, opts = {}) {
531
570
  const lit = cls(toned
532
571
  ? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
533
572
  : anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
534
- 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}/>`;
535
579
  // A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
536
- const label = e.label == null || tl
537
- ? ''
538
- : (() => {
539
- const lw = labelPillW(str(e.label));
540
- labelRects[r.id] = { x: r.mid.x - lw / 2, y: r.mid.y - 9, w: lw, h: 18 };
541
- fonts.push(11);
542
- 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)"` +
543
- cls(toned
544
- ? 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)')
545
- : anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
546
- `/><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>`);
547
- })();
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
+ }
548
597
  return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
549
598
  });
550
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`
@@ -643,7 +692,7 @@ export function render(fig, opts = {}) {
643
692
  }
644
693
  }
645
694
  const bounds = placed[0];
646
- 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
647
696
  const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
648
697
  const mapW = Math.max(bounds.w + pad * 2, 560);
649
698
  // `rail: 'only'` drops the map, but only when the rail has a hop to draw; a figure never renders empty.
@@ -800,9 +849,12 @@ ${said.join('\n')}
800
849
  width: n2(W),
801
850
  boxes: only ? [] : sceneBoxes,
802
851
  edges: [
803
- ...(only ? [] : routed).map((r) => {
852
+ ...(only ? [] : routed).flatMap((r) => {
804
853
  const e = fig.edges[ids.indexOf(r.id)];
805
- return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id], ...(tl && { behind: true }) };
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 }) }];
806
858
  }),
807
859
  // Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
808
860
  ...(rail?.rows ?? []).flatMap((row, i) => {
@@ -826,14 +878,30 @@ ${said.join('\n')}
826
878
  }),
827
879
  ],
828
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
+ }),
829
884
  };
830
885
  return { svg, scene };
831
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
+ }
832
894
  /** One self-contained animated SVG string for the figure. Needs no React and no browser. */
833
895
  export function toSvg(fig, opts = {}) {
834
896
  return render(fig, opts).svg;
835
897
  }
836
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. */
837
899
  export function check(fig, opts = {}) {
838
- 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 })];
839
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.4.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.4.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.4.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": {