flowfig 0.10.0 → 0.12.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,12 +1,13 @@
1
1
  'use client';
2
+ import { createElement as _createElement } from "react";
2
3
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import { Fragment, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from 'react';
4
5
  import { arcRoom, avoidOf, markSide, route } from './geometry.js';
5
6
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
6
7
  import { textWidth } from './text.js';
7
8
  import { checkScene, checkSpec, checkTheme } from './check.js';
8
9
  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, specKey, nextWide, } from './model.js';
10
+ 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, scenes, insideStep, flowOrder, readMs, portLabel, OPEN_MS, } from './model.js';
10
11
  export { LIGHT, DARK } from './model.js';
11
12
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
12
13
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
@@ -56,21 +57,75 @@ const HIDDEN = {
56
57
  * Use `toSvg` from `flowfig/svg` for a static animated SVG.
57
58
  */
58
59
  export function Flow(props) {
59
- const { layout, edges, steps, lanes, timeline } = props;
60
+ const all = useMemo(() => scenes(props), [props.layout, props.edges, props.steps, props.lanes, props.timeline, props.rail, props.theme, props.speed]);
61
+ const [path, setPath] = useState([]);
62
+ const [full, setFull] = useState(false);
63
+ const [start, setStart] = useState();
64
+ const memo = useRef({});
65
+ const at = path.join('>');
66
+ const s = all.find((x) => x.path.join('>') === at) ?? all[0];
67
+ const { layout, edges, steps, lanes, timeline } = s.props;
60
68
  const key = useMemo(() => specKey(layout, edges, steps), [layout, edges, steps]);
61
69
  const [narrow, setNarrow] = useState({ wide: 0, key: '' });
62
70
  const fold = !lanes && !timeline && props.rail !== 'only';
63
71
  const wide = fold && narrow.key === key ? narrow.wide : 0;
64
72
  const setWide = useCallback((w) => setNarrow({ wide: w, key }), [key]);
65
- const placed = useMemo(() => autoLayout({ layout: wide ? { ...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 }));
73
+ const placed = useMemo(() => autoLayout({ layout: wide > 0 ? { ...layout, auto: true, direction: 'column' } : layout, edges, steps, lanes, timeline }), [wide, layout, edges, steps, lanes, timeline]);
74
+ const level = all.length > 1
75
+ ? {
76
+ scene: s,
77
+ owners: new Map(all.filter((x) => x.path.slice(0, -1).join('>') === at && x.path.length > s.path.length).map((x) => [x.path.at(-1), x.parts])),
78
+ path: s.path.map((id, i) => ({ id, name: all.find((x) => x.path.join('>') === s.path.slice(0, i + 1).join('>')).name })),
79
+ onOpen: (id, from) => {
80
+ memo.current[at] = from;
81
+ const child = all.find((x) => x.path.join('>') === [...s.path, id].join('>'));
82
+ setStart({
83
+ step: child.props.steps?.length ? insideStep(child, from.port) : null,
84
+ beat: 0,
85
+ playing: from.playing,
86
+ rect: from.rect,
87
+ });
88
+ setPath([...s.path, id]);
89
+ },
90
+ onUp: (depth) => {
91
+ const to = s.path.slice(0, depth);
92
+ const { step, beat, playing } = memo.current[to.join('>')];
93
+ const beats = step == null ? 1 : (all.find((x) => x.path.join('>') === to.join('>')).props.steps?.[step].flow.length ?? 1);
94
+ setStart({ step, beat: Math.min(beat + 1, beats - 1), playing, box: s.path[depth] });
95
+ setPath(to);
96
+ },
97
+ }
98
+ : undefined;
99
+ const figure = useRef(null);
100
+ const descId = useId();
101
+ const alt = useMemo(() => altText({ layout: placed.layout, edges: placed.edges, steps }), [placed, steps]);
102
+ return (_jsxs("figure", { ref: figure, className: "flowfig", "data-inside": level ? '' : undefined, "aria-label": alt.title, "aria-describedby": descId, style: {
103
+ ...Object.fromEntries(Object.entries(props.theme ?? {}).map(([k, val]) => [`--fig-${k}`, val])),
104
+ position: full ? 'fixed' : 'relative',
105
+ inset: full ? 0 : undefined,
106
+ zIndex: full ? 1000 : undefined,
107
+ overflow: full ? 'auto' : undefined,
108
+ margin: full ? 0 : '24px 0',
109
+ padding: full ? '48px 24px 24px' : '20px 16px 16px',
110
+ border: full ? 'none' : `1px solid ${v('border')}`,
111
+ borderRadius: full ? 0 : 16,
112
+ background: v('bg'),
113
+ fontFamily: v('font'),
114
+ color: v('fg'),
115
+ }, children: [_jsx("div", { id: descId, style: HIDDEN, children: alt.desc }), _createElement(FlowBody, { ...props, ...s.props, key: at, layout: placed.layout, edges: placed.edges, written: s.props, wide: wide, setWide: fold ? setWide : undefined, full: full, setFull: setFull, level: level, start: start, figure: figure })] }));
67
116
  }
68
- // The ASYNC tag is 9 px; phone text needs 11 px.
69
- const RAIL_ZOOM = 11 / 9;
70
- function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, written, wide, setWide, }) {
117
+ const reduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
118
+ // The ASYNC tag is 9 px.
119
+ const RAIL_TEXT = 9;
120
+ // offsetWidth is rounded, so a rect ratio reads scale 1 as 0.999.
121
+ const shownScale = (el) => {
122
+ let k = 1;
123
+ for (let n = el; n; n = n.parentElement)
124
+ k *= new DOMMatrix(getComputedStyle(n).transform).a;
125
+ return k;
126
+ };
127
+ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, written, wide, setWide, full, setFull, level, start, figure, }) {
71
128
  const tl = timeline && isLanesLayout(layout);
72
- const alt = useMemo(() => altText({ layout, edges, steps: stepsIn }), [layout, edges, stepsIn]);
73
- const descId = useId();
74
129
  const tabsId = useId();
75
130
  const synthetic = tl && !stepsIn.length;
76
131
  const steps = useMemo(() => (synthetic ? timelineBeats({ layout, edges, timeline, today }) : stepsIn), [synthetic, layout, edges, timeline, today, stepsIn]);
@@ -78,31 +133,77 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
78
133
  const area = useRef(null);
79
134
  const root = useRef(null);
80
135
  const outer = useRef(null);
81
- const [fit, setFit] = useState({ scale: 1, height: 0 });
82
- const [full, setFull] = useState(false);
136
+ const [fit, setFit] = useState({ scale: 1, height: 0, scroll: false });
137
+ const backBtn = useRef(null);
138
+ const ghost = useRef(null);
139
+ const place = (n) => {
140
+ const f = figure.current, a = f.getBoundingClientRect(), r = n.getBoundingClientRect();
141
+ return { x: r.left - a.left - f.clientLeft + f.scrollLeft, y: r.top - a.top - f.clientTop + f.scrollTop, w: r.width, h: r.height };
142
+ };
143
+ const showGhost = (r) => {
144
+ Object.assign(ghost.current.style, { display: 'block', left: `${r.x}px`, top: `${r.y}px`, width: `${r.w}px`, height: `${r.h}px` });
145
+ return ghost.current;
146
+ };
147
+ const toward = (from, to) => `translate(${from.x - to.x}px, ${from.y - to.y}px) scale(${from.w / to.w}, ${from.h / to.h})`;
148
+ const moving = useRef(false);
149
+ const goUp = (depth) => {
150
+ const map = root.current, r = start?.rect;
151
+ if (moving.current)
152
+ return;
153
+ if (reduced() || !map || !r)
154
+ return level.onUp(depth);
155
+ moving.current = true;
156
+ map.animate([{ opacity: 1 }, { opacity: 0 }], { duration: OPEN_MS.hide, fill: 'forwards' });
157
+ const to = place(map);
158
+ showGhost(to).animate([{ transform: 'none' }, { transform: toward(r, to) }], {
159
+ duration: OPEN_MS.shrink,
160
+ delay: OPEN_MS.hide,
161
+ easing: 'cubic-bezier(0.4, 0, 1, 1)',
162
+ fill: 'forwards',
163
+ }).onfinish = () => level.onUp(depth);
164
+ };
165
+ const up = level?.path.length ? () => goUp(level.path.length - 1) : undefined;
83
166
  useEffect(() => {
84
- if (!full)
167
+ if (!full && !up)
85
168
  return;
86
- const onKey = (e) => e.key === 'Escape' && setFull(false);
169
+ const onKey = (e) => {
170
+ const at = document.activeElement;
171
+ const inside = !!at && !!figure.current?.contains(at);
172
+ if (e.key === 'Escape' && up && (inside || full))
173
+ up();
174
+ else if (e.key === 'Escape')
175
+ setFull(false);
176
+ else if (e.key === 'Backspace' && up && inside && !at.closest('input,textarea,[contenteditable]')) {
177
+ e.preventDefault();
178
+ up();
179
+ }
180
+ };
87
181
  addEventListener('keydown', onKey);
88
182
  return () => removeEventListener('keydown', onKey);
89
- }, [full]);
183
+ }, [full, up]);
184
+ useEffect(() => {
185
+ if (start?.box)
186
+ figure.current?.querySelector(`[data-fig="${CSS.escape(start.box)}"]`)?.focus();
187
+ else if (level?.path.length)
188
+ backBtn.current?.focus();
189
+ }, []);
90
190
  const dots = useRef([]);
