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/README.md +2 -30
- package/dist/check.js +44 -1
- package/dist/cli.js +1 -1
- package/dist/geometry.d.ts +17 -1
- package/dist/geometry.js +177 -14
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +4 -2
- package/dist/index.js +195 -73
- package/dist/mcp.js +1 -1
- package/dist/model.d.ts +91 -0
- package/dist/model.js +249 -1
- package/dist/scene.d.ts +7 -0
- package/dist/svg.d.ts +3 -0
- package/dist/svg.js +155 -87
- package/dist/text.d.ts +2 -0
- package/dist/text.js +19 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
.
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
305
|
+
...routed.flatMap((r) => {
|
|
228
306
|
const e = edges[ids.indexOf(r.id)];
|
|
229
|
-
|
|
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
|
|
585
|
+
const { cols, starts, gaps, lead, blocks, gutter, tall } = lanePlan;
|
|
505
586
|
const n = Math.max(0, ...cols.values()) + 1;
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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 }),
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
864
|
+
}, children: cur?.hops[j]?.data }, j))), routed.flatMap((r) => {
|
|
753
865
|
const e = edges[ids.indexOf(r.id)];
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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:
|
|
761
|
-
top:
|
|
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:
|
|
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;
|