flowfig 0.9.0 → 0.11.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
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Fragment, useEffect, useId, useMemo, useRef, useState } from 'react';
4
- import { arcRoom, avoidOf, route } from './geometry.js';
3
+ import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import { arcRoom, avoidOf, markSide, 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, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, altText, 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, labelLines, fitCap, LABEL_LINE, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, edgeTip, } from './model.js';
8
+ import { autoLayout, rowBreaks } from './auto.js';
9
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, altText, 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, labelLines, fitCap, LABEL_LINE, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, edgeTip, fitScale, shownFont, textFloor, specKey, nextWide, } from './model.js';
9
10
  export { LIGHT, DARK } from './model.js';
10
11
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
11
12
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
@@ -54,17 +55,37 @@ const HIDDEN = {
54
55
  * The interactive player for one figure. Give it a `FlowProps` spec.
55
56
  * Use `toSvg` from `flowfig/svg` for a static animated SVG.
56
57
  */
57
- export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, }) {
58
+ export function Flow(props) {
59
+ const { layout, edges, steps, lanes, timeline } = props;
60
+ const key = useMemo(() => specKey(layout, edges, steps), [layout, edges, steps]);
61
+ const [narrow, setNarrow] = useState({ wide: 0, key: '' });
62
+ const fold = !lanes && !timeline && props.rail !== 'only';
63
+ const wide = fold && narrow.key === key ? narrow.wide : 0;
64
+ const setWide = useCallback((w) => setNarrow({ wide: w, key }), [key]);
65
+ const placed = useMemo(() => autoLayout({ layout: wide > 0 ? { ...layout, auto: true, direction: 'column' } : layout, edges, steps, lanes, timeline }), [wide, layout, edges, steps, lanes, timeline]);
66
+ return (_jsx(FlowBody, { ...props, layout: placed.layout, edges: placed.edges, written: props, wide: wide, setWide: fold ? setWide : undefined }));
67
+ }
68
+ // The ASYNC tag is 9 px.
69
+ const RAIL_TEXT = 9;
70
+ // offsetWidth is rounded, so a rect ratio reads scale 1 as 0.999.
71
+ const shownScale = (el) => {
72
+ let k = 1;
73
+ for (let n = el; n; n = n.parentElement)
74
+ k *= new DOMMatrix(getComputedStyle(n).transform).a;
75
+ return k;
76
+ };
77
+ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, written, wide, setWide, }) {
58
78
  const tl = timeline && isLanesLayout(layout);
59
79
  const alt = useMemo(() => altText({ layout, edges, steps: stepsIn }), [layout, edges, stepsIn]);
60
80
  const descId = useId();
81
+ const tabsId = useId();
61
82
  const synthetic = tl && !stepsIn.length;
62
83
  const steps = useMemo(() => (synthetic ? timelineBeats({ layout, edges, timeline, today }) : stepsIn), [synthetic, layout, edges, timeline, today, stepsIn]);
63
84
  const [axisW, setAxisW] = useState(TL_AXIS_W);
64
85
  const area = useRef(null);
65
86
  const root = useRef(null);
66
87
  const outer = useRef(null);
67
- const [fit, setFit] = useState({ scale: 1, height: 0 });
88
+ const [fit, setFit] = useState({ scale: 1, height: 0, scroll: false });
68
89
  const [full, setFull] = useState(false);
69
90
  useEffect(() => {
70
91
  if (!full)
@@ -82,6 +103,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
82
103
  const firstLabel = useRef('');
83
104
  const paths = useRef({});
84
105
  const [routed, setRouted] = useState([]);
106
+ const routedOn = useRef(new WeakMap());
107
+ const markSides = new Set(nodes(layout).flatMap((n) => (n.mark ? [markSide(n.id, n.mark, routed)] : [])));
85
108
  const [extraTall, setExtraTall] = useState(new Set());
86
109
  const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
87
110
  ? planLanes({ layout, edges, steps, lanes }, { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
@@ -129,6 +152,13 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
129
152
  const railOnly = withRail === 'only';
130
153
  const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
131
154
  const noMap = railOnly && rail != null;
155
+ const railPane = useRef(null);
156
+ const [railBox, setRailBox] = useState(0);
157
+ const zoom = wide ? 11 / RAIL_TEXT : noMap && railBox ? textFloor(railBox) / RAIL_TEXT : 0;
158
+ const follow = useRef(true);
159
+ const user = useRef(false);
160
+ const left = useRef(0);
161
+ const busy = useRef(0);
132
162
  const jump = useRef(null);
133
163
  const [hoverEdge, setHoverEdge] = useState(null);
134
164
  const [still, setStill] = useState(false);
@@ -148,15 +178,20 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
148
178
  const el = root.current, box = outer.current;
149
179
  if (!el || !box)
150
180
  return;
181
+ let last = '';
151
182
  const measure = () => {
152
- const scale = Math.max(0.5, Math.min(1, box.clientWidth / el.offsetWidth));
153
- setFit({ scale, height: el.offsetHeight * scale });
183
+ if (!el.offsetWidth)
184
+ return;
185
+ const goal = nextWide(wide, box.clientWidth, el.offsetWidth);
186
+ if (setWide && goal !== wide)
187
+ return setWide(goal);
188
+ const scale = fitScale(wide, box.clientWidth, el.offsetWidth);
189
+ setFit({ scale, height: el.offsetHeight * scale, scroll: scale > box.clientWidth / el.offsetWidth });
154
190
  setMapW(el.offsetWidth);
155
191
  if (area.current)
156
192
  setAxisW(area.current.offsetWidth);
157
193
  const base = el.getBoundingClientRect();
158
- // Rects are measured unscaled: k is the scale on screen.
159
- const k = base.width / el.offsetWidth;
194
+ const k = shownScale(el);
160
195
  const rects = {};
161
196
  el.querySelectorAll('[data-fig]').forEach((n) => {
162
197
  const r = n.getBoundingClientRect();
@@ -202,6 +237,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
202
237
  const li = layout.children.findIndex((l) => isGroup(l) && (l.id === id || l.children.some((b) => b.id === id)));
203
238
  return laneBands[`${li}@${lanePlan?.ends.get(eid)?.[j]}`];
204
239
  };
240
+ const key = JSON.stringify([rects, extra, bands, laneBands, pillArea], (_k, v) => (typeof v === 'number' ? Math.round(v) : v));
241
+ if (key === last)
242
+ return;
243
+ last = key;
205
244
  const end = (eid, id, start) => lanePlan ? laneEnd(lanePlan, eid, id, start, (lane, b) => bands[`${lane}@${b}`], rects) : id;
206
245
  const next = route(edges.map((e, i) => ({
207
246
  id: ids[i],
@@ -209,12 +248,14 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
209
248
  to: end(ids[i], e.to, false),
210
249
  around: e.around ?? (lanePlan?.around.has(ids[i]) ? 'below' : undefined),
211
250
  ...(tl && { sides: ['r', 'l'], elbow: true }),
251
+ ...(rowBreaks.has(e) && { drop: true }),
212
252
  ...(stubs.has(ids[i]) && {
213
253
  stub: stubs.get(ids[i]).map(labelPillW),
214
254
  bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)],
215
255
  }),
216
256
  ...(!tl && e.label != null && { labelW: labelPillW(str(e.label)) }),
217
257
  })), rects, tips, avoid, pillArea, lanePlan ? { bands: Object.values(laneBands), boxes: nodes(layout).flatMap((n) => (rects[n.id] ? [rects[n.id]] : [])) } : undefined);
258
+ routedOn.current.set(next, rects);
218
259
  setRouted(next);
219
260
  if (lanePlan) {
220
261
  const more = [...tightCopies(layout.children, lanePlan.ends, edges, next)].filter((k) => !lanePlan.tall.has(k) && !extraTall.has(k));
@@ -224,19 +265,44 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
224
265
  };
225
266
  measure();
226
267
  const ro = new ResizeObserver(measure);
227
- ro.observe(el);
268
+ ro.observe(el, { box: 'border-box' });
228
269
  ro.observe(box);
229
270
  el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
230
271
  return () => ro.disconnect();
231
- }, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall]);
272
+ }, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall, wide, setWide]);
273
+ useEffect(() => {
274
+ const pane = railPane.current;
275
+ const n = rail?.rows.flatMap((r) => (r.kind === 'message' && r.step === active && r.beat === beat ? [r.n] : []))[0];
276
+ const now = zoom && follow.current && n != null ? pane?.querySelector(`[data-rail-row="${n}"] path`) : null;
277
+ if (!pane || !now)
278
+ return;
279
+ const [p, r] = [pane.getBoundingClientRect(), now.getBoundingClientRect()];
280
+ user.current = false;
281
+ // Some browsers fire no scrollend event.
282
+ busy.current = performance.now() + 1000;
283
+ pane.scrollTo({ left: pane.scrollLeft + (r.left + r.right) / 2 - p.left - pane.clientWidth / 2, behavior: still ? 'auto' : 'smooth' });
284
+ }, [zoom, active, beat, still, rail]);
285
+ useEffect(() => {
286
+ const pane = railPane.current;
287
+ if (!pane)
288
+ return;
289
+ const end = () => (busy.current = 0);
290
+ pane.addEventListener('scrollend', end);
291
+ const ro = new ResizeObserver(() => setRailBox(pane.clientWidth));
292
+ if (noMap)
293
+ ro.observe(pane);
294
+ return () => (pane.removeEventListener('scrollend', end), ro.disconnect());
295
+ }, [rail, noMap]);
232
296
  const reported = useRef(new Set());