91
191
  const chips = useRef([]);
92
192
  const bar = useRef(null);
93
- const [beat, setBeat] = useState(0);
193
+ const [beat, setBeat] = useState(start?.beat ?? 0);
94
194
  const [holding, setHolding] = useState(false);
95
195
  const [returning, setReturning] = useState(false);
96
196
  const firstLabel = useRef('');
97
197
  const paths = useRef({});
98
198
  const [routed, setRouted] = useState([]);
199
+ const routedOn = useRef(new WeakMap());
99
200
  const markSides = new Set(nodes(layout).flatMap((n) => (n.mark ? [markSide(n.id, n.mark, routed)] : [])));
100
201
  const [extraTall, setExtraTall] = useState(new Set());
101
202
  const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
102
203
  ? planLanes({ layout, edges, steps, lanes }, { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
103
204
  : null, [lanes, tl, layout, edges, steps, withRail]);
104
- const [active, setActive] = useState(steps.length ? 0 : null);
105
- const [playing, setPlaying] = useState(autoplay);
205
+ const [active, setActive] = useState(start ? start.step : steps.length ? 0 : null);
206
+ const [playing, setPlaying] = useState(start ? start.playing : autoplay);
106
207
  const playingRef = useRef(playing);
107
208
  playingRef.current = playing;
108
209
  const [rate, setRate] = useState(1);
@@ -139,17 +240,45 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
139
240
  beats.slice(0, beat + 1).forEach((b, i) => Object.keys(b.show ?? {}).forEach((k) => (at[k] = i)));
140
241
  return at;
141
242
  }, [beats, beat]);
