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/index.js CHANGED
@@ -5,7 +5,7 @@ import { route } from './geometry.js';
5
5
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
6
6
  import { textWidth } from './text.js';
7
7
  import { checkScene, checkSpec, checkTheme } from './check.js';
8
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, laneColumns, LANE_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, dateLabelRaised, } from './model.js';
8
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, beatMs, STEP_HOLD_MS, playheadItem, dateLabelRaised, } from './model.js';
9
9
  export { LIGHT, DARK } from './model.js';
10
10
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
11
11
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
@@ -74,6 +74,14 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
74
74
  const [holding, setHolding] = useState(false); // the step hold: a timeline's today line rests at today
75
75
  const paths = useRef({});
76
76
  const [routed, setRouted] = useState([]);
77
+ // Lane copies that grow because a stub there had no place clear of every edge path (see `tightCopies`).
78
+ const [extraTall, setExtraTall] = useState(new Set());
79
+ // Lanes: the shared wrap plan (blocks, gaps, stubs), the same as the SVG at the default 830 px width.
80
+ const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
81
+ ? planLanes({ layout, edges, steps, lanes },
82
+ // A rail sets a least width, as in the SVG.
83
+ { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
84
+ : null, [lanes, tl, layout, edges, steps, withRail]);
77
85
  const [active, setActive] = useState(steps.length ? 0 : null);
78
86
  const [playing, setPlaying] = useState(autoplay);
79
87
  // The step's clock lives outside React: pausing freezes it, resizing keeps it, only a new step resets it.
@@ -169,13 +177,62 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
169
177
  const r = n.getBoundingClientRect();
170
178
  avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
171
179
  });
172
- setRouted(route(edges.map((e, i) => ({
180
+ // Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane labels.
181
+ const stubs = lanePlan?.stubs ?? new Map();
182
+ if (stubs.size) {
183
+ for (const n of nodes(layout))
184
+ if (rects[n.id])
185
+ avoid.push(rects[n.id]);
186
+ el.querySelectorAll('[data-fig-gutter]').forEach((n) => {
187
+ const r = n.getBoundingClientRect();
188
+ avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
189
+ });
190
+ }
191
+ // A wrapped lane end routes to the band of its block (`data-fig-copy`), as in the SVG.
192
+ const bands = {};
193
+ el.querySelectorAll('[data-fig-copy]').forEach((n) => {
194
+ const r = n.getBoundingClientRect();
195
+ bands[n.dataset.figCopy] = { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
196
+ });
197
+ // A stub pill stays in the lanes, right of the gutter, as in the SVG.
198
+ const blockRects = [...el.querySelectorAll('[data-fig-block]')].map((n) => n.getBoundingClientRect());
199
+ const pillArea = stubs.size && blockRects.length
200
+ ? (() => {
201
+ const [x0, y0] = [Math.min(...blockRects.map((r) => r.left)), Math.min(...blockRects.map((r) => r.top))];
202
+ const [x1, y1] = [Math.max(...blockRects.map((r) => r.right)), Math.max(...blockRects.map((r) => r.bottom))];
203
+ const x = (x0 - base.left) / k + lanePlan.gutter;
204
+ return { x, y: (y0 - base.top) / k, w: (x1 - base.left) / k - x, h: (y1 - y0) / k };
205
+ })()
206
+ : undefined;
207
+ // The band of an edge end, in the block of that end, as in the SVG.
208
+ const laneBands = {};
209
+ el.querySelectorAll('[data-fig-band]').forEach((n) => {
210
+ const r = n.getBoundingClientRect();
211
+ laneBands[n.dataset.figBand] = { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
212
+ });
213
+ const bandOf = (eid, id, j) => {
214
+ const li = layout.children.findIndex((l) => isGroup(l) && (l.id === id || l.children.some((b) => b.id === id)));
215
+ return laneBands[`${li}@${lanePlan?.ends.get(eid)?.[j]}`];
216
+ };
217
+ const end = (eid, id, start) => lanePlan ? laneEnd(lanePlan, eid, id, start, (lane, b) => bands[`${lane}@${b}`], rects) : id;
218
+ const next = route(edges.map((e, i) => ({
173
219
  id: ids[i],
174
- from: e.from,
175
- to: e.to,
176
- around: e.around,
220
+ from: end(ids[i], e.from, true),
221
+ to: end(ids[i], e.to, false),
222
+ around: e.around ?? (lanePlan?.around.has(ids[i]) ? 'below' : undefined),
177
223
  ...(tl && { sides: ['r', 'l'], elbow: true }),
178
- })), rects, tips, avoid));
224
+ ...(stubs.has(ids[i]) && {
225
+ stub: stubs.get(ids[i]).map(labelPillW),
226
+ bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)],
227
+ }),
228
+ ...(stubs.size && e.label != null && { labelW: labelPillW(str(e.label)) }),
229
+ })), rects, tips, avoid, pillArea);
230
+ setRouted(next);
231
+ if (lanePlan) {
232
+ const more = [...tightCopies(layout.children, lanePlan.ends, edges, next)].filter((k) => !lanePlan.tall.has(k) && !extraTall.has(k));
233
+ if (more.length)
234
+ setExtraTall(new Set([...extraTall, ...more]));
235
+ }
179
236
  };