233
297
  useEffect(() => {
234
298
  const el = root.current, box = outer.current;
235
299
  const fig = figure.current;
236
- if (!check || !fig || (!noMap && (!el || !box)))
300
+ if (!check || !fig || (noMap ? !railBox : !el || !box || !el.offsetWidth))
301
+ return;
302
+ if (setWide && el && box && nextWide(wide, box.clientWidth, el.offsetWidth) !== wide)
237
303
  return;
238
304
  const base = (el ?? fig).getBoundingClientRect();
239
- const k = el ? base.width / el.offsetWidth : 1;
305
+ const k = el ? shownScale(el) : 1;
240
306
  const rel = (n) => {
241
307
  const r = n.getBoundingClientRect();
242
308
  return { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
@@ -253,7 +319,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
253
319
  .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
254
320
  .map((t) => {
255
321
  const fontSize = parseFloat(getComputedStyle(t).fontSize);
256
- if (!n.dataset.diamond)
322
+ if (!n.dataset.diamond || t.closest('[data-fig-outside]'))
257
323
  return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
258
324
  const [r, range] = [rel(t), document.createRange()];
259
325
  range.selectNodeContents(t);
@@ -267,17 +333,28 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
267
333
  }),
268
334
  };
269
335
  });
336
+ const moved = (b) => {
337
+ const r = routedOn.current.get(routed)?.[b.id];
338
+ return !r || Math.abs(r.x - b.rect.x) > 1 || Math.abs(r.y - b.rect.y) > 1;
339
+ };
340
+ if (el && boxes.some(moved))
341
+ return;
270
342
  const fonts = [...fig.querySelectorAll('*')]
271
343
  .filter((t) => !t.children.length &&
272
344
  t.textContent?.trim() &&
273
345
  // An SVG <text> has no CSS box, so skip clientWidth.
274
346
  (t instanceof SVGElement || t.clientWidth > 0) &&
275
347
  !t.closest('[data-fig-tag],[aria-hidden]'))
276
- .map((t) => parseFloat(getComputedStyle(t).fontSize));
348
+ .map((t) => {
349
+ const svg = railPane.current?.firstElementChild;
350
+ const place = el?.contains(t) ? 'map' : svg?.contains(t) && rail ? 'rail' : 'plain';
351
+ const railScale = svg && rail ? svg.getBoundingClientRect().width / rail.width : 1;
352
+ return shownFont(parseFloat(getComputedStyle(t).fontSize), place, { map: k, rail: railScale });
353
+ });
277
354
  const labels = Object.fromEntries([...fig.querySelectorAll('[data-fig-label]')].map((n) => [n.dataset.figLabel, rel(n)]));
278
355
  const lanesIn = el ? [...el.querySelectorAll('[data-fig-band]')] : [];
279
356
  const scene = {
280
- width: Math.max(el?.offsetWidth ?? 0, rail?.width ?? 0),
357
+ width: (box ?? fig).clientWidth,
281
358
  boxes,
282
359
  ...(lanePlan?.stubs.size && {
283
360
  lanes: lanesIn.map((n) => ({
@@ -322,8 +399,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
322
399
  minFont: Math.min(...fonts),
323
400
  };
324
401
  for (const f of [
325
- ...checkSpec({ layout, edges, steps }),
326
- ...checkScene(scene, { width: (box ?? fig).clientWidth }),
402
+ ...checkSpec(written),
403
+ ...checkScene(scene, { width: (box ?? fig).clientWidth, minText: textFloor((box ?? fig).clientWidth), auto: written.layout.auto }),
327
404
  ...checkTheme(theme),
328
405
  ]) {
329
406
  const key = f.rule + ':' + f.ids.join() + ':' + f.message;
@@ -333,7 +410,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
333
410
  // oxlint-disable-next-line no-console
334
411
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
335
412
  }
336
- }, [check, routed, layout, edges, steps, ids, theme, rail, noMap, lanePlan]);
413
+ }, [check, routed, written, layout, edges, steps, ids, theme, rail, noMap, railBox, lanePlan, wide, setWide]);
337
414
  useEffect(() => {
338
415
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
339
416
  if (clock.current.beats !== beats) {
@@ -453,6 +530,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
453
530
  return t && TONES[t];
454
531
  };
455
532
  const goTo = (step, b) => {
533
+ follow.current = true;
456
534
  setPlaying(true);
457
535
  if (step === active) {
458
536
  clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + beatMs(x, speed), 0);
@@ -648,15 +726,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
648
726
  display: 'flex',
649
727
  flexDirection: item.direction ?? 'row',
650
728
  gap: groupGap(item, edges),
651
- alignItems: item.align
652
- ? item.align === 'center'
653
- ? 'center'
654
- : 'flex-' + item.align
655
- : item.direction === 'column'
656
- ? 'stretch'
657
- : 'center',
729
+ alignItems: !item.align || item.align === 'center' ? 'center' : 'flex-' + item.align,
658
730
  justifyContent: 'center',
659
- }, children: item.children.map((c) => renderItem(c, depth + 1)) })] }, item.id ?? depth + String(item.label)));
731
+ }, children: item.children.map((c) => renderItem(c, depth + 1)) })] }, item.id ?? `group:${nodes(item)[0]?.id}`));
660
732
  }