142
- const figure = useRef(null);
143
243
  const [mapW, setMapW] = useState(0);
144
244
  const railOnly = withRail === 'only';
145
245
  const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
146
246
  const noMap = railOnly && rail != null;
147
247
  const railPane = useRef(null);
248
+ const [railBox, setRailBox] = useState(0);
249
+ const zoom = wide ? 11 / RAIL_TEXT : noMap && railBox ? textFloor(railBox) / RAIL_TEXT : 0;
148
250
  const follow = useRef(true);
149
251
  const user = useRef(false);
150
252
  const left = useRef(0);
151
253
  const busy = useRef(0);
152
- const jump = useRef(null);
254
+ const jump = useRef(start?.beat ?? null);
255
+ const wait = useRef(start?.rect && start.playing
256
+ ? OPEN_MS.grow +
257
+ OPEN_MS.stagger * (nodes(layout).length - 1) +
258
+ readMs([level.scene.line, ...level.scene.ports.map(portLabel)].join(' '))
259
+ : 0);
260
+ useLayoutEffect(() => {
261
+ const map = root.current;
262
+ if (!map || reduced())
263
+ return;
264
+ if (start?.box)
265
+ return void map.animate([{ opacity: 0 }, { opacity: 1 }], { duration: OPEN_MS.fade });
266
+ if (!start?.rect)
267
+ return;
268
+ const to = place(map);
269
+ showGhost(to).animate([{ transform: toward(start.rect, to) }, { transform: 'none' }], {
270
+ duration: OPEN_MS.grow,
271
+ easing: 'cubic-bezier(0.2, 0, 0, 1)',
272
+ });
273
+ for (const e of map.querySelectorAll(':scope > svg'))
274
+ e.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 1, delay: OPEN_MS.grow, fill: 'backwards' });
275
+ const shows = flowOrder(level.scene).map((id, k) => map.querySelector(`[data-fig="${CSS.escape(id)}"]`)?.animate([{ opacity: 0 }, { opacity: 1 }], {
276
+ duration: OPEN_MS.fade,
277
+ delay: OPEN_MS.grow + OPEN_MS.stagger * k,
278
+ fill: 'backwards',
279
+ }));
280
+ shows.at(-1)?.finished.then(() => ghost.current && (ghost.current.style.display = 'none'));
281
+ }, []);
153
282
  const [hoverEdge, setHoverEdge] = useState(null);
154
283
  const [still, setStill] = useState(false);
155
284
  useEffect(() => setStill(window.matchMedia('(prefers-reduced-motion: reduce)').matches), []);
@@ -176,13 +305,12 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
176
305
  if (setWide && goal !== wide)
177
306
  return setWide(goal);
178
307
  const scale = fitScale(wide, box.clientWidth, el.offsetWidth);
179
- setFit({ scale, height: el.offsetHeight * scale });
308
+ setFit({ scale, height: el.offsetHeight * scale, scroll: scale > box.clientWidth / el.offsetWidth });
180
309
  setMapW(el.offsetWidth);
181
310
  if (area.current)
182
311
  setAxisW(area.current.offsetWidth);
183
312
  const base = el.getBoundingClientRect();
184
- // Rects are measured unscaled: k is the scale on screen.
185
- const k = base.width / el.offsetWidth;
313
+ const k = shownScale(el);
186
314
  const rects = {};
187
315
  el.querySelectorAll('[data-fig]').forEach((n) => {
188
316
  const r = n.getBoundingClientRect();
@@ -246,6 +374,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
246
374
  }),
247
375
  ...(!tl && e.label != null && { labelW: labelPillW(str(e.label)) }),
248
376
  })), rects, tips, avoid, pillArea, lanePlan ? { bands: Object.values(laneBands), boxes: nodes(layout).flatMap((n) => (rects[n.id] ? [rects[n.id]] : [])) } : undefined);
377
+ routedOn.current.set(next, rects);
249
378
  setRouted(next);