180
237
  measure();
181
238
  const ro = new ResizeObserver(measure);
@@ -183,7 +240,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
183
240
  ro.observe(box);
184
241
  el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
185
242
  return () => ro.disconnect();
186
- }, [edges, ids, layout, tips, noMap, lanes, tl, axisW]);
243
+ }, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall]);
187
244
  // The same rules as `flowfig check`, on what the browser actually drew: real fonts, real wrapping. Each fault prints once.
188
245
  const reported = useRef(new Set());
189
246
  useEffect(() => {
@@ -201,16 +258,30 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
201
258
  const boxIds = new Set(nodes(layout).map((n) => n.id));
202
259
  const boxes = [...(el ?? fig).querySelectorAll('[data-fig]')]
203
260
  .filter((n) => boxIds.has(n.dataset.fig))
204
- .map((n) => ({
205
- id: n.dataset.fig,
206
- rect: rel(n),
207
- texts: [...n.querySelectorAll('*')]
208
- .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
209
- .map((t) => {
210
- const fontSize = parseFloat(getComputedStyle(t).fontSize);
211
- return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
212
- }),
213
- }));
261
+ .map((n) => {
262
+ const rect = rel(n);
263
+ return {
264
+ id: n.dataset.fig,
265
+ rect,
266
+ texts: [...n.querySelectorAll('*')]
267
+ .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
268
+ .map((t) => {
269
+ const fontSize = parseFloat(getComputedStyle(t).fontSize);
270
+ if (!n.dataset.diamond)
271
+ return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
272
+ // A diamond gives a line the width of its outline at the far edge of the line, and the line is as wide as its text.
273
+ const [r, range] = [rel(t), document.createRange()];
274
+ range.selectNodeContents(t);
275
+ const far = Math.max(Math.abs(r.y - rect.y - rect.h / 2), Math.abs(r.y + r.h - rect.y - rect.h / 2));
276
+ return {
277
+ text: t.textContent,
278
+ fontSize,
279
+ room: diamondRoom(rect.w, rect.h, far),
280
+ need: range.getBoundingClientRect().width / k,
281
+ };
282
+ }),
283
+ };
284
+ });
214
285
  // Every visible leaf text counts, as in the SVG scene; the 9 px tag pills do not.
215
286
  // The rail is a sibling of the map, so the rail queries start at the figure. An SVG <text> has no CSS box, so it skips clientWidth.
216
287
  const fonts = [...fig.querySelectorAll('*')]
@@ -220,13 +291,23 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
220
291
  !t.closest('[data-fig-tag],[aria-hidden]'))
221
292
  .map((t) => parseFloat(getComputedStyle(t).fontSize));
222
293
  const labels = Object.fromEntries([...fig.querySelectorAll('[data-fig-label]')].map((n) => [n.dataset.figLabel, rel(n)]));
294
+ const lanesIn = el ? [...el.querySelectorAll('[data-fig-band]')] : [];
223
295
  const scene = {
224
296
  width: Math.max(el?.offsetWidth ?? 0, rail?.width ?? 0),
225
297
  boxes,
298
+ ...(lanePlan?.stubs.size && {
299
+ lanes: lanesIn.map((n) => ({
300
+ id: str(layout.children[Number(n.dataset.figBand.split('@')[0])].label),
301
+ rect: rel(n),
302
+ })),
303
+ }),
226
304
  edges: [
227
- ...routed.map((r) => {
305
+ ...routed.flatMap((r) => {
228
306
  const e = edges[ids.indexOf(r.id)];
229
- return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) };
307
+ // A cross-block edge is two scene edges: each stub with its pill.
308
+ if (r.stub)
309
+ return r.stub.pts.map((pts, j) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: labels[`${r.id}:${j}`] }));
310
+ return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) }];
230
311
  }),