661
733
  const lit = litNodes.has(item.id);
662
734
  const diamond = item.shape === 'decision';
@@ -664,7 +736,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
664
736
  const card = carded.has(item.id);
665
737
  const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
666
738
  const w = diamond || card || item.width != null || str(item.label) ? nodeWidth(item, card, cap) + (diamond ? 70 : 0) : undefined;
667
- const lines = w != null && !diamond && str(item.label) ? labelLines(item, w) : null;
739
+ const lines = w != null && str(item.label) ? labelLines(item, w) : null;
668
740
  const bt = item.tone && TONES[item.tone];
669
741
  return (_jsxs("div", { "data-fig": item.id, title: item.source, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
670
742
  position: 'relative',
@@ -711,14 +783,12 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
711
783
  transition: 'border-color .25s',
712
784
  } })), item.mark && (_jsx("span", { "aria-hidden": true, style: {
713
785
  position: 'absolute',
714
- top: '50%',
715
- transform: 'translateY(-50%)',
786
+ ...markAt(markSide(item.id, item.mark, routed), item.mark === 'start' ? -18 : -20),
716
787
  boxSizing: 'border-box',
717
788
  borderRadius: '50%',
718
789
  ...(item.mark === 'start'
719
- ? { left: -18, width: 10, height: 10, background: bt ?? v('accent') }
790
+ ? { width: 10, height: 10, background: bt ?? v('accent') }
720
791
  : {
721
- right: -20,
722
792
  width: 14,
723
793
  height: 14,
724
794
  border: `1.5px solid ${bt ?? v('accent')}`,
@@ -788,8 +858,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
788
858
  borderBottom: `1px solid ${v('border')}`,
789
859
  backgroundImage: `radial-gradient(color-mix(in srgb, ${v('fg')} 16%, transparent) 1px, transparent 1.2px)`,
790
860
  backgroundSize: '14px 14px',
791
- }, children: _jsx("div", { ref: outer, style: {
792
- overflow: fit.scale > 0.5 ? 'hidden' : 'auto',
861
+ }, children: _jsx("div", { ref: outer, role: "tabpanel", id: `${tabsId}-panel`, "aria-labelledby": `${tabsId}-tab-${active ?? 0}`, style: {
862
+ overflow: wide || fit.scroll ? 'auto' : 'hidden',
793
863
  height: fit.scale < 1 ? fit.height : undefined,
794
864
  }, children: _jsxs("div", { ref: root, style: {
795
865
  position: 'relative',
@@ -797,8 +867,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
797
867
  margin: '0 auto',
798
868
  transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
799
869
  transformOrigin: 'top left',
800
- paddingTop: arcRoom(routed, 'above') || 4,
801
- paddingBottom: arcRoom(routed, 'below') || 4,
870
+ paddingTop: arcRoom(routed, 'above') || (markSides.has('t') ? 20 : 4),
871
+ paddingBottom: arcRoom(routed, 'below') || (markSides.has('b') ? 20 : 4),
802
872
  paddingLeft: arcRoom(routed, 'left') || 4,
803
873
  paddingRight: arcRoom(routed, 'right') || 4,
804
874
  }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
@@ -879,52 +949,72 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
879
949
  fontFamily: MONO,
880
950
  transition: 'background .25s, color .25s, opacity .25s',
881
951
  }, children: p.text }, p.key)));
882
- })] }) }) }) })), rail && (_jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
883
- display: 'block',
884
- width: '100%',
885
- maxWidth: rail.width,
886
- height: 'auto',
887
- margin: `${noMap ? 0 : RAIL.gap}px auto 0`,
888
- overflow: 'visible',
889
- fontFamily: v('font'),
890
- }, children: [rail.bands.map((b) => (_jsxs("g", { children: [_jsx("rect", { x: b.rect.x, y: b.rect.y, width: b.rect.w, height: b.rect.h, rx: 10, fill: v('surface'), stroke: v('border') }), _jsx("text", { x: b.rect.x + 10, y: b.rect.y + 14, fill: v('muted'), fontSize: 11, fontWeight: 600, letterSpacing: ".04em", children: b.label.toUpperCase() })] }, b.id))), rail.columns.map((c) => (_jsx("text", { x: c.x, y: rail.head - RAIL.cols / 2 + 4, fill: v('fg'), fontSize: 12, fontWeight: 500, textAnchor: "middle", children: c.label }, c.id))), (() => {
891
- const st = railState(rail, active ?? 0);
892
- const lines = rail.rows.flatMap((row, i) => row.kind === 'message' && st[i].shown ? rail.columns.map((c) => `M ${c.x} ${st[i].y} V ${st[i].y + RAIL.row}`) : []);
893
- return _jsx("path", { d: lines.join(' '), stroke: v('border'), strokeDasharray: "2 3" });
894
- })(), (() => {
895
- const st = railState(rail, active ?? 0);
896
- return rail.rows.map((row, i) => {
897
- if (!st[i].shown)
898
- return null;
899
- const y = st[i].y;
900
- if (row.kind === 'phase') {
901
- const open = !rail.folds || row.step === active;
902
- const playing = row.step === active ? rail.rows.find((r) => r.kind === 'message' && r.step === active && r.beat === beat) : undefined;
903
- return (_jsxs("g", { style: { cursor: open ? undefined : 'pointer' }, onClick: open ? undefined : () => goTo(row.step, 0), children: [_jsx("text", { x: RAIL.pad, y: y + 19, fill: open ? v('fg') : v('muted'), fontSize: 13, fontWeight: 600, fontFamily: MONO, children: open ? row.label : foldedLabel(row) }), _jsx("path", { d: `M ${open ? row.line.open : row.line.folded} ${y + 15} H ${row.line.end}`, stroke: v('border') }), playing?.kind === 'message' && (_jsxs("text", { x: rail.width - RAIL.pad, y: y + 19, fill: v('muted'), fontSize: 11, fontWeight: 600, textAnchor: "end", children: [still ? rail.total : playing.n, " of ", rail.total] }))] }, `p${i}`));
904
- }
905
- const state = still
906
- ? 'done'
907
- : active == null || row.step > active || (row.step === active && row.beat > beat)
908
- ? 'next'
909
- : row.step === active && row.beat === beat
910
- ? 'now'
911
- : 'done';
912
- const on = state === 'now' ||
913
- hoverEdge === row.edge ||
914
- (hover != null && [rail.columns[row.from].id, rail.columns[row.to].id].includes(hover));
915
- const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
916
- const ly = y + RAIL.row / 2;
917
- const tone = state === 'now' && row.tone ? TONES[row.tone] : undefined;
918
- const g = row.group != null ? rail.groups[row.group] : null;
919
- const tip = edgeTip(row.edge, edges[ids.indexOf(row.edge)].source, allBeats);
920
- return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [tip && _jsx("title", { children: tip }), g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
921
- (() => {
922
- const tagW = ASYNC_TAG_W;
923
- const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
924
- return (_jsxs(_Fragment, { children: [_jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: v('bg') }), _jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: TONES.gray, fillOpacity: 0.15 }), _jsx("text", { x: tx + tagW / 2, y: ly + 3, fill: TONES.gray, fontSize: 9, fontWeight: 600, letterSpacing: ".03em", textAnchor: "middle", "data-fig-tag": true, children: "ASYNC" })] }));
925
- })(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'), stroke: on ? (tone ?? v('accent')) : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
926
- });
927
- })(), _jsx("defs", { children: _jsx("marker", { id: "flowfig-rail-arrow", viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: v('muted') }) }) })] })), steps.length > 0 && (_jsxs("figcaption", { style: { marginTop: 14, textAlign: 'center' }, children: [_jsxs("div", { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center' }, children: [_jsx("button", { type: "button", "aria-label": playing ? 'Pause' : 'Play', title: playing ? 'Pause' : 'Play', style: iconBtn, onClick: () => setPlaying((p) => !p), children: _jsx(Icon, { d: playing ? 'M5.5 4v8M10.5 4v8' : 'M5 3.5v9l7.5-4.5z', fill: !playing }) }), !synthetic && (_jsx("div", { role: "tablist", style: {
952
+ })] }) }) }) })), rail && (_jsx("div", { ref: railPane, onWheel: () => (user.current = true), onPointerDown: () => (user.current = true), onTouchStart: () => (user.current = true), onScroll: (e) => {
953
+ const x = e.currentTarget.scrollLeft;
954
+ if (user.current && x !== left.current && performance.now() > busy.current)
955
+ follow.current = false;
956
+ left.current = x;
957
+ }, style: { overflowX: zoom ? 'auto' : undefined }, children: _jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
958
+ display: 'block',
959
+ width: zoom ? rail.width * zoom : '100%',
960
+ maxWidth: zoom ? undefined : rail.width,
961
+ height: 'auto',
962
+ margin: `${noMap ? 0 : RAIL.gap}px auto 0`,
963
+ overflow: 'visible',
964
+ fontFamily: v('font'),
965
+ }, children: [rail.bands.map((b) => (_jsxs("g", { children: [_jsx("rect", { x: b.rect.x, y: b.rect.y, width: b.rect.w, height: b.rect.h, rx: 10, fill: v('surface'), stroke: v('border') }), _jsx("text", { x: b.rect.x + 10, y: b.rect.y + 14, fill: v('muted'), fontSize: 11, fontWeight: 600, letterSpacing: ".04em", children: b.label.toUpperCase() })] }, b.id))), rail.columns.map((c) => (_jsx("text", { x: c.x, y: rail.head - RAIL.cols / 2 + 4, fill: v('fg'), fontSize: 12, fontWeight: 500, textAnchor: "middle", children: c.label }, c.id))), (() => {
966
+ const st = railState(rail, active ?? 0);
967
+ const lines = rail.rows.flatMap((row, i) => row.kind === 'message' && st[i].shown ? rail.columns.map((c) => `M ${c.x} ${st[i].y} V ${st[i].y + RAIL.row}`) : []);
968
+ return _jsx("path", { d: lines.join(' '), stroke: v('border'), strokeDasharray: "2 3" });
969
+ })(), (() => {
970
+ const st = railState(rail, active ?? 0);
971
+ return rail.rows.map((row, i) => {
972
+ if (!st[i].shown)
973
+ return null;
974
+ const y = st[i].y;
975
+ if (row.kind === 'phase') {
976
+ const open = !rail.folds || row.step === active;
977
+ const playing = row.step === active ? rail.rows.find((r) => r.kind === 'message' && r.step === active && r.beat === beat) : undefined;
978
+ return (_jsxs("g", { style: { cursor: open ? undefined : 'pointer' }, onClick: open ? undefined : () => goTo(row.step, 0), children: [_jsx("text", { x: RAIL.pad, y: y + 19, fill: open ? v('fg') : v('muted'), fontSize: 13, fontWeight: 600, fontFamily: MONO, children: open ? row.label : foldedLabel(row) }), _jsx("path", { d: `M ${open ? row.line.open : row.line.folded} ${y + 15} H ${row.line.end}`, stroke: v('border') }), playing?.kind === 'message' && (_jsxs("text", { x: rail.width - RAIL.pad, y: y + 19, fill: v('muted'), fontSize: 11, fontWeight: 600, textAnchor: "end", children: [still ? rail.total : playing.n, " of ", rail.total] }))] }, `p${i}`));
979
+ }
980
+ const state = still
981
+ ? 'done'
982
+ : active == null || row.step > active || (row.step === active && row.beat > beat)
983
+ ? 'next'
984
+ : row.step === active && row.beat === beat
985
+ ? 'now'
986
+ : 'done';
987
+ const on = state === 'now' ||
988
+ hoverEdge === row.edge ||
989
+ (hover != null && [rail.columns[row.from].id, rail.columns[row.to].id].includes(hover));
990
+ const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
991
+ const ly = y + RAIL.row / 2;
992
+ const tone = state === 'now' && row.tone ? TONES[row.tone] : undefined;
993
+ const g = row.group != null ? rail.groups[row.group] : null;
994
+ const tip = edgeTip(row.edge, edges[ids.indexOf(row.edge)].source, allBeats);
995
+ return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [tip && _jsx("title", { children: tip }), g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
996
+ (() => {
997
+ const tagW = ASYNC_TAG_W;
998
+ const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
999
+ return (_jsxs(_Fragment, { children: [_jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: v('bg') }), _jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: TONES.gray, fillOpacity: 0.15 }), _jsx("text", { x: tx + tagW / 2, y: ly + 3, fill: TONES.gray, fontSize: 9, fontWeight: 600, letterSpacing: ".03em", textAnchor: "middle", "data-fig-tag": true, children: "ASYNC" })] }));
1000
+ })(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'), stroke: on ? (tone ?? v('accent')) : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
1001
+ });
1002
+ })(), _jsx("defs", { children: _jsx("marker", { id: "flowfig-rail-arrow", viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: v('muted') }) }) })] }) })), steps.length > 0 && (_jsxs("figcaption", { style: { marginTop: 14, textAlign: 'center' }, children: [_jsxs("div", { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center' }, children: [_jsx("button", { type: "button", "aria-label": playing ? 'Pause' : 'Play', title: playing ? 'Pause' : 'Play', style: iconBtn, onClick: () => {
1003
+ follow.current ||= !playing;
1004
+ setPlaying((p) => !p);
1005
+ }, children: _jsx(Icon, { d: playing ? 'M5.5 4v8M10.5 4v8' : 'M5 3.5v9l7.5-4.5z', fill: !playing }) }), !synthetic && (_jsx("div", { role: "tablist", onKeyDown: (e) => {
1006
+ const to = { ArrowRight: (active ?? 0) + 1, ArrowLeft: (active ?? 0) - 1, Home: 0, End: steps.length - 1 }[e.key];
1007
+ if (to === undefined)
1008
+ return;
1009
+ e.preventDefault();
1010
+ const n = (to + steps.length) % steps.length;
1011
+ clock.current.elapsed = 0;
1012
+ follow.current = true;
1013
+ setActive(n);
1014
+ setPlaying(true);
1015
+ setBeat(0);
1016
+ document.getElementById(`${tabsId}-tab-${n}`)?.focus();
1017
+ }, style: {
928
1018
  display: 'inline-flex',
929
1019
  gap: 2,
930
1020
  padding: 3,
@@ -933,8 +1023,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
933
1023
  border: `1px solid ${v('border')}`,
934
1024
  }, children: steps.map((s, i) => {
935
1025
  const on = active === i;
936
- return (_jsxs("button", { type: "button", role: "tab", "aria-selected": on, onClick: () => {
1026
+ return (_jsxs("button", { type: "button", role: "tab", "aria-selected": on, id: `${tabsId}-tab-${i}`, "aria-controls": noMap ? undefined : `${tabsId}-panel`, tabIndex: on ? 0 : -1, onClick: () => {
937
1027
  clock.current.elapsed = 0;
1028
+ follow.current = true;
938
1029
  setActive(i);
939
1030
  setPlaying(true);
940
1031
  setBeat(0);
@@ -983,6 +1074,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
983
1074
  animation: 'flowfig-in .35s ease-out',
984
1075
  }, children: said }, `${active}-${saidAt}`))] }))] }));
985
1076
  }
1077
+ const markAt = (side, off) => side === 'l' || side === 'r'
1078
+ ? { [side === 'l' ? 'left' : 'right']: off, top: '50%', transform: 'translateY(-50%)' }
1079
+ : { [side === 't' ? 'top' : 'bottom']: off, left: '50%', transform: 'translateX(-50%)' };
986
1080
  function Icon({ d, fill = false }) {
987
1081
  return (_jsx("svg", { width: 14, height: 14, viewBox: "0 0 16 16", fill: fill ? 'currentColor' : 'none', stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: _jsx("path", { d: d }) }));
988
1082
  }
package/dist/load.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { altText, counts, toBeat } from './model.js';
3
- import { coverageLine, owners, unsureLines, verifyReport } from './verify.js';
3
+ import { coverageLine, detailFindings, owners, unsureLines, verifyReport } from './verify.js';
4
4
  import { toSvg } from './svg.js';
5
5
  const SPEC_OPEN = '<metadata id="figure-spec"><![CDATA[';
6
6
  const SPEC_CLOSE = ']]></metadata>';
@@ -68,8 +68,9 @@ export function summaryLines(props) {
68
68
  return [...edges, ...steps, `alt: ${altText(props).title}`];
69
69
  }
70
70
  export function verifyLines(props, figure, root = process.cwd()) {
71
- if (!owners(props).some(([, source, via]) => source != null || via != null))
72
- return [];
71
+ if (!owners(props).some(([, source, via]) => source != null || via != null)) {
72
+ return detailFindings(props, root).map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${figure}: ${f.message}`);
73
+ }
73
74
  const { findings, coverage } = verifyReport(props, { root });
74
75
  return [
75
76
  ...findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${figure}: ${f.message}`),
@@ -0,0 +1,19 @@
1
+ import { type FlowProps } from './model.ts';
2
+ export type MermaidFault = {
3
+ line: number;
4
+ reason: string;
5
+ };
6
+ type Result = {
7
+ spec: FlowProps;
8
+ faults: [];
9
+ } | {
10
+ spec?: undefined;
11
+ faults: MermaidFault[];
12
+ };
13
+ export declare function fromMermaid(text: string, first?: number): Result;
14
+ export declare function markdownBlocks(text: string): {
15
+ text: string;
16
+ line: number;
17
+ }[];
18
+ export declare function runFromMermaid(argv: string[]): number;
19
+ export {};