250
379
  if (lanePlan) {
251
380
  const more = [...tightCopies(layout.children, lanePlan.ends, edges, next)].filter((k) => !lanePlan.tall.has(k) && !extraTall.has(k));
@@ -263,7 +392,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
263
392
  useEffect(() => {
264
393
  const pane = railPane.current;
265
394
  const n = rail?.rows.flatMap((r) => (r.kind === 'message' && r.step === active && r.beat === beat ? [r.n] : []))[0];
266
- const now = wide && follow.current && n != null ? pane?.querySelector(`[data-rail-row="${n}"] path`) : null;
395
+ const now = zoom && follow.current && n != null ? pane?.querySelector(`[data-rail-row="${n}"] path`) : null;
267
396
  if (!pane || !now)
268
397
  return;
269
398
  const [p, r] = [pane.getBoundingClientRect(), now.getBoundingClientRect()];
@@ -271,25 +400,28 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
271
400
  // Some browsers fire no scrollend event.
272
401
  busy.current = performance.now() + 1000;
273
402
  pane.scrollTo({ left: pane.scrollLeft + (r.left + r.right) / 2 - p.left - pane.clientWidth / 2, behavior: still ? 'auto' : 'smooth' });
274
- }, [wide, active, beat, still, rail]);
403
+ }, [zoom, active, beat, still, rail]);
275
404
  useEffect(() => {
276
405
  const pane = railPane.current;
277
406
  if (!pane)
278
407
  return;
279
408
  const end = () => (busy.current = 0);
280
409
  pane.addEventListener('scrollend', end);
281
- return () => pane.removeEventListener('scrollend', end);
282
- }, [rail]);
410
+ const ro = new ResizeObserver(() => setRailBox(pane.clientWidth));
411
+ if (noMap)
412
+ ro.observe(pane);
413
+ return () => (pane.removeEventListener('scrollend', end), ro.disconnect());
414
+ }, [rail, noMap]);
283
415
  const reported = useRef(new Set());
284
416
  useEffect(() => {
285
417
  const el = root.current, box = outer.current;
286
418
  const fig = figure.current;
287
- if (!check || !fig || (!noMap && (!el || !box || !el.offsetWidth)))
419
+ if (!check || !fig || (noMap ? !railBox : !el || !box || !el.offsetWidth))
288
420
  return;
289
421
  if (setWide && el && box && nextWide(wide, box.clientWidth, el.offsetWidth) !== wide)
290
422
  return;
291
423
  const base = (el ?? fig).getBoundingClientRect();
292
- const k = el ? base.width / el.offsetWidth : 1;
424
+ const k = el ? shownScale(el) : 1;
293
425
  const rel = (n) => {
294
426
  const r = n.getBoundingClientRect();
295
427
  return { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
@@ -306,7 +438,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
306
438
  .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
307
439
  .map((t) => {
308
440
  const fontSize = parseFloat(getComputedStyle(t).fontSize);
309
- if (!n.dataset.diamond)
441
+ if (!n.dataset.diamond || t.closest('[data-fig-outside]'))
310
442
  return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
311
443
  const [r, range] = [rel(t), document.createRange()];
312
444
  range.selectNodeContents(t);
@@ -320,6 +452,12 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
320
452
  }),
321
453
  };
322
454
  });
455
+ const moved = (b) => {
456
+ const r = routedOn.current.get(routed)?.[b.id];
457
+ return !r || Math.abs(r.x - b.rect.x) > 1 || Math.abs(r.y - b.rect.y) > 1;
458
+ };
459
+ if (el && boxes.some(moved))
460
+ return;
323
461
  const fonts = [...fig.querySelectorAll('*')]
324
462
  .filter((t) => !t.children.length &&
325
463
  t.textContent?.trim() &&
@@ -328,8 +466,9 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
328
466
  !t.closest('[data-fig-tag],[aria-hidden]'))
329
467
  .map((t) => {
330
468
  const svg = railPane.current?.firstElementChild;
331
- const at = el?.contains(t) ? k : svg?.contains(t) && rail ? svg.getBoundingClientRect().width / rail.width : 1;
332
- return parseFloat(getComputedStyle(t).fontSize) * at;
469
+ const place = el?.contains(t) ? 'map' : svg?.contains(t) && rail ? 'rail' : 'plain';
470
+ const railScale = svg && rail ? svg.getBoundingClientRect().width / rail.width : 1;
471
+ return shownFont(parseFloat(getComputedStyle(t).fontSize), place, { map: k, rail: railScale });
333
472
  });
334
473
  const labels = Object.fromEntries([...fig.querySelectorAll('[data-fig-label]')].map((n) => [n.dataset.figLabel, rel(n)]));
335
474
  const lanesIn = el ? [...el.querySelectorAll('[data-fig-band]')] : [];
@@ -378,7 +517,11 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
378
517
  ],
379
518
  minFont: Math.min(...fonts),
380
519
  };
381
- for (const f of [...checkSpec(written), ...checkScene(scene, { width: (box ?? fig).clientWidth }), ...checkTheme(theme)]) {
520
+ for (const f of [
521
+ ...checkSpec(written),
522
+ ...checkScene(scene, { width: (box ?? fig).clientWidth, minText: textFloor((box ?? fig).clientWidth), auto: written.layout.auto }),
523
+ ...checkTheme(theme),
524
+ ]) {
382
525
  const key = f.rule + ':' + f.ids.join() + ':' + f.message;
383
526
  if (reported.current.has(key))
384
527
  continue;
@@ -386,13 +529,14 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
386
529
  // oxlint-disable-next-line no-console
387
530
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
388
531
  }
389
- }, [check, routed, written, layout, edges, steps, ids, theme, rail, noMap, lanePlan, wide, setWide]);
532
+ }, [check, routed, written, layout, edges, steps, ids, theme, rail, noMap, railBox, lanePlan, wide, setWide]);
390
533
  useEffect(() => {
391
534
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
392
535
  if (clock.current.beats !== beats) {
393
536
  const j = jump.current ?? 0;
394
537
  jump.current = null;
395
- clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) };
538
+ clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) - wait.current * BASE_RATE };
539
+ wait.current = 0;
396
540
  setBeat(j);
397
541
  }
398
542
  if (!beats.length || (!noMap && !routed.length && !(tl && !edges.length)))
