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/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, 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,51 @@ 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 && 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
|
+
: placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))),
|
|
260
|
+
// A stub pill stays in the lanes, right of the gutter.
|
|
261
|
+
stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined);
|
|
262
|
+
};
|
|
263
|
+
let routed = go();
|
|
264
|
+
for (let pass = 0; plan && pass < 3; pass++) {
|
|
265
|
+
const more = [...tightCopies(laneGroups, plan.ends, fig.edges, routed)].filter((k) => !sizes.tall.has(k));
|
|
266
|
+
if (!more.length)
|
|
267
|
+
break;
|
|
268
|
+
more.forEach((k) => sizes.tall.add(k));
|
|
269
|
+
routed = go();
|
|
270
|
+
}
|
|
271
|
+
nodes = placed.filter((p) => !isGroup(p.item));
|
|
244
272
|
const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
|
|
245
273
|
// What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
|
|
246
274
|
const fonts = [14, ...(steps.length ? [13.5] : [])];
|
|
@@ -255,12 +283,18 @@ export function render(fig, opts = {}) {
|
|
|
255
283
|
fonts.push(13);
|
|
256
284
|
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
285
|
}
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
286
|
+
const dia = p.item.shape === 'decision' && !cards.has(p.item.id);
|
|
287
|
+
const dl = dia ? diamondLines(p.item, p.w) : null;
|
|
288
|
+
const room = (far) => diamondRoom(p.w, p.h, far);
|
|
289
|
+
const flat = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
|
|
290
|
+
const texts = [{ text: str(p.item.label), fontSize: 14, room: dl ? room(dl.label.far) : flat }];
|
|
291
|
+
if (dl)
|
|
292
|
+
for (const l of dl.subs)
|
|
293
|
+
texts.push({ text: l.text, fontSize: 12, room: room(l.far) });
|
|
294
|
+
else if (p.item.sub)
|
|
295
|
+
texts.push({ text: str(p.item.sub), fontSize: 12, room: flat });
|
|
296
|
+
if (p.item.sub)
|
|
262
297
|
fonts.push(12);
|
|
263
|
-
}
|
|
264
298
|
for (const c of cards.get(p.item.id) ?? [])
|
|
265
299
|
for (const r of layoutCard(c, p.w - 20).rows) {
|
|
266
300
|
const fontSize = r.row.mono ? 10.5 : 11;
|
|
@@ -443,7 +477,8 @@ export function render(fig, opts = {}) {
|
|
|
443
477
|
const cx = p.x + p.w / 2;
|
|
444
478
|
const contents = cards.get(item.id);
|
|
445
479
|
const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
|
|
446
|
-
const
|
|
480
|
+
const dl = item.shape === 'decision' && !contents ? diamondLines(item, p.w) : null;
|
|
481
|
+
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
482
|
const shape = item.shape === 'decision'
|
|
448
483
|
? `<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
484
|
: item.shape === 'store'
|
|
@@ -451,7 +486,11 @@ export function render(fig, opts = {}) {
|
|
|
451
486
|
`<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
487
|
: `<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
488
|
const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
|
|
454
|
-
const sub =
|
|
489
|
+
const sub = dl
|
|
490
|
+
? dl.subs.map((l) => `<text x="${n2(cx)}" y="${n2(p.y + p.h / 2 + l.dy)}" class="sub">${esc(l.text)}</text>`).join('')
|
|
491
|
+
: item.sub
|
|
492
|
+
? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>`
|
|
493
|
+
: '';
|
|
455
494
|
// The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
|
|
456
495
|
const dot = bt ?? 'var(--accent)';
|
|
457
496
|
const my = n2(p.y + p.h / 2);
|
|
@@ -531,20 +570,30 @@ export function render(fig, opts = {}) {
|
|
|
531
570
|
const lit = cls(toned
|
|
532
571
|
? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
|
|
533
572
|
: anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
|
|
534
|
-
|
|
573
|
+
// A cross-block edge draws its two stubs; its full path only guides the packet, so the packet jumps between the blocks.
|
|
574
|
+
const path = r.stub
|
|
575
|
+
? r.stub.parts
|
|
576
|
+
.map((d) => `<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
|
|
577
|
+
.join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
|
|
578
|
+
: `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
|
|
535
579
|
// 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
|
-
|
|
580
|
+
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)"` +
|
|
581
|
+
cls(toned
|
|
582
|
+
? 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)')
|
|
583
|
+
: anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
|
|
584
|
+
`/><text x="${n2(x)}" y="${n2(y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(text)}</text>`;
|
|
585
|
+
let label = '';
|
|
586
|
+
if (r.stub) {
|
|
587
|
+
fonts.push(11);
|
|
588
|
+
const texts = stubs.get(r.id);
|
|
589
|
+
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('');
|
|
590
|
+
}
|
|
591
|
+
else if (e.label != null && !tl) {
|
|
592
|
+
const lw = labelPillW(str(e.label));
|
|
593
|
+
labelRects[r.id] = { x: r.mid.x - lw / 2, y: r.mid.y - 9, w: lw, h: 18 };
|
|
594
|
+
fonts.push(11);
|
|
595
|
+
label = pill(r.mid.x, r.mid.y, lw, str(e.label));
|
|
596
|
+
}
|
|
548
597
|
return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
|
|
549
598
|
});
|
|
550
599
|
// 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`
|
|
@@ -643,7 +692,7 @@ export function render(fig, opts = {}) {
|
|
|
643
692
|
}
|
|
644
693
|
}
|
|
645
694
|
const bounds = placed[0];
|
|
646
|
-
const arcs = fig.edges.some((e) => e.around);
|
|
695
|
+
const arcs = fig.edges.some((e, i) => (e.around || plan?.around.has(ids[i])) && !stubs.has(ids[i])); // a stub does not arc
|
|
647
696
|
const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
|
|
648
697
|
const mapW = Math.max(bounds.w + pad * 2, 560);
|
|
649
698
|
// `rail: 'only'` drops the map, but only when the rail has a hop to draw; a figure never renders empty.
|
|
@@ -800,9 +849,12 @@ ${said.join('\n')}
|
|
|
800
849
|
width: n2(W),
|
|
801
850
|
boxes: only ? [] : sceneBoxes,
|
|
802
851
|
edges: [
|
|
803
|
-
...(only ? [] : routed).
|
|
852
|
+
...(only ? [] : routed).flatMap((r) => {
|
|
804
853
|
const e = fig.edges[ids.indexOf(r.id)];
|
|
805
|
-
|
|
854
|
+
// A cross-block edge is two scene edges: each stub with its pill.
|
|
855
|
+
if (r.stub)
|
|
856
|
+
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] }));
|
|
857
|
+
return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id], ...(tl && { behind: true }) }];
|
|
806
858
|
}),
|
|
807
859
|
// Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
|
|
808
860
|
...(rail?.rows ?? []).flatMap((row, i) => {
|
|
@@ -826,14 +878,30 @@ ${said.join('\n')}
|
|
|
826
878
|
}),
|
|
827
879
|
],
|
|
828
880
|
minFont: Math.min(...fonts),
|
|
881
|
+
...(stubs.size && {
|
|
882
|
+
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 } })),
|
|
883
|
+
}),
|
|
829
884
|
};
|
|
830
885
|
return { svg, scene };
|
|
831
886
|
}
|
|
887
|
+
/** 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. */
|
|
888
|
+
function planFor(fig, opts) {
|
|
889
|
+
if (fig.timeline || !fig.lanes || !isLanesLayout(fig.layout))
|
|
890
|
+
return null;
|
|
891
|
+
const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
|
|
892
|
+
return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
|
|
893
|
+
}
|
|
832
894
|
/** One self-contained animated SVG string for the figure. Needs no React and no browser. */
|
|
833
895
|
export function toSvg(fig, opts = {}) {
|
|
834
896
|
return render(fig, opts).svg;
|
|
835
897
|
}
|
|
836
898
|
/** 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
899
|
export function check(fig, opts = {}) {
|
|
838
|
-
|
|
900
|
+
const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
|
|
901
|
+
rule: 'lane-end-block',
|
|
902
|
+
severity: 'warning',
|
|
903
|
+
ids: [id],
|
|
904
|
+
message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
|
|
905
|
+
}));
|
|
906
|
+
return [...checkSpec(fig), ...checkScene(render(fig, opts).scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
|
|
839
907
|
}
|
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/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.5.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.5.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowfig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|