231
312
  // A rail pill is a label for label-overlap; its curve is a point at the pill center, so it can cross no box.
232
313
  ...(rail?.rows ?? []).flatMap((row) => {
@@ -259,7 +340,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
259
340
  reported.current.add(key);
260
341
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
261
342
  }
262
- }, [check, routed, layout, edges, steps, ids, theme, rail, noMap]);
343
+ }, [check, routed, layout, edges, steps, ids, theme, rail, noMap, lanePlan]);
263
344
  // Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
264
345
  useEffect(() => {
265
346
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
@@ -501,45 +582,60 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
501
582
  }, children: dateText }, dateText) })] }))] }));
502
583
  }
503
584
  if (lanes && item === layout && isLanesLayout(layout)) {
504
- const cols = laneColumns({ layout, edges, steps, lanes });
585
+ const { cols, starts, gaps, lead, blocks, gutter, tall } = lanePlan;
505
586
  const n = Math.max(0, ...cols.values()) + 1;
506
- return (_jsx("div", { style: {
507
- display: 'grid',
508
- gridTemplateColumns: `max-content repeat(${n}, max-content)`,
509
- columnGap: 0,
510
- rowGap: LANE_ROW_GAP,
511
- position: 'relative',
512
- }, children: layout.children.map((lane, li) => {
513
- const lit = lane.id != null && litNodes.has(lane.id);
514
- return (_jsxs(Fragment, { children: [_jsx("div", { "data-fig": lane.id, "data-fig-lane": "", style: {
515
- gridColumn: '1 / -1',
516
- gridRow: li + 1,
517
- background: v('surface'),
518
- border: `1px solid ${lit ? v('accent') : v('border')}`,
519
- boxShadow: lit ? glow : undefined,
520
- transition: 'border-color .25s, box-shadow .25s',
521
- borderRadius: 14,
522
- zIndex: 0,
523
- } }), _jsx("div", { style: {
524
- gridColumn: 1,
525
- gridRow: li + 1,
526
- alignSelf: 'center',
527
- zIndex: 1,
528
- padding: '0 18px',
529
- fontSize: 12,
530
- fontWeight: 600,
531
- letterSpacing: '.04em',
532
- textTransform: 'uppercase',
533
- color: v('muted'),
534
- }, children: lane.label }), lane.children.map((b) => (_jsx("div", { style: {
535
- gridColumn: cols.get(b.id) + 2,
536
- gridRow: li + 1,
537
- alignSelf: 'center',
538
- zIndex: 1,
539
- // The last time column keeps the frame side, as in the SVG.
540
- padding: `${LANE_PAD}px ${cols.get(b.id) === n - 1 ? 18 : LANE_GAP}px ${LANE_PAD}px 0`,
541
- }, children: renderItem(b, 1) }, b.id)))] }, lane.id ?? String(lane.label)));
542
- }) }));
587
+ const count = blocks.length;
588
+ // One grid per block of `per` time columns, as in the SVG. A wrapped block shows only the lanes with a box in it.
589
+ const grid = (bk) => {
590
+ const m = starts[bk + 1] - starts[bk];
591
+ const inBlock = (b) => laneBlock(starts, cols.get(b.id)) === bk;
592
+ const shown = layout.children.filter((lane) => count < 2 || lane.children.some(inBlock));
593
+ return (_jsx("div", { "data-fig-block": bk, style: {
594
+ display: 'grid',
595
+ // A wrapped block stretches to the widest block, so all bands share one width.
596
+ // Every block has the gutter of the plan, so the labels and the columns line up as in the SVG.
597
+ gridTemplateColumns: `${gutter}px repeat(${m}, max-content)${count > 1 ? ' 1fr' : ''}`,
598
+ // An empty lane keeps the SVG's least band height: a 38 px label line plus the lane padding.
599
+ gridAutoRows: `minmax(${38 + LANE_PAD * 2}px, auto)`,
600
+ columnGap: 0,
601
+ rowGap: LANE_ROW_GAP,
602
+ position: 'relative',
603
+ }, children: shown.map((lane, li) => {
604
+ const lit = lane.id != null && litNodes.has(lane.id);
605
+ return (_jsxs(Fragment, { children: [_jsx("div", { "data-fig": lane.id, "data-fig-copy": lane.id != null ? `${lane.id}@${bk}` : undefined, "data-fig-band": `${layout.children.indexOf(lane)}@${bk}`, "data-fig-lane": "", style: {
606
+ gridColumn: '1 / -1',
607
+ gridRow: li + 1,
608
+ background: v('surface'),
609
+ border: `1px solid ${lit ? v('accent') : v('border')}`,
610
+ boxShadow: lit ? glow : undefined,
611
+ transition: 'border-color .25s, box-shadow .25s',
612
+ borderRadius: 14,
613
+ zIndex: 0,
614
+ } }), _jsx("div", { "data-fig-gutter": "", style: {
615
+ gridColumn: 1,
616
+ gridRow: li + 1,
617
+ alignSelf: 'center',
618
+ zIndex: 1,
619
+ padding: '0 18px',
620
+ whiteSpace: 'nowrap',
621
+ fontSize: 12,
622
+ fontWeight: 600,
623
+ letterSpacing: '.04em',
624
+ textTransform: 'uppercase',
625
+ color: v('muted'),
626
+ }, children: lane.label }), lane.children.filter(inBlock).map((b) => (_jsx("div", { style: {
627
+ gridColumn: cols.get(b.id) - starts[bk] + 2,
628
+ gridRow: li + 1,
629
+ alignSelf: 'center',
630
+ zIndex: 1,
631
+ // The space after each column and before a block's first column comes from the plan, as in the SVG.
632
+ padding: `${LANE_PAD}px ${gaps[cols.get(b.id)]}px ${LANE_PAD + (tall.has(`${layout.children.indexOf(lane)}@${bk}`) || extraTall.has(`${layout.children.indexOf(lane)}@${bk}`) ? STUB_ROOM : 0)}px ${cols.get(b.id) === starts[bk] ? lead[bk] : 0}px`,
633
+ }, children: renderItem(b, 1) }, b.id)))] }, lane.id ?? String(lane.label)));
634
+ }) }, bk));
635
+ };
636
+ if (count < 2)
637
+ return grid(blocks[0]);
638
+ return _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: LANE_BLOCK_GAP }, children: blocks.map(grid) });
543
639
  }