@@ -414,7 +558,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
414
558
  return setActive((a) => ((a ?? 0) + 1) % steps.length);
415
559
  clock.current.elapsed = 0;
416
560
  }
417
- const t = clock.current.elapsed;
561
+ const t = Math.max(0, clock.current.elapsed);
418
562
  if (bar.current)
419
563
  bar.current.style.transform = `scaleX(${Math.min(1, t / total)})`;
420
564
  const next = ends.findIndex((e) => t < e);
@@ -518,7 +662,6 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
518
662
  }
519
663
  };
520
664
  const focus = hover != null || hoverEdge != null || step != null;
521
- const vars = Object.fromEntries(Object.entries(theme ?? {}).map(([k, val]) => [`--fig-${k}`, val]));
522
665
  const timelineFig = useMemo(() => (tl ? timelineLayout({ layout, edges, timeline, today }, axisW) : null), [tl, layout, edges, timeline, today, axisW]);
523
666
  const renderBar = (n, it) => {
524
667
  const lit = litNodes.has(n.id);
@@ -712,9 +855,36 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
712
855
  const card = carded.has(item.id);
713
856
  const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
714
857
  const w = diamond || card || item.width != null || str(item.label) ? nodeWidth(item, card, cap) + (diamond ? 70 : 0) : undefined;
715
- const lines = w != null && !diamond && str(item.label) ? labelLines(item, w) : null;
716
858
  const bt = item.tone && TONES[item.tone];
717
- return (_jsxs("div", { "data-fig": item.id, title: item.source, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
859
+ const parts = level?.owners.get(item.id);
860
+ const port = level?.scene.ports.some((p) => p.id === item.id);
861
+ const lines = w != null && str(item.label) ? labelLines(item, w) : null;
862
+ const open = () => {
863
+ const hop = cur?.hops.find((h) => (h.back ? edges[ids.indexOf(h.edge)]?.from : edges[ids.indexOf(h.edge)]?.to) === item.id);
864
+ const e = hop && edges[ids.indexOf(hop.edge)];
865
+ const at = { step: active, beat, playing, port: e ? (hop.back ? e.to : e.from) : undefined };
866
+ const map = root.current, n = map?.querySelector(`[data-fig="${CSS.escape(item.id)}"]`);
867
+ if (moving.current)
868
+ return;
869
+ if (reduced() || !map || !n)
870
+ return level.onOpen(item.id, at);
871
+ moving.current = true;
872
+ const rect = place(n);
873
+ showGhost(rect);
874
+ map.animate([{ opacity: 1 }, { opacity: 0 }], { duration: OPEN_MS.fade, fill: 'forwards' }).onfinish = () => level.onOpen(item.id, { ...at, rect });
875
+ };
876
+ return (_jsxs("div", { "data-fig": item.id, title: item.source, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), ...(parts != null && {
877
+ role: 'button',
878
+ tabIndex: 0,
879
+ 'aria-label': `Open ${str(item.label) || item.id}, ${parts} parts`,
880
+ onClick: open,
881
+ onKeyDown: (e) => {
882
+ if (e.key !== 'Enter' && e.key !== ' ')
883
+ return;
884
+ e.preventDefault();
885
+ open();
886
+ },
887
+ }), style: {
718
888
  position: 'relative',
719
889
  isolation: 'isolate',
720
890
  minWidth: 100,
@@ -727,19 +897,23 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
727
897
  justifyContent: 'center',
728
898
  padding: diamond
729
899
  ? '22px 34px'
730
- : `calc(${store ? 24 : 10}px - var(--ff-b, 0px)) calc(${card ? 10 : 16}px - var(--ff-b, 0px)) calc(10px - var(--ff-b, 0px))`,
900
+ : `calc(${store ? 23 : 9}px - var(--ff-b, 0px)) calc(${card ? 10 : 16}px - var(--ff-b, 0px)) calc(9px - var(--ff-b, 0px))`,
731
901
  textAlign: 'center',
732
902
  background: diamond ? undefined : bt ? toneTint(bt, v('bg')) : v('bg'),
733
- color: v('fg'),
734
- border: diamond ? undefined : `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
903
+ color: port ? v('muted') : v('fg'),
904
+ border: diamond ? undefined : `1px ${port ? 'dashed' : 'solid'} ${bt ?? (lit ? v('accent') : v('border'))}`,
735
905
  ...(bt && { '--ff-box': bt }),
736
- boxShadow: diamond ? undefined : '0 1px 2px rgba(0,0,0,.06)',
906
+ boxShadow: diamond
907
+ ? undefined
908
+ : parts != null
909
+ ? `3px 3px 0 -1px ${v('bg')}, 3px 3px 0 0 ${v('border')}`
910
+ : '0 1px 2px rgba(0,0,0,.06)',
737
911
  opacity: focus && !lit ? 0.7 : 1,
738
912
  borderRadius: store ? '50% / 12px' : 10,
739
913
  fontSize: 14,
740
914
  fontWeight: 500,
741
915
  transition: 'border-color .4s, background .4s, box-shadow .4s, border-width .4s, padding .4s, opacity .25s',
742
- cursor: 'default',
916
+ cursor: parts != null ? 'pointer' : 'default',
743
917
  }, children: [diamond && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", style: {
744
918
  position: 'absolute',
745
919
  inset: 0,
@@ -770,7 +944,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
770
944
  border: `1.5px solid ${bt ?? v('accent')}`,
771
945
  background: `radial-gradient(circle, ${bt ?? v('accent')} 0 4px, transparent 4.5px)`,
772
946
  }),
773
- } })), lines ? (lines.map((l, i) => (_jsx("div", { style: { whiteSpace: 'nowrap', lineHeight: `${LABEL_LINE}px` }, children: l }, i)))) : (_jsx("div", { style: { lineHeight: `${LABEL_LINE}px` }, children: item.label })), dl
947
+ } })), lines && port ? (_jsx("div", { style: { lineHeight: `${LABEL_LINE}px`, width: 'min-content', margin: '0 auto' }, children: lines.map((l, i) => (_jsxs(Fragment, { children: [i > 0 && ' ', _jsx("span", { style: { whiteSpace: 'nowrap' }, children: l })] }, i))) })) : lines ? (lines.map((l, i) => (_jsx("div", { style: { whiteSpace: 'nowrap', lineHeight: `${LABEL_LINE}px` }, children: l }, i)))) : (_jsx("div", { style: { lineHeight: `${LABEL_LINE}px` }, children: item.label })), dl
774
948
  ? dl.subs.map((l, i) => (_jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), lineHeight: '15px', whiteSpace: 'nowrap' }, children: l.text }, i)))
775
949
  : item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
776
950
  marginTop: 8,
@@ -803,20 +977,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
803
977
  background: v('bg'),
804
978
  color: v('muted'),
805
979
  };
806
- return (_jsxs("figure", { ref: figure, className: "flowfig", "aria-label": alt.title, "aria-describedby": descId, style: {
807
- ...vars,
808
- position: full ? 'fixed' : 'relative',
809
- inset: full ? 0 : undefined,
810
- zIndex: full ? 1000 : undefined,
811
- overflow: full ? 'auto' : undefined,
812
- margin: full ? 0 : '24px 0',
813
- padding: full ? '48px 24px 24px' : '20px 16px 16px',
814
- border: full ? 'none' : `1px solid ${v('border')}`,
815
- borderRadius: full ? 0 : 16,
816
- background: v('bg'),
817
- fontFamily: v('font'),
818
- color: v('fg'),
819
- }, children: [_jsx("div", { id: descId, style: HIDDEN, children: alt.desc }), _jsx("button", { type: "button", "aria-label": full ? 'Close full screen' : 'Full screen', title: full ? 'Close (Esc)' : 'Full screen', onClick: () => setFull((f) => !f), style: {
980
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": full ? 'Close full screen' : 'Full screen', title: full ? 'Close (Esc)' : 'Full screen', onClick: () => setFull(!full), style: {
820
981
  ...iconBtn,
821
982
  position: 'absolute',
822
983
  top: 10,
@@ -827,113 +988,128 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
827
988
  }, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-label{from{opacity:0}to{opacity:0}}@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}' +
828
989
  // The 2 px active border takes 1 px of padding, so the box size stays.
829
990
  `.${ACTIVE}:not([data-diamond]){--ff-b:1px;border-width:2px!important;background:color-mix(in srgb, ${hue} 10%, ${v('surface')})!important;box-shadow:${glow}!important;border-color:${hue}!important}` +
830
- `.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
991
+ `.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` +
992
+ (level
993
+ ? '@media (prefers-reduced-motion: reduce){.flowfig[data-inside] *{transition:none!important;animation:none!important}}'
994
+ : '') }), level && (_jsx("div", { ref: ghost, style: {
995
+ display: 'none',
996
+ position: 'absolute',
997
+ zIndex: 4,
998
+ boxSizing: 'border-box',
999
+ border: `1.5px solid ${v('accent')}`,
1000
+ borderRadius: 10,
1001
+ transformOrigin: '0 0',
1002
+ pointerEvents: 'none',
1003
+ } })), !noMap && (_jsx(_Fragment, { children: _jsxs("div", { style: {
831
1004
  margin: full ? '-48px -24px 0' : '-20px -16px 0',
832
1005
  padding: full ? '48px 24px 24px' : '24px 16px',
833
1006
  borderRadius: full ? 0 : '16px 16px 0 0',
834
1007
  borderBottom: `1px solid ${v('border')}`,
835
1008
  backgroundImage: `radial-gradient(color-mix(in srgb, ${v('fg')} 16%, transparent) 1px, transparent 1.2px)`,
836
1009
  backgroundSize: '14px 14px',
837
- }, children: _jsx("div", { ref: outer, role: "tabpanel", id: `${tabsId}-panel`, "aria-labelledby": `${tabsId}-tab-${active ?? 0}`, style: {
838
- overflow: wide || fit.scale <= 0.5 ? 'auto' : 'hidden',
839
- height: fit.scale < 1 ? fit.height : undefined,
840
- }, children: _jsxs("div", { ref: root, style: {
841
- position: 'relative',
842
- width: 'max-content',
843
- margin: '0 auto',
844
- transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
845
- transformOrigin: 'top left',
846
- paddingTop: arcRoom(routed, 'above') || (markSides.has('t') ? 20 : 4),
847
- paddingBottom: arcRoom(routed, 'below') || (markSides.has('b') ? 20 : 4),
848
- paddingLeft: arcRoom(routed, 'left') || 4,
849
- paddingRight: arcRoom(routed, 'right') || 4,
850
- }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
851
- position: 'absolute',
852
- inset: 0,
853
- width: '100%',
854
- height: '100%',
855
- overflow: 'visible',
856
- pointerEvents: 'none',
857
- zIndex: tl ? 0 : undefined,
858
- }, children: [_jsx("defs", { children: ['off', 'on'].map((k) => (_jsx("marker", { id: `fig-arrow-${k}`, 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: "context-stroke" }) }, k))) }), routed.map((r) => {
859
- const on = litEdges.has(r.id);
860
- const tone = hopTone(r.id);
861
- const hidden = !on && edges[ids.indexOf(r.id)].quiet;
862
- const tip = edgeTip(r.id, edges[ids.indexOf(r.id)].source, allBeats);
863
- const look = (d, key, guides = false) => (_jsx("path", { ref: guides
864
- ? (p) => {
1010
+ }, children: [up && (_jsxs("div", { style: { display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: 13, color: v('muted') }, children: [_jsx("button", { ref: backBtn, type: "button", onClick: up, style: { ...iconBtn, width: 'auto', padding: '0 12px', font: 'inherit' }, children: "Back" }), level.path.map((p, i) => i < level.path.length - 1 ? (_jsxs(Fragment, { children: [_jsx("button", { type: "button", onClick: () => goUp(i + 1), style: { border: 0, padding: 0, background: 'none', font: 'inherit', color: v('accent'), cursor: 'pointer' }, children: p.name }), ' > '] }, p.id)) : (_jsx("span", { style: { color: v('fg') }, children: p.name }, p.id))), _jsx("div", { "aria-live": "polite", style: HIDDEN, children: `${level.scene.line}: ${level.scene.parts} parts.` +
1011
+ level.scene.ports
1012
+ .map((p) => p.way === 'in' ? ` In from ${p.name}.` : p.way === 'out' ? ` Out to ${p.name}.` : ` In and out: ${p.name}.`)
1013
+ .join('') })] })), _jsx("div", { ref: outer, role: "tabpanel", id: `${tabsId}-panel`, "aria-labelledby": `${tabsId}-tab-${active ?? 0}`, style: {
1014
+ overflow: wide || fit.scroll ? 'auto' : 'hidden',
1015
+ height: fit.scale < 1 ? fit.height : undefined,
1016
+ }, children: _jsxs("div", { ref: root, style: {
1017
+ position: 'relative',
1018
+ width: 'max-content',
1019
+ margin: '0 auto',
1020
+ transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
1021
+ transformOrigin: 'top left',
1022
+ paddingTop: arcRoom(routed, 'above') || (markSides.has('t') ? 20 : 4),
1023
+ paddingBottom: arcRoom(routed, 'below') || (markSides.has('b') ? 20 : 4),
1024
+ paddingLeft: arcRoom(routed, 'left') || 4,
1025
+ paddingRight: arcRoom(routed, 'right') || 4,
1026
+ }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
1027
+ position: 'absolute',
1028
+ inset: 0,
1029
+ width: '100%',
1030
+ height: '100%',
1031
+ overflow: 'visible',
1032
+ pointerEvents: 'none',
1033
+ zIndex: tl ? 0 : undefined,
1034
+ }, children: [_jsx("defs", { children: ['off', 'on'].map((k) => (_jsx("marker", { id: `fig-arrow-${k}`, 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: "context-stroke" }) }, k))) }), routed.map((r) => {
1035
+ const on = litEdges.has(r.id);
1036
+ const tone = hopTone(r.id);
1037
+ const hidden = !on && edges[ids.indexOf(r.id)].quiet;
1038
+ const tip = edgeTip(r.id, edges[ids.indexOf(r.id)].source, allBeats);
1039
+ const look = (d, key, guides = false) => (_jsx("path", { ref: guides
1040
+ ? (p) => {
1041
+ paths.current[r.id] = p;
1042
+ }
1043
+ : 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', pointerEvents: tip ? 'stroke' : undefined }, children: tip && _jsx("title", { children: tip }) }, key));
1044
+ const guide = (_jsx("path", { ref: (p) => {
865
1045
  paths.current[r.id] = p;
866
- }
867
- : 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', pointerEvents: tip ? 'stroke' : undefined }, children: tip && _jsx("title", { children: tip }) }, key));
868
- const guide = (_jsx("path", { ref: (p) => {
869
- paths.current[r.id] = p;
870
- }, d: r.d, fill: "none", stroke: "none" }, r.id));
871
- if (r.stub)
872
- return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
873
- return look(r.d, r.id, true);
874
- }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
875
- dots.current[j] = g;
876
- }, 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) => {
877
- chips.current[j] = c;
878
- }, style: {
879
- position: 'absolute',
880
- left: 0,
881
- top: 0,
882
- zIndex: 2,
883
- opacity: 0,
884
- maxWidth: 220,
885
- width: 'max-content',
886
- padding: '4px 9px',
887
- borderRadius: 8,
888
- fontSize: 11.5,
889
- lineHeight: '15px',
890
- pointerEvents: 'none',
891
- background: v('accent'),
892
- color: ON_ACCENT,
893
- boxShadow: '0 4px 14px rgba(0,0,0,.18)',
894
- transition: 'opacity .2s',
895
- }, children: cur?.hops[j]?.data }, j))), routed.flatMap((r) => {
896
- const e = edges[ids.indexOf(r.id)];
897
- const texts = r.stub && lanePlan?.stubs.get(r.id);
898
- if (!texts && (e.label == null || tl))
899
- return [];
900
- const on = litEdges.has(r.id);
901
- const tone = hopTone(r.id);
902
- const pills = texts
903
- ? r.stub.pills.map((p, j) => ({
904
- key: `${r.id}:${j}`,
905
- x: p.x + p.w / 2,
906
- y: p.y + p.h / 2,
907
- text: (j === 0 && r.stub.short ? texts[2] : texts[j]),
908
- }))
909
- : [{ key: r.id, x: r.mid.x, y: r.mid.y, text: e.label }];
910
- return pills.map((p) => (_jsx("div", { "data-fig-label": p.key, style: {
1046
+ }, d: r.d, fill: "none", stroke: "none" }, r.id));
1047
+ if (r.stub)
1048
+ return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
1049
+ return look(r.d, r.id, true);
1050
+ }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
1051
+ dots.current[j] = g;
1052
+ }, 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) => {
1053
+ chips.current[j] = c;
1054
+ }, style: {
911
1055
  position: 'absolute',
912
- left: p.x,
913
- top: p.y,
914
- transform: 'translate(-50%, -50%)',
915
- fontSize: 11,
916
- lineHeight: '16px',
917
- padding: '0 7px',
918
- borderRadius: 999,
919
- whiteSpace: 'nowrap',
1056
+ left: 0,
1057
+ top: 0,
1058
+ zIndex: 2,
1059
+ opacity: 0,
1060
+ maxWidth: 220,
1061
+ width: 'max-content',
1062
+ padding: '4px 9px',
1063
+ borderRadius: 8,
1064
+ fontSize: 11.5,
1065
+ lineHeight: '15px',
920
1066
  pointerEvents: 'none',
921
- background: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'),
922
- color: on ? ON_ACCENT : v('muted'),
923
- border: `1px solid ${on ? (tone ?? v('accent')) : v('border')}`,
924
- opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
925
- fontFamily: MONO,
926
- transition: 'background .25s, color .25s, opacity .25s',
927
- }, children: p.text }, p.key)));
928
- })] }) }) }) })), rail && (_jsx("div", { ref: railPane, onWheel: () => (user.current = true), onPointerDown: () => (user.current = true), onTouchStart: () => (user.current = true), onScroll: (e) => {
1067
+ background: v('accent'),
1068
+ color: ON_ACCENT,
1069
+ boxShadow: '0 4px 14px rgba(0,0,0,.18)',
1070
+ transition: 'opacity .2s',
1071
+ }, children: cur?.hops[j]?.data }, j))), routed.flatMap((r) => {
1072
+ const e = edges[ids.indexOf(r.id)];
1073
+ const texts = r.stub && lanePlan?.stubs.get(r.id);
1074
+ if (!texts && (e.label == null || tl))
1075
+ return [];
1076
+ const on = litEdges.has(r.id);
1077
+ const tone = hopTone(r.id);
1078
+ const pills = texts
1079
+ ? r.stub.pills.map((p, j) => ({
1080
+ key: `${r.id}:${j}`,
1081
+ x: p.x + p.w / 2,
1082
+ y: p.y + p.h / 2,
1083
+ text: (j === 0 && r.stub.short ? texts[2] : texts[j]),
1084
+ }))
1085
+ : [{ key: r.id, x: r.mid.x, y: r.mid.y, text: e.label }];
1086
+ return pills.map((p) => (_jsx("div", { "data-fig-label": p.key, style: {
1087
+ position: 'absolute',
1088
+ left: p.x,
1089
+ top: p.y,
1090
+ transform: 'translate(-50%, -50%)',
1091
+ fontSize: 11,
1092
+ lineHeight: '16px',
1093
+ padding: '0 7px',
1094
+ borderRadius: 999,
1095
+ whiteSpace: 'nowrap',
1096
+ pointerEvents: 'none',
1097
+ background: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'),
1098
+ color: on ? ON_ACCENT : v('muted'),
1099
+ border: `1px solid ${on ? (tone ?? v('accent')) : v('border')}`,
1100
+ opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
1101
+ fontFamily: MONO,
1102
+ transition: 'background .25s, color .25s, opacity .25s',
1103
+ }, children: p.text }, p.key)));
1104
+ })] }) })] }) })), rail && (_jsx("div", { ref: railPane, onWheel: () => (user.current = true), onPointerDown: () => (user.current = true), onTouchStart: () => (user.current = true), onScroll: (e) => {
929
1105
  const x = e.currentTarget.scrollLeft;
930
1106
  if (user.current && x !== left.current && performance.now() > busy.current)
931
1107
  follow.current = false;
932
1108
  left.current = x;
933
- }, style: { overflowX: wide ? 'auto' : undefined }, children: _jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
1109
+ }, style: { overflowX: zoom ? 'auto' : undefined }, children: _jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
934
1110
  display: 'block',
935
- width: wide ? rail.width * RAIL_ZOOM : '100%',
936
- maxWidth: wide ? undefined : rail.width,
1111
+ width: zoom ? rail.width * zoom : '100%',
1112
+ maxWidth: zoom ? undefined : rail.width,
937
1113
  height: 'auto',
938
1114
  margin: `${noMap ? 0 : RAIL.gap}px auto 0`,
939
1115
  overflow: 'visible',