flowfig 0.4.0 → 0.6.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 +183 -140
- package/dist/browser.js +6 -2
- package/dist/check.js +48 -2
- package/dist/cli.js +2 -2
- package/dist/geometry.d.ts +27 -1
- package/dist/geometry.js +288 -24
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +11 -2
- package/dist/index.js +247 -103
- package/dist/init.js +5 -2
- package/dist/mcp.js +1 -1
- package/dist/model.d.ts +97 -0
- package/dist/model.js +251 -1
- package/dist/scene.d.ts +9 -0
- package/dist/svg.d.ts +3 -0
- package/dist/svg.js +190 -100
- package/dist/text.d.ts +2 -0
- package/dist/text.js +19 -0
- package/dist/verify.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/svg.js
CHANGED
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
// Wrapping is therefore approximate; `flowfig check` uses the same measure, and the player check measures real text.
|
|
9
9
|
import { route } from './geometry.js';
|
|
10
10
|
import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
|
|
11
|
-
import { textWidth } from './text.js';
|
|
11
|
+
import { textWidth, wrap } from './text.js';
|
|
12
12
|
import { checkScene, checkSpec, checkTheme } from './check.js';
|
|
13
13
|
export { parseSource, links } from './source.js';
|
|
14
14
|
export { diff, formatDiff } from './diff.js';
|
|
15
|
-
import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows,
|
|
15
|
+
import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, lanePlan, STUB_ROOM, laneBlock, laneEnd, tightCopies, nodeWidth, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
|
|
16
16
|
export { LIGHT, DARK } from './model.js';
|
|
17
17
|
const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
|
|
18
|
-
const LABEL_LINE = 18, SUB_LINE = 15
|
|
19
|
-
const FRAME_TOP = 37,
|
|
18
|
+
const LABEL_LINE = 18, SUB_LINE = 15;
|
|
19
|
+
const FRAME_TOP = 37, FRAME_BOTTOM = 18;
|
|
20
20
|
// The card-on fills are fixed colors that approximate the player's 8% accent tint. The active tint is the player's color-mix.
|
|
21
21
|
const vars = (t, cardOn) => `--accent:${t.accent}; --fg:${t.fg}; --muted:${t.muted}; --bg:${t.bg}; --surface:${t.surface}; --border:${t.border}; --card-on:${cardOn}; --tint:color-mix(in srgb, var(--accent) 10%, var(--surface));`;
|
|
22
22
|
/** What a card actually holds: rows, or its text. Only text survives outside React. */
|
|
@@ -26,25 +26,6 @@ const n2 = (v) => Math.round(v * 10) / 10;
|
|
|
26
26
|
// Keyframe times are fractions of a loop that can last a minute: one decimal would round a 0.7 s hop away, and the packet would jump.
|
|
27
27
|
const n4 = (v) => Math.round(v * 10000) / 10000;
|
|
28
28
|
const pct = (v) => Math.round(v * 10000) / 100 + '%';
|
|
29
|
-
/** Break a string into lines that fit `width`, keeping the newlines it already has. */
|
|
30
|
-
function wrap(s, width, fontSize, mono = false) {
|
|
31
|
-
const out = [];
|
|
32
|
-
for (const para of s.split('\n')) {
|
|
33
|
-
let line = '';
|
|
34
|
-
for (const word of para.split(' ')) {
|
|
35
|
-
if (!line)
|
|
36
|
-
line = word;
|
|
37
|
-
else if (textWidth(line + ' ' + word, fontSize, mono) <= width)
|
|
38
|
-
line += ' ' + word;
|
|
39
|
-
else {
|
|
40
|
-
out.push(line);
|
|
41
|
-
line = word;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
out.push(line);
|
|
45
|
-
}
|
|
46
|
-
return out;
|
|
47
|
-
}
|
|
48
29
|
/** A tag pill. The tag is drawn in bold capitals with letter spacing, so the width counts both. */
|
|
49
30
|
const pillW = (tag) => textWidth(tag.toUpperCase(), 9) + tag.length * 0.27 + 8;
|
|
50
31
|
/** A card's rows, wrapped for `width`, with the height they need. */
|
|
@@ -69,23 +50,16 @@ function layoutCard(c, width) {
|
|
|
69
50
|
const height = rows.reduce((h, r) => h + (r.heads ? LINE : 0) + r.lines.length * LINE, 0) + ROW_GAP * Math.max(0, rows.length - 1) + CARD_PAD * 2;
|
|
70
51
|
return { rows, height };
|
|
71
52
|
}
|
|
72
|
-
function nodeWidth(item, carded) {
|
|
73
|
-
if (item.width != null)
|
|
74
|
-
return item.width;
|
|
75
|
-
if (carded)
|
|
76
|
-
return CARD_WIDTH;
|
|
77
|
-
const label = textWidth(str(item.label), 14) + 32;
|
|
78
|
-
const sub = textWidth(str(item.sub), 12) + 32;
|
|
79
|
-
return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, label, sub));
|
|
80
|
-
}
|
|
81
53
|
function size(item, s) {
|
|
82
54
|
if (!isGroup(item)) {
|
|
83
55
|
const contents = s.cards.get(item.id);
|
|
84
|
-
const w = nodeWidth(item, contents != null);
|
|
56
|
+
const w = nodeWidth(item, contents != null) + (item.shape === 'decision' ? 70 : 0);
|
|
85
57
|
const top = item.shape === 'store' ? 24 : 10;
|
|
86
58
|
const card = contents ? 8 + s.cardH.get(item.id) : 0;
|
|
87
59
|
const h = Math.max(top + LABEL_LINE + (item.sub ? SUB_LINE : 0) + card + 10, s.minH(item.id));
|
|
88
|
-
|
|
60
|
+
if (item.shape !== 'decision')
|
|
61
|
+
return { w, h };
|
|
62
|
+
return { w, h: contents ? h + 24 : Math.max(diamondLines(item, w).h, s.minH(item.id) + 24) };
|
|
89
63
|
}
|
|
90
64
|
const kids = item.children.map((c) => size(c, s));
|
|
91
65
|
const gap = s.gap(item);
|
|
@@ -94,33 +68,55 @@ function size(item, s) {
|
|
|
94
68
|
const inner = item.direction === 'column' ? { w: across, h: along } : { w: along, h: across };
|
|
95
69
|
return item.label != null ? { w: inner.w + FRAME_SIDE * 2, h: inner.h + FRAME_TOP + FRAME_BOTTOM } : inner;
|
|
96
70
|
}
|
|
97
|
-
/** Swimlanes: the bands span the width, the label sits in a left gutter, and a box sits at its time column.
|
|
71
|
+
/** Swimlanes: the bands span the width, the label sits in a left gutter, and a box sits at its time column. Columns past
|
|
72
|
+
* the plan wrap into blocks under the first. A block holds only the lanes with a box in it; all bands share one width. */
|
|
98
73
|
function placeLanes(fig, x, y, s, out) {
|
|
99
74
|
const lanes = fig.layout.children;
|
|
100
|
-
const cols =
|
|
75
|
+
const { cols, starts, gaps, lead, blocks } = s.plan;
|
|
101
76
|
const colW = Array.from({ length: Math.max(-1, ...cols.values()) + 1 }, () => 0);
|
|
102
77
|
for (const lane of lanes)
|
|
103
78
|
for (const b of lane.children)
|
|
104
79
|
colW[cols.get(b.id)] = Math.max(colW[cols.get(b.id)], size(b, s).w);
|
|
105
80
|
const gutter = gutterOf(lanes);
|
|
106
|
-
const
|
|
107
|
-
const
|
|
81
|
+
const colsOf = (k) => colW.slice(starts[k], starts[k + 1]);
|
|
82
|
+
const colX = colW.map((_, c) => {
|
|
83
|
+
const k = laneBlock(starts, c);
|
|
84
|
+
return (gutter +
|
|
85
|
+
lead[k] +
|
|
86
|
+
colsOf(k)
|
|
87
|
+
.slice(0, c - starts[k])
|
|
88
|
+
.reduce((a, w, i) => a + w + gaps[starts[k] + i], 0));
|
|
89
|
+
});
|
|
90
|
+
// A figure with no box has no column: its band keeps the frame side on the right.
|
|
91
|
+
const width = Math.max(...blocks.map((k) => {
|
|
92
|
+
const ws = colsOf(k);
|
|
93
|
+
return (gutter +
|
|
94
|
+
lead[k] +
|
|
95
|
+
ws.reduce((a, w) => a + w, 0) +
|
|
96
|
+
gaps.slice(starts[k], starts[k] + ws.length - 1).reduce((a, g) => a + g, 0) +
|
|
97
|
+
(gaps[starts[k] + ws.length - 1] ?? FRAME_SIDE));
|
|
98
|
+
}));
|
|
108
99
|
const topAt = out.length;
|
|
109
100
|
out.push({ x, y, w: width, h: 0, item: fig.layout });
|
|
110
101
|
let ly = y;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
102
|
+
blocks.forEach((bk, i) => {
|
|
103
|
+
if (i)
|
|
104
|
+
ly += LANE_BLOCK_GAP - LANE_ROW_GAP;
|
|
105
|
+
for (const lane of lanes) {
|
|
106
|
+
const kids = lane.children.filter((b) => laneBlock(starts, cols.get(b.id)) === bk).map((b) => ({ b, ...size(b, s) }));
|
|
107
|
+
// A wrapped block draws only its own lanes; one block draws every lane, an empty one too.
|
|
108
|
+
if (blocks.length > 1 && !kids.length)
|
|
109
|
+
continue;
|
|
110
|
+
const inner = Math.max(LABEL_LINE + 20, ...kids.map((k) => k.h));
|
|
111
|
+
const h = inner + LANE_PAD * 2 + (s.tall.has(`${lanes.indexOf(lane)}@${bk}`) ? STUB_ROOM : 0);
|
|
112
|
+
out.push({ x, y: ly, w: width, h, item: lane, lane: true, block: bk });
|
|
113
|
+
for (const k of kids)
|
|
114
|
+
out.push({ x: x + colX[cols.get(k.b.id)], y: ly + LANE_PAD + (inner - k.h) / 2, w: k.w, h: k.h, item: k.b });
|
|
115
|
+
ly += h + LANE_ROW_GAP;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
120
118
|
out[topAt].h = ly - LANE_ROW_GAP - y;
|
|
121
119
|
}
|
|
122
|
-
/** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
|
|
123
|
-
const gutterOf = (lanes) => Math.max(0, ...lanes.map((l) => textWidth(str(l.label).toUpperCase(), 12))) + FRAME_SIDE * 2;
|
|
124
120
|
/** A timeline: the axis strip on top, one band per track, and the bars and milestones at the shared layout x. */
|
|
125
121
|
function placeTimeline(fig, x, y, out) {
|
|
126
122
|
const lanes = fig.layout.children;
|
|
@@ -210,7 +206,9 @@ export function render(fig, opts = {}) {
|
|
|
210
206
|
return n > 2 ? n * 30 : 0;
|
|
211
207
|
};
|
|
212
208
|
const cardH = new Map();
|
|
213
|
-
const
|
|
209
|
+
const lanes = !tl && fig.lanes && isLanesLayout(fig.layout);
|
|
210
|
+
const plan = lanes ? planFor(fig, opts) : null;
|
|
211
|
+
const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges), fig, plan, tall: new Set(plan?.tall) };
|
|
214
212
|
for (const [id, contents] of cards) {
|
|
215
213
|
const width = CARD_WIDTH; // refined below once the node's own width is known
|
|
216
214
|
const widths = [width];
|
|
@@ -218,7 +216,7 @@ export function render(fig, opts = {}) {
|
|
|
218
216
|
}
|
|
219
217
|
const placed = [];
|
|
220
218
|
place(fig.layout, pad, pad, sizes, placed);
|
|
221
|
-
|
|
219
|
+
let nodes = placed.filter((p) => !isGroup(p.item));
|
|
222
220
|
// A node's own width can differ from CARD_WIDTH (`width` in the spec), so re-measure once placed.
|
|
223
221
|
for (const p of nodes) {
|
|
224
222
|
if (!cards.has(p.item.id))
|
|
@@ -226,21 +224,55 @@ export function render(fig, opts = {}) {
|
|
|
226
224
|
const inner = p.w - 20;
|
|
227
225
|
cardH.set(p.item.id, Math.max(...cards.get(p.item.id).map((c) => layoutCard(c, inner).height), LINE + CARD_PAD * 2));
|
|
228
226
|
}
|
|
229
|
-
placed.length = 0;
|
|
230
|
-
place(fig.layout, pad, pad, sizes, placed);
|
|
231
227
|
const rects = {};
|
|
232
|
-
for (const p of placed)
|
|
233
|
-
if (p.item.id)
|
|
234
|
-
rects[p.item.id] = p;
|
|
235
228
|
const tips = new Set(placed.filter((p) => !isGroup(p.item) && (p.item.shape === 'decision' || p.tl?.milestone)).map((p) => p.item.id));
|
|
236
229
|
const ids = fig.edges.map(edgeId);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
230
|
+
// Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane gutters.
|
|
231
|
+
const stubs = plan?.stubs ?? new Map();
|
|
232
|
+
const gutter = plan ? gutterOf(fig.layout.children) : 0;
|
|
233
|
+
const band = (id, k) => placed.find((p) => p.lane && p.item.id === id && p.block === k);
|
|
234
|
+
// The band of an edge end, in the block of that end: the band of the box's lane, or of the lane itself.
|
|
235
|
+
const laneGroups = fig.layout.children;
|
|
236
|
+
const bandOf = (eid, id, k) => {
|
|
237
|
+
const block = plan?.ends.get(eid)?.[k];
|
|
238
|
+
const lane = laneGroups.find((l) => l.id === id || l.children.some((b) => b.id === id));
|
|
239
|
+
return placed.find((p) => p.lane && p.item === lane && p.block === block);
|
|
240
|
+
};
|
|
241
|
+
const end = (eid, id, start) => (plan ? laneEnd(plan, eid, id, start, band, rects) : id);
|
|
242
|
+
// Place and route. A stub end with no place clear of every edge path makes its lane copy taller, then both run again.
|
|
243
|
+
const go = () => {
|
|
244
|
+
placed.length = 0;
|
|
245
|
+
place(fig.layout, pad, pad, sizes, placed);
|
|
246
|
+
for (const p of placed)
|
|
247
|
+
if (p.item.id)
|
|
248
|
+
rects[p.item.id] = p;
|
|
249
|
+
return route(fig.edges.map((e, i) => ({
|
|
250
|
+
id: ids[i],
|
|
251
|
+
from: end(ids[i], e.from, true),
|
|
252
|
+
to: end(ids[i], e.to, false),
|
|
253
|
+
around: e.around ?? (plan?.around.has(ids[i]) ? 'below' : undefined),
|
|
254
|
+
...(tl && { sides: ['r', 'l'], elbow: true }),
|
|
255
|
+
...(stubs.has(ids[i]) && { stub: stubs.get(ids[i]).map(labelPillW), bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)] }),
|
|
256
|
+
...((stubs.size || lanes) && e.label != null && { labelW: labelPillW(str(e.label)) }),
|
|
257
|
+
})), rects, tips, stubs.size
|
|
258
|
+
? placed.flatMap((p) => (!isGroup(p.item) ? [p] : p.lane ? [{ x: p.x, y: p.y, w: gutter, h: p.h }] : []))
|
|
259
|
+
: [
|
|
260
|
+
...placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))),
|
|
261
|
+
// A timeline elbow keeps clear of the bars and milestones it does not connect.
|
|
262
|
+
...placed.filter((p) => p.tl).map((p) => ({ x: p.x, y: p.y, w: p.w, h: p.h, box: true })),
|
|
263
|
+
],
|
|
264
|
+
// A stub pill stays in the lanes, right of the gutter.
|
|
265
|
+
stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined, lanes ? { bands: placed.filter((p) => p.lane), boxes: placed.filter((p) => !isGroup(p.item)) } : undefined);
|
|
266
|
+
};
|
|
267
|
+
let routed = go();
|
|
268
|
+
for (let pass = 0; plan && pass < 3; pass++) {
|
|
269
|
+
const more = [...tightCopies(laneGroups, plan.ends, fig.edges, routed)].filter((k) => !sizes.tall.has(k));
|
|
270
|
+
if (!more.length)
|
|
271
|
+
break;
|
|
272
|
+
more.forEach((k) => sizes.tall.add(k));
|
|
273
|
+
routed = go();
|
|
274
|
+
}
|
|
275
|
+
nodes = placed.filter((p) => !isGroup(p.item));
|
|
244
276
|
const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
|
|
245
277
|
// What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
|
|
246
278
|
const fonts = [14, ...(steps.length ? [13.5] : [])];
|
|
@@ -255,12 +287,18 @@ export function render(fig, opts = {}) {
|
|
|
255
287
|
fonts.push(13);
|
|
256
288
|
return { id: p.item.id, rect: { x: p.x, y: p.y, w: p.w, h: p.h }, texts: [{ text: label, fontSize: 13, room }] };
|
|
257
289
|
}
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
290
|
+
const dia = p.item.shape === 'decision' && !cards.has(p.item.id);
|
|
291
|
+
const dl = dia ? diamondLines(p.item, p.w) : null;
|
|
292
|
+
const room = (far) => diamondRoom(p.w, p.h, far);
|
|
293
|
+
const flat = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
|
|
294
|
+
const texts = [{ text: str(p.item.label), fontSize: 14, room: dl ? room(dl.label.far) : flat }];
|
|
295
|
+
if (dl)
|
|
296
|
+
for (const l of dl.subs)
|
|
297
|
+
texts.push({ text: l.text, fontSize: 12, room: room(l.far) });
|
|
298
|
+
else if (p.item.sub)
|
|
299
|
+
texts.push({ text: str(p.item.sub), fontSize: 12, room: flat });
|
|
300
|
+
if (p.item.sub)
|
|
262
301
|
fonts.push(12);
|
|
263
|
-
}
|
|
264
302
|
for (const c of cards.get(p.item.id) ?? [])
|
|
265
303
|
for (const r of layoutCard(c, p.w - 20).rows) {
|
|
266
304
|
const fontSize = r.row.mono ? 10.5 : 11;
|
|
@@ -443,7 +481,8 @@ export function render(fig, opts = {}) {
|
|
|
443
481
|
const cx = p.x + p.w / 2;
|
|
444
482
|
const contents = cards.get(item.id);
|
|
445
483
|
const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
|
|
446
|
-
const
|
|
484
|
+
const dl = item.shape === 'decision' && !contents ? diamondLines(item, p.w) : null;
|
|
485
|
+
const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (dl ? dl.label.dy : item.sub ? -2 : 5);
|
|
447
486
|
const shape = item.shape === 'decision'
|
|
448
487
|
? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(p.y + p.h / 2)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(p.y + p.h / 2)}" fill="${fill0}" stroke="${stroke0}"${stroke}/>`
|
|
449
488
|
: item.shape === 'store'
|
|
@@ -451,7 +490,11 @@ export function render(fig, opts = {}) {
|
|
|
451
490
|
`<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 0 ${n2(p.w)} 0" fill="none" stroke="${stroke0}"${rim}/>`
|
|
452
491
|
: `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
|
|
453
492
|
const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
|
|
454
|
-
const sub =
|
|
493
|
+
const sub = dl
|
|
494
|
+
? dl.subs.map((l) => `<text x="${n2(cx)}" y="${n2(p.y + p.h / 2 + l.dy)}" class="sub">${esc(l.text)}</text>`).join('')
|
|
495
|
+
: item.sub
|
|
496
|
+
? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>`
|
|
497
|
+
: '';
|
|
455
498
|
// The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
|
|
456
499
|
const dot = bt ?? 'var(--accent)';
|
|
457
500
|
const my = n2(p.y + p.h / 2);
|
|
@@ -531,20 +574,30 @@ export function render(fig, opts = {}) {
|
|
|
531
574
|
const lit = cls(toned
|
|
532
575
|
? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
|
|
533
576
|
: anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
|
|
534
|
-
|
|
577
|
+
// A cross-block edge draws its two stubs; its full path only guides the packet, so the packet jumps between the blocks.
|
|
578
|
+
const path = r.stub
|
|
579
|
+
? r.stub.parts
|
|
580
|
+
.map((d) => `<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
|
|
581
|
+
.join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
|
|
582
|
+
: `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
|
|
535
583
|
// A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
584
|
+
const pill = (x, y, lw, text) => `<rect x="${n2(x - lw / 2)}" y="${n2(y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
|
|
585
|
+
cls(toned
|
|
586
|
+
? frames(on.map((x, i) => x ? `fill: ${tone[i] ? toneFill(col[i]) : col[i]}; stroke: ${col[i]}` : 'fill: var(--bg); stroke: var(--border)'), 'l', 'fill: var(--bg); stroke: var(--border)')
|
|
587
|
+
: anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
|
|
588
|
+
`/><text x="${n2(x)}" y="${n2(y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(text)}</text>`;
|
|
589
|
+
let label = '';
|
|
590
|
+
if (r.stub) {
|
|
591
|
+
fonts.push(11);
|
|
592
|
+
const texts = stubs.get(r.id);
|
|
593
|
+
label = r.stub.pills.map((p, k) => pill(p.x + p.w / 2, p.y + 9, p.w, k === 0 && r.stub.short ? texts[2] : texts[k])).join('');
|
|
594
|
+
}
|
|
595
|
+
else if (e.label != null && !tl) {
|
|
596
|
+
const lw = labelPillW(str(e.label));
|
|
597
|
+
labelRects[r.id] = { x: r.mid.x - lw / 2, y: r.mid.y - 9, w: lw, h: 18 };
|
|
598
|
+
fonts.push(11);
|
|
599
|
+
label = pill(r.mid.x, r.mid.y, lw, str(e.label));
|
|
600
|
+
}
|
|
548
601
|
return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
|
|
549
602
|
});
|
|
550
603
|
// A packet per hop: it waits offstage, crosses its edge in `speed`, rests at the end of the edge to the end of its beat, then leaves. Any `data`
|
|
@@ -569,8 +622,8 @@ export function render(fig, opts = {}) {
|
|
|
569
622
|
`<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n4(t0)};${n4(t1)};1" keyPoints="${h.back ? '1;1;0;0' : '0;0;1;1'}" calcMode="linear">` +
|
|
570
623
|
`<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
|
|
571
624
|
});
|
|
572
|
-
// The timeline axis
|
|
573
|
-
let axisSvg = '', todaySvg = '';
|
|
625
|
+
// The timeline axis, the fixed today marker and the moving playhead. Their lines sit behind the bars, their labels on top.
|
|
626
|
+
let axisSvg = '', lineSvg = '', todaySvg = '';
|
|
574
627
|
if (tl) {
|
|
575
628
|
const top = placed[0];
|
|
576
629
|
const x0 = top.x + gutterOf(fig.layout.children);
|
|
@@ -586,19 +639,21 @@ export function render(fig, opts = {}) {
|
|
|
586
639
|
// The today marker is fixed at the figure's `today`.
|
|
587
640
|
if (tl.today != null) {
|
|
588
641
|
const tx = x0 + tl.today;
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
`<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
|
|
642
|
+
lineSvg = `<path d="M ${n2(tx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-opacity="0.6" stroke-width="1" stroke-dasharray="3 3"/>`;
|
|
643
|
+
todaySvg = `<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
|
|
592
644
|
}
|
|
593
|
-
// The playhead moves to the start of each beat's item, then to the last date in the step hold.
|
|
645
|
+
// The playhead moves to the start of each beat's item, then to the last date in the step hold. The loop starts with
|
|
646
|
+
// the playhead at the first beat's item, so the first frame shows the first beat; the last hold moves it back there.
|
|
594
647
|
if (tl.last != null && segs.length) {
|
|
595
648
|
const home = tl.last;
|
|
596
649
|
const startOf = new Map(tl.items.map((i) => [i.id, i.x + (i.milestone ? i.w / 2 : 0)]));
|
|
597
650
|
const dateOf = new Map(tl.items.map((i) => [i.id, i.date]));
|
|
598
651
|
const hold = STEP_HOLD_MS / 1000 / BASE_RATE;
|
|
599
|
-
const
|
|
652
|
+
const first = loopStartItem(tl.items, steps);
|
|
653
|
+
const start = first ? startOf.get(first.id) : home;
|
|
654
|
+
const pts = [[0, start]];
|
|
600
655
|
const dates = []; // the label date in each time span
|
|
601
|
-
let at =
|
|
656
|
+
let at = start;
|
|
602
657
|
segs.forEach((s, i) => {
|
|
603
658
|
const item = playheadItem(tl.items, beats[s.si], s.bi);
|
|
604
659
|
const to = item ? startOf.get(item.id) : home;
|
|
@@ -606,15 +661,20 @@ export function render(fig, opts = {}) {
|
|
|
606
661
|
const last = i === segs.length - 1 || segs[i + 1].si !== s.si;
|
|
607
662
|
const end = last ? s.t1 - hold : s.t1; // the step hold starts here
|
|
608
663
|
pts.push([s.t0, at], [Math.min(s.t0 + RAMP, end), to], [end, to]);
|
|
609
|
-
|
|
664
|
+
// The label waits for the line, except at the loop start, where the line is already in place.
|
|
665
|
+
const [la, lb] = i === 0 && to === start ? [s.t0, end] : labelSpan(s.t0, end, RAMP);
|
|
610
666
|
if (la < lb)
|
|
611
667
|
dates.push({ a: la, b: lb, d: date, x: to });
|
|
612
668
|
at = to;
|
|
613
669
|
if (last) {
|
|
614
|
-
|
|
615
|
-
const
|
|
670
|
+
// The last hold of the loop ends with the move back to the loop start.
|
|
671
|
+
const back = i === segs.length - 1 ? Math.max(end + RAMP, s.t1 - RAMP) : s.t1;
|
|
672
|
+
pts.push([Math.min(end + RAMP, s.t1), home], [back, home]);
|
|
673
|
+
const [ha, hb] = labelSpan(end, back, RAMP);
|
|
616
674
|
if (ha < hb)
|
|
617
675
|
dates.push({ a: ha, b: hb, d: tl.lastDate, x: home });
|
|
676
|
+
if (back < s.t1)
|
|
677
|
+
pts.push([s.t1, start]);
|
|
618
678
|
at = home;
|
|
619
679
|
}
|
|
620
680
|
});
|
|
@@ -639,11 +699,12 @@ export function render(fig, opts = {}) {
|
|
|
639
699
|
const raised = dateLabelRaised(dates.find((x) => x.d === d).x, tl.today, textWidth(d, 11), textWidth('today', 11));
|
|
640
700
|
return `<text x="${n2(lx + 3)}" y="${n2(top.y + (raised ? 10 : 22))}" opacity="0"${cls('today', seen.get(key))}>${esc(d)}</text>`;
|
|
641
701
|
});
|
|
642
|
-
|
|
702
|
+
lineSvg += `<g${cls(name)}><path d="M ${n2(lx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-width="1.5"/></g>`;
|
|
703
|
+
todaySvg += `<g${cls(name)}>${labels.join('')}</g>`;
|
|
643
704
|
}
|
|
644
705
|
}
|
|
645
706
|
const bounds = placed[0];
|
|
646
|
-
const arcs = fig.edges.some((e) => e.around);
|
|
707
|
+
const arcs = fig.edges.some((e, i) => (e.around || plan?.around.has(ids[i])) && !stubs.has(ids[i])); // a stub does not arc
|
|
647
708
|
const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
|
|
648
709
|
const mapW = Math.max(bounds.w + pad * 2, 560);
|
|
649
710
|
// `rail: 'only'` drops the map, but only when the rail has a hop to draw; a figure never renders empty.
|
|
@@ -782,7 +843,7 @@ ${only
|
|
|
782
843
|
? ''
|
|
783
844
|
: `<g transform="translate(${n2(shift)} ${arcs ? 44 : 0})">
|
|
784
845
|
${axisSvg}
|
|
785
|
-
${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}
|
|
846
|
+
${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}${lineSvg && `\n${lineSvg}`}
|
|
786
847
|
${tl ? edgeSvg.join('\n') : ''}
|
|
787
848
|
${boxes.filter((b, i) => b && !(tl && isGroup(placed[i].item))).join('\n')}
|
|
788
849
|
${tl ? '' : edgeSvg.join('\n')}
|
|
@@ -800,9 +861,22 @@ ${said.join('\n')}
|
|
|
800
861
|
width: n2(W),
|
|
801
862
|
boxes: only ? [] : sceneBoxes,
|
|
802
863
|
edges: [
|
|
803
|
-
...(only ? [] : routed).
|
|
864
|
+
...(only ? [] : routed).flatMap((r) => {
|
|
804
865
|
const e = fig.edges[ids.indexOf(r.id)];
|
|
805
|
-
|
|
866
|
+
// A cross-block edge is two scene edges: each stub with its pill.
|
|
867
|
+
if (r.stub)
|
|
868
|
+
return r.stub.pts.map((pts, k) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: r.stub.pills[k] }));
|
|
869
|
+
// A timeline edge is drawn behind, but `check` still tests its elbow against the boxes it does not connect.
|
|
870
|
+
return [
|
|
871
|
+
{
|
|
872
|
+
id: r.id,
|
|
873
|
+
from: e.from,
|
|
874
|
+
to: e.to,
|
|
875
|
+
curve: r.curve,
|
|
876
|
+
label: labelRects[r.id],
|
|
877
|
+
...(tl && { behind: true, elbow: r.elbow }),
|
|
878
|
+
},
|
|
879
|
+
];
|
|
806
880
|
}),
|
|
807
881
|
// Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
|
|
808
882
|
...(rail?.rows ?? []).flatMap((row, i) => {
|
|
@@ -826,14 +900,30 @@ ${said.join('\n')}
|
|
|
826
900
|
}),
|
|
827
901
|
],
|
|
828
902
|
minFont: Math.min(...fonts),
|
|
903
|
+
...(lanes && {
|
|
904
|
+
lanes: placed.filter((p) => p.lane).map((p) => ({ id: str(p.item.label), rect: { x: p.x, y: p.y, w: p.w, h: p.h } })),
|
|
905
|
+
}),
|
|
829
906
|
};
|
|
830
907
|
return { svg, scene };
|
|
831
908
|
}
|
|
909
|
+
/** The lanes wrap plan at the width and text size of `opts`, or null if the figure does not draw as lanes. A rail sets a least width. */
|
|
910
|
+
function planFor(fig, opts) {
|
|
911
|
+
if (fig.timeline || !fig.lanes || !isLanesLayout(fig.layout))
|
|
912
|
+
return null;
|
|
913
|
+
const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
|
|
914
|
+
return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
|
|
915
|
+
}
|
|
832
916
|
/** One self-contained animated SVG string for the figure. Needs no React and no browser. */
|
|
833
917
|
export function toSvg(fig, opts = {}) {
|
|
834
918
|
return render(fig, opts).svg;
|
|
835
919
|
}
|
|
836
920
|
/** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. A `Finding` has a `rule` name, a `severity`, the ids it names and a message. */
|
|
837
921
|
export function check(fig, opts = {}) {
|
|
838
|
-
|
|
922
|
+
const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
|
|
923
|
+
rule: 'lane-end-block',
|
|
924
|
+
severity: 'warning',
|
|
925
|
+
ids: [id],
|
|
926
|
+
message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
|
|
927
|
+
}));
|
|
928
|
+
return [...checkSpec(fig), ...checkScene(render(fig, opts).scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
|
|
839
929
|
}
|
package/dist/text.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** The width of `s` in px at `fontSize`. */
|
|
2
2
|
export declare function textWidth(s: string, fontSize: number, mono?: boolean): number;
|
|
3
|
+
/** Break a string into lines that fit `width`, keeping the newlines it already has. */
|
|
4
|
+
export declare function wrap(s: string, width: number, fontSize: number, mono?: boolean): string[];
|
package/dist/text.js
CHANGED
|
@@ -11,3 +11,22 @@ export function textWidth(s, fontSize, mono = false) {
|
|
|
11
11
|
em += FULL.test(ch) ? 1 : mono ? 0.6 : WIDE.test(ch) ? 0.85 : NARROW.test(ch) ? 0.3 : TALL.test(ch) ? 0.64 : 0.53;
|
|
12
12
|
return em * fontSize;
|
|
13
13
|
}
|
|
14
|
+
/** Break a string into lines that fit `width`, keeping the newlines it already has. */
|
|
15
|
+
export function wrap(s, width, fontSize, mono = false) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const para of s.split('\n')) {
|
|
18
|
+
let line = '';
|
|
19
|
+
for (const word of para.split(' ')) {
|
|
20
|
+
if (!line)
|
|
21
|
+
line = word;
|
|
22
|
+
else if (textWidth(line + ' ' + word, fontSize, mono) <= width)
|
|
23
|
+
line += ' ' + word;
|
|
24
|
+
else {
|
|
25
|
+
out.push(line);
|
|
26
|
+
line = word;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
out.push(line);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
package/dist/verify.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// `flowfig verify`: checks that the code a link names is still there. The link parsing lives in source.ts, which has no Node import.
|
|
2
2
|
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
-
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { headingSlug, links } from './source.js';
|
|
5
5
|
export { parseSource, headingSlug, owners, links } from './source.js';
|
|
6
6
|
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -28,7 +28,7 @@ export function verify(fig, { root = process.cwd() } = {}) {
|
|
|
28
28
|
for (const l of all) {
|
|
29
29
|
const full = resolve(root, l.path);
|
|
30
30
|
const rel = relative(root, full);
|
|
31
|
-
const outside = isAbsolute(l.path) || rel === '..' || rel.startsWith(
|
|
31
|
+
const outside = isAbsolute(l.path) || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
32
32
|
if (!files.has(full))
|
|
33
33
|
files.set(full, outside ? null : read(full));
|
|
34
34
|
const text = files.get(full);
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.6.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.6.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowfig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Your coding agent draws animated diagrams from your code, and flowfig checks them. One animated SVG for a GitHub README, PR or issue, and a React player. Set up Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro with npx flowfig init.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|