544
640
  if (isGroup(item)) {
545
641
  const lit = item.id != null && litNodes.has(item.id);
@@ -578,14 +674,16 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
578
674
  const diamond = item.shape === 'decision';
579
675
  const store = item.shape === 'store';
580
676
  const card = carded.has(item.id);
677
+ // The sub of a diamond wraps as in the SVG, one div per line, so a line stays inside the outline.
678
+ const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
581
679
  const bt = item.tone && TONES[item.tone];
582
680
  return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
583
681
  position: 'relative',
584
682
  isolation: 'isolate',
585
683
  minWidth: 100,
586
- maxWidth: card ? undefined : 190,
587
- width: item.width ?? (card ? CARD_WIDTH : undefined),
588
- minHeight: minHeight(item.id),
684
+ maxWidth: card || diamond ? undefined : 190,
685
+ width: diamond ? nodeWidth(item, card) + 70 : (item.width ?? (card ? CARD_WIDTH : undefined)),
686
+ minHeight: dl ? Math.max(dl.h, (minHeight(item.id) ?? 0) + 24) : minHeight(item.id),
589
687
  boxSizing: 'border-box',
590
688
  display: 'flex',
591
689
  flexDirection: 'column',
@@ -637,7 +735,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
637
735
  border: `1.5px solid ${bt ?? v('accent')}`,
638
736
  background: `radial-gradient(circle, ${bt ?? v('accent')} 0 4px, transparent 4.5px)`,
639
737
  }),
640
- } })), _jsx("div", { children: item.label }), item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
738
+ } })), _jsx("div", { children: item.label }), dl
739
+ ? dl.subs.map((l, i) => (_jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), lineHeight: '15px', whiteSpace: 'nowrap' }, children: l.text }, i)))
740
+ : item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
641
741
  marginTop: 8,
642
742
  padding: '6px 8px',
643
743
  minHeight: item.lines != null ? item.lines * CARD_LINE + CARD_PAD * 2 + 1 : undefined,
@@ -710,8 +810,11 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
710
810
  transformOrigin: 'top left',
711
811
  padding: 4,
712
812
  // room for edges that arc over or under the boxes
713
- paddingTop: edges.some((e) => e.around === 'above') ? 44 : 4,
714
- paddingBottom: edges.some((e) => e.around === 'below') ? 44 : 4,
813
+ // A stub does not arc.
814
+ paddingTop: edges.some((e, i) => e.around === 'above' && !lanePlan?.stubs.has(ids[i])) ? 44 : 4,
815
+ paddingBottom: edges.some((e, i) => (e.around === 'below' || (!e.around && lanePlan?.around.has(ids[i]))) && !lanePlan?.stubs.has(ids[i]))
816
+ ? 44
817
+ : 4,
715
818
  }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
716
819
  position: 'absolute',
717
820
  inset: 0,
@@ -725,9 +828,18 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
725
828
  const on = litEdges.has(r.id);
726
829
  const tone = hopTone(r.id);
727
830
  const hidden = !on && edges[ids.indexOf(r.id)].quiet;
728
- return (_jsx("path", { ref: (p) => {
831
+ const look = (d, key, guides = false) => (_jsx("path", { ref: guides
832
+ ? (p) => {
833
+ paths.current[r.id] = p;
834
+ }
835
+ : undefined, d: d, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s' } }, key));
836
+ const guide = (_jsx("path", { ref: (p) => {
729
837
  paths.current[r.id] = p;
730
- }, d: r.d, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s' } }, r.id));
838
+ }, d: r.d, fill: "none", stroke: "none" }, r.id));
839
+ // A cross-block edge draws its two stubs; its full path only guides the packet, which jumps between the blocks.
840
+ if (r.stub)
841
+ return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
842
+ return look(r.d, r.id, true);
731
843
  }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
732
844
  dots.current[j] = g;
733
845
  }, style: { opacity: 0, color: v('accent') }, children: [_jsx("circle", { r: 10, fill: "currentColor", opacity: 0.2 }), _jsx("circle", { r: 4.5, fill: "currentColor" })] }, j)))] }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsx("div", { ref: (c) => {
@@ -749,16 +861,26 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
749
861
  color: ON_ACCENT,
750
862
  boxShadow: '0 4px 14px rgba(0,0,0,.18)',
751
863
  transition: 'opacity .2s',
752
- }, children: cur?.hops[j]?.data }, j))), routed.map((r) => {
864
+ }, children: cur?.hops[j]?.data }, j))), routed.flatMap((r) => {
753
865
  const e = edges[ids.indexOf(r.id)];
754
- if (e.label == null || tl)
755
- return null;
866
+ const texts = r.stub && lanePlan?.stubs.get(r.id);
867
+ if (!texts && (e.label == null || tl))
868
+ return [];
756
869
  const on = litEdges.has(r.id);
757
870
  const tone = hopTone(r.id);
758
- return (_jsx("div", { "data-fig-label": r.id, style: {
871
+ // A cross-block edge has two pills, one per stub, in place of its label.
872
+ const pills = texts
873
+ ? r.stub.pills.map((p, j) => ({
874
+ key: `${r.id}:${j}`,
875
+ x: p.x + p.w / 2,
876
+ y: p.y + p.h / 2,
877
+ text: (j === 0 && r.stub.short ? texts[2] : texts[j]),
878
+ }))
879
+ : [{ key: r.id, x: r.mid.x, y: r.mid.y, text: e.label }];
880
+ return pills.map((p) => (_jsx("div", { "data-fig-label": p.key, style: {
759
881
  position: 'absolute',
760
- left: r.mid.x,
761
- top: r.mid.y,
882
+ left: p.x,
883
+ top: p.y,
762
884
  transform: 'translate(-50%, -50%)',
763
885
  fontSize: 11,
764
886
  lineHeight: '16px',
@@ -772,7 +894,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
772
894
  opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
773
895
  fontFamily: MONO,
774
896
  transition: 'background .25s, color .25s, opacity .25s',
775
- }, children: e.label }, r.id));
897
+ }, children: p.text }, p.key)));
776
898
  })] }) }) }) })), rail && (_jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
777
899
  display: 'block',
778
900
  width: '100%',
package/dist/mcp.js CHANGED
@@ -100,7 +100,7 @@ function run(name, args) {
100
100
  if (name === 'check' || errors)
101
101
  return ok(lines.join('\n'), errors);
102
102
  const out = String(args.out);
103
- const svg = svgWithSpec(props);
103
+ const svg = svgWithSpec(props, opts);
104
104
  try {
105
105
  mkdirSync(dirname(out), { recursive: true });
106
106
  writeFileSync(out, svg);
package/dist/model.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { Rect } from './geometry.ts';
2
3
  /** A box in the figure. A box with a `show` in some step becomes a content card: it grows to fit the most any step puts in it. */
3
4
  export type FigNode = {
4
5
  /** Name that edges, steps and beats use to point at this box. Must be unique in the figure. */
@@ -248,6 +249,96 @@ export declare const validAt: (at: unknown) => at is number;
248
249
  /** 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),
249
250
  * then the boxes no step touches in layout order. `at` overrides. Two boxes may share a column. */
250
251
  export declare function laneColumns(fig: FlowProps): Map<string, number>;
252
+ /** The side of an SVG frame, and the width limits of a box with no card. The lane split measures with them. */
253
+ export declare const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
254
+ /** The gap between two blocks of wrapped lanes. */
255
+ export declare const LANE_BLOCK_GAP = 40;
256
+ /** 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. */
257
+ export declare const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
258
+ /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
259
+ export declare function nodeWidth(item: FigNode, carded: boolean): number;
260
+ /** 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. */
261
+ export declare const diamondRoom: (w: number, h: number, far: number) => number;
262
+ /** 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
263
+ * 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
264
+ * 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. */
265
+ export declare function diamondLines(item: FigNode, w: number): {
266
+ h: number;
267
+ label: {
268
+ dy: number;
269
+ far: number;
270
+ };
271
+ subs: {
272
+ text: string;
273
+ dy: number;
274
+ far: number;
275
+ }[];
276
+ };
277
+ /** The lane copies (`laneIndex@block`) that hold a stub end with no place clear of every edge path. A renderer grows each by
278
+ * STUB_ROOM and routes again: the pill then fits under or over its box, inside the band. Both renderers call it with their routes. */
279
+ export declare function tightCopies(lanes: FigGroup[], ends: Map<string, [number, number]>, edges: FlowProps['edges'], routed: {
280
+ id: string;
281
+ stub?: {
282
+ tight: [boolean, boolean];
283
+ };
284
+ }[]): Set<string>;
285
+ /** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
286
+ export declare const laneGutter: (lanes: FigGroup[]) => number;
287
+ /** How a lanes figure wraps. Both renderers lay out with it, so the player shows the blocks and stubs of the SVG. */
288
+ export type LanePlan = {
289
+ /** The time column of each box. */
290
+ cols: Map<string, number>;
291
+ /** The first column of each block, then the column count: block k holds columns starts[k] to starts[k + 1] - 1. */
292
+ starts: number[];
293
+ /** The most columns in one block. */
294
+ per: number;
295
+ /** The blocks that hold a box, in order. A block with no box is not drawn. A figure with no box keeps block 0. */
296
+ blocks: number[];
297
+ /** The width of the label gutter, the same in every block. */
298
+ gutter: number;
299
+ /** The space after each column: the gap to the next column of its block, or the right margin of the band for the last one. */
300
+ gaps: number[];
301
+ /** The room before the first column of each block, after the gutter. */
302
+ lead: number[];
303
+ /** The block of the source and of the target of each edge, by edge id. A lane end names the copy of the lane in that block. */
304
+ ends: Map<string, [number, number]>;
305
+ /** The edges whose ends sit in two blocks, by edge id: the pill texts at the source and at the target, then a shorter source
306
+ * text without the edge label, if the edge has one. */
307
+ stubs: Map<string, string[]>;
308
+ /** Edges between two boxes of one lane and one block, with a box of that lane between them and no `around` of their own.
309
+ * A straight route would cross that box, so the renderers route them around below. */
310
+ around: Set<string>;
311
+ /** The lane copies (`laneIndex@block`) that grow by STUB_ROOM at the bottom: a stub pill there has no room beside its box. */
312
+ tall: Set<string>;
313
+ /** Edges with a lane end that has no block on the side of the other end. They use the nearest block; `check` reports them. */
314
+ lost: string[];
315
+ };
316
+ /** The block of a time column, from the block starts of a plan. */
317
+ export declare const laneBlock: (starts: number[], c: number) => number;
318
+ /** The pill texts of a cross-block edge: "label → Target" at the source, "from Source" at the target, and "→ Target", a
319
+ * shorter source text for a pill that has no place for the long one. */
320
+ export declare const stubTexts: (label: unknown, from: unknown, to: unknown) => string[];
321
+ /**
322
+ * 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
323
+ * 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.
324
+ * 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.
325
+ * If the wrap cannot make the text readable, the lanes do not wrap: the smallest font is under `minText`, or `floor` (the least
326
+ * width of the rest of the figure, such as the rail) is already too wide.
327
+ * 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
328
+ * 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.
329
+ */
330
+ export declare function lanePlan(fig: FlowProps, { width, minText, padding, floor }?: {
331
+ floor?: number | undefined;
332
+ minText?: number | undefined;
333
+ padding?: number | undefined;
334
+ width?: number | undefined;
335
+ }): LanePlan;
336
+ /**
337
+ * 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
338
+ * 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
339
+ * 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`.
340
+ */
341
+ export declare function laneEnd(plan: LanePlan, edge: string, id: string, start: boolean, band: (lane: string, block: number) => Rect | undefined, rects: Record<string, Rect>): string;
251
342
  /** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
252
343
  * the axis strip height and the axis width of the SVG. */
253
344
  export declare const TL_BAR_H = 28, TL_ROW_GAP = 8, TL_MIN_BAR = 24, TL_DIAMOND = 14, TL_AXIS_H = 28, TL_AXIS_W = 640;