flowfig 0.3.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 +70 -41
- package/dist/browser.d.ts +33 -0
- package/dist/browser.js +201 -0
- package/dist/check.js +104 -4
- package/dist/cli.js +177 -7
- package/dist/diff.js +1 -1
- package/dist/draw.js +20 -3
- package/dist/geometry.d.ts +20 -1
- package/dist/geometry.js +204 -14
- package/dist/gif.d.ts +12 -0
- package/dist/gif.js +245 -0
- package/dist/guide.d.ts +3 -3
- package/dist/guide.js +28 -11
- package/dist/index.d.ts +1 -1
- package/dist/index.js +357 -39
- package/dist/init.d.ts +5 -0
- package/dist/init.js +58 -22
- package/dist/mcp.js +1 -1
- package/dist/model.d.ts +180 -0
- package/dist/model.js +420 -8
- package/dist/open.d.ts +15 -0
- package/dist/open.js +82 -0
- package/dist/page.d.ts +2 -0
- package/dist/page.js +16 -0
- package/dist/picker.d.ts +33 -0
- package/dist/picker.js +148 -0
- package/dist/png.d.ts +7 -0
- package/dist/png.js +70 -0
- package/dist/scene.d.ts +9 -0
- package/dist/svg.d.ts +3 -0
- package/dist/svg.js +334 -76
- 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, toBeat, beatMs, STEP_HOLD_MS, } from './model.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, 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,7 +68,92 @@ 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
|
}
|
|
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. */
|
|
73
|
+
function placeLanes(fig, x, y, s, out) {
|
|
74
|
+
const lanes = fig.layout.children;
|
|
75
|
+
const { cols, starts, gaps, lead, blocks } = s.plan;
|
|
76
|
+
const colW = Array.from({ length: Math.max(-1, ...cols.values()) + 1 }, () => 0);
|
|
77
|
+
for (const lane of lanes)
|
|
78
|
+
for (const b of lane.children)
|
|
79
|
+
colW[cols.get(b.id)] = Math.max(colW[cols.get(b.id)], size(b, s).w);
|
|
80
|
+
const gutter = gutterOf(lanes);
|
|
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
|
+
}));
|
|
99
|
+
const topAt = out.length;
|
|
100
|
+
out.push({ x, y, w: width, h: 0, item: fig.layout });
|
|
101
|
+
let ly = y;
|
|
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
|
+
});
|
|
118
|
+
out[topAt].h = ly - LANE_ROW_GAP - y;
|
|
119
|
+
}
|
|
120
|
+
/** A timeline: the axis strip on top, one band per track, and the bars and milestones at the shared layout x. */
|
|
121
|
+
function placeTimeline(fig, x, y, out) {
|
|
122
|
+
const lanes = fig.layout.children;
|
|
123
|
+
const gutter = gutterOf(lanes);
|
|
124
|
+
const lay = timelineLayout(fig, TL_AXIS_W);
|
|
125
|
+
const width = gutter + TL_AXIS_W + FRAME_SIDE;
|
|
126
|
+
const topAt = out.length;
|
|
127
|
+
out.push({ x, y, w: width, h: 0, item: fig.layout });
|
|
128
|
+
let ly = y + TL_AXIS_H;
|
|
129
|
+
lanes.forEach((lane, track) => {
|
|
130
|
+
const rows = lay.rows[track];
|
|
131
|
+
const h = rows * TL_BAR_H + (rows - 1) * TL_ROW_GAP + LANE_PAD * 2;
|
|
132
|
+
out.push({ x, y: ly, w: width, h, item: lane, lane: true });
|
|
133
|
+
for (const it of lay.items) {
|
|
134
|
+
if (it.track !== track)
|
|
135
|
+
continue;
|
|
136
|
+
const node = lane.children.find((k) => !isGroup(k) && k.id === it.id);
|
|
137
|
+
const top = ly + LANE_PAD + it.row * (TL_BAR_H + TL_ROW_GAP);
|
|
138
|
+
const bh = it.milestone ? it.w : TL_BAR_H;
|
|
139
|
+
out.push({
|
|
140
|
+
x: x + gutter + it.x,
|
|
141
|
+
y: top + (TL_BAR_H - bh) / 2,
|
|
142
|
+
w: it.w,
|
|
143
|
+
h: bh,
|
|
144
|
+
item: node,
|
|
145
|
+
tl: { milestone: it.milestone, labelInside: it.labelInside },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
ly += h + LANE_ROW_GAP;
|
|
149
|
+
});
|
|
150
|
+
out[topAt].h = ly - LANE_ROW_GAP - y;
|
|
151
|
+
}
|
|
97
152
|
function place(item, x, y, s, out) {
|
|
153
|
+
if (item === s.fig.layout && s.fig.timeline && isLanesLayout(s.fig.layout))
|
|
154
|
+
return placeTimeline(s.fig, x, y, out);
|
|
155
|
+
if (item === s.fig.layout && s.fig.lanes && isLanesLayout(s.fig.layout))
|
|
156
|
+
return placeLanes(s.fig, x, y, s, out);
|
|
98
157
|
const { w, h } = size(item, s);
|
|
99
158
|
out.push({ x, y, w, h, item });
|
|
100
159
|
if (!isGroup(item))
|
|
@@ -126,7 +185,10 @@ const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helv
|
|
|
126
185
|
export function render(fig, opts = {}) {
|
|
127
186
|
const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
|
|
128
187
|
const pad = opts.padding ?? 24;
|
|
129
|
-
const
|
|
188
|
+
const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
|
|
189
|
+
// A timeline with no steps of its own walks its dated items in date order.
|
|
190
|
+
const synthetic = tl != null && !fig.steps?.length;
|
|
191
|
+
const steps = synthetic ? timelineBeats(fig) : (fig.steps ?? []);
|
|
130
192
|
const beats = steps.map((s) => s.flow.map(toBeat));
|
|
131
193
|
// Every content a box will ever show, so its card can be sized to the biggest one up front.
|
|
132
194
|
const cards = new Map();
|
|
@@ -144,7 +206,9 @@ export function render(fig, opts = {}) {
|
|
|
144
206
|
return n > 2 ? n * 30 : 0;
|
|
145
207
|
};
|
|
146
208
|
const cardH = new Map();
|
|
147
|
-
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) };
|
|
148
212
|
for (const [id, contents] of cards) {
|
|
149
213
|
const width = CARD_WIDTH; // refined below once the node's own width is known
|
|
150
214
|
const widths = [width];
|
|
@@ -152,7 +216,7 @@ export function render(fig, opts = {}) {
|
|
|
152
216
|
}
|
|
153
217
|
const placed = [];
|
|
154
218
|
place(fig.layout, pad, pad, sizes, placed);
|
|
155
|
-
|
|
219
|
+
let nodes = placed.filter((p) => !isGroup(p.item));
|
|
156
220
|
// A node's own width can differ from CARD_WIDTH (`width` in the spec), so re-measure once placed.
|
|
157
221
|
for (const p of nodes) {
|
|
158
222
|
if (!cards.has(p.item.id))
|
|
@@ -160,25 +224,77 @@ export function render(fig, opts = {}) {
|
|
|
160
224
|
const inner = p.w - 20;
|
|
161
225
|
cardH.set(p.item.id, Math.max(...cards.get(p.item.id).map((c) => layoutCard(c, inner).height), LINE + CARD_PAD * 2));
|
|
162
226
|
}
|
|
163
|
-
placed.length = 0;
|
|
164
|
-
place(fig.layout, pad, pad, sizes, placed);
|
|
165
227
|
const rects = {};
|
|
166
|
-
|
|
167
|
-
if (p.item.id)
|
|
168
|
-
rects[p.item.id] = p;
|
|
169
|
-
const tips = new Set(placed.filter((p) => !isGroup(p.item) && p.item.shape === 'decision').map((p) => p.item.id));
|
|
228
|
+
const tips = new Set(placed.filter((p) => !isGroup(p.item) && (p.item.shape === 'decision' || p.tl?.milestone)).map((p) => p.item.id));
|
|
170
229
|
const ids = fig.edges.map(edgeId);
|
|
171
|
-
|
|
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));
|
|
172
272
|
const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
|
|
173
273
|
// What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
|
|
174
274
|
const fonts = [14, ...(steps.length ? [13.5] : [])];
|
|
175
275
|
const sceneBoxes = nodes.map((p) => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
276
|
+
if (p.tl) {
|
|
277
|
+
// A label inside the bar has the bar as room; a label beside the bar has the space to the next item of its row, or to the band edge.
|
|
278
|
+
const label = str(p.item.label);
|
|
279
|
+
const mine = tl.items.find((i) => i.id === p.item.id);
|
|
280
|
+
const next = tl.items.filter((i) => i.track === mine.track && i.row === mine.row && i.x > mine.x).map((i) => p.x - mine.x + i.x - 8);
|
|
281
|
+
const limit = Math.min(placed[0].x + placed[0].w - FRAME_SIDE, ...next);
|
|
282
|
+
const room = p.tl.labelInside ? p.w - 16 : limit - (p.x + p.w) - 6;
|
|
283
|
+
fonts.push(13);
|
|
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 }] };
|
|
181
285
|
}
|
|
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)
|
|
297
|
+
fonts.push(12);
|
|
182
298
|
for (const c of cards.get(p.item.id) ?? [])
|
|
183
299
|
for (const r of layoutCard(c, p.w - 20).rows) {
|
|
184
300
|
const fontSize = r.row.mono ? 10.5 : 11;
|
|
@@ -225,7 +341,12 @@ export function render(fig, opts = {}) {
|
|
|
225
341
|
const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
|
|
226
342
|
const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
|
|
227
343
|
const litNodes = segs.map((seg, i) => {
|
|
228
|
-
const on = new Set([
|
|
344
|
+
const on = new Set([
|
|
345
|
+
...(steps[seg.si].nodes ?? []),
|
|
346
|
+
...Object.keys(shownAt[i]),
|
|
347
|
+
...(beats[seg.si][seg.bi].light ?? []),
|
|
348
|
+
...(beats[seg.si][seg.bi].focus ?? []),
|
|
349
|
+
]);
|
|
229
350
|
fig.edges.forEach((e, ei) => {
|
|
230
351
|
if (litEdges[i].has(ids[ei]))
|
|
231
352
|
on.add(e.from).add(e.to);
|
|
@@ -277,6 +398,7 @@ export function render(fig, opts = {}) {
|
|
|
277
398
|
* FADE is in timeline seconds, which are wall seconds.
|
|
278
399
|
*/
|
|
279
400
|
const FADE = 0.4;
|
|
401
|
+
const RAMP = 0.4; // the today line moves in 400 ms, in wall seconds like FADE
|
|
280
402
|
// A box tone is a permanent state: a 1 px border and a light tint in the off and trail looks. The active look uses the hop tone, else the box tone.
|
|
281
403
|
const looks = (look, boxTone, hopTone) => {
|
|
282
404
|
const tint = boxTone ? toneTint(boxTone, 'var(--bg)') : 'var(--bg)';
|
|
@@ -295,7 +417,9 @@ export function render(fig, opts = {}) {
|
|
|
295
417
|
if (!litNodes[i].has(id))
|
|
296
418
|
return void pieces.push({ a: s.t0, b: s.t1, look: 'off' });
|
|
297
419
|
const here = hops.filter((h) => h.dest === id && h.si === s.si && h.bi === s.bi);
|
|
298
|
-
|
|
420
|
+
// A focused box turns active at the beat start; in a timeline, when the today line arrives.
|
|
421
|
+
const focused = beats[s.si][s.bi].focus?.includes(id);
|
|
422
|
+
const arrive = Math.min(...here.map((h) => h.t1), focused ? Math.min(s.t0 + (tl ? RAMP : 0), s.t1) : s.t1, s.t1);
|
|
299
423
|
if (arrive > s.t0)
|
|
300
424
|
pieces.push({ a: s.t0, b: arrive, look: 'trail' });
|
|
301
425
|
if (arrive < s.t1)
|
|
@@ -330,18 +454,31 @@ export function render(fig, opts = {}) {
|
|
|
330
454
|
return '';
|
|
331
455
|
const on = item.id ? segs.map((_, i) => litNodes[i].has(item.id)) : [];
|
|
332
456
|
return (`<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="14" fill="var(--surface)" stroke="var(--border)"` +
|
|
333
|
-
cls(anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
|
|
334
|
-
`/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
|
|
457
|
+
cls(p.lane && 'lane', anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
|
|
458
|
+
`/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.lane ? p.y + p.h / 2 + 4 : p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
|
|
335
459
|
}
|
|
336
460
|
const bt = item.tone && TONES[item.tone];
|
|
337
461
|
const stroke = cls(boxAnim(item.id, true, bt));
|
|
462
|
+
if (p.tl) {
|
|
463
|
+
const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
|
|
464
|
+
const stroke0 = bt ?? 'var(--border)';
|
|
465
|
+
const label = str(item.label);
|
|
466
|
+
const inside = p.tl.labelInside;
|
|
467
|
+
const cx = p.x + p.w / 2, cy = p.y + p.h / 2;
|
|
468
|
+
const shape = p.tl.milestone
|
|
469
|
+
? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(cy)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(cy)}" fill="${fill0}" stroke="${stroke0}"${stroke}/>`
|
|
470
|
+
: `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="6" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
|
|
471
|
+
const tx = inside ? p.x + 8 : p.x + p.w + 6;
|
|
472
|
+
return shape + `<text x="${n2(tx)}" y="${n2(cy + 4.5)}" class="bar">${esc(label)}</text>`;
|
|
473
|
+
}
|
|
338
474
|
const rim = item.shape === 'store' ? cls(boxAnim(item.id, false, bt)) : '';
|
|
339
475
|
const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
|
|
340
476
|
const stroke0 = bt ?? 'var(--border)';
|
|
341
477
|
const cx = p.x + p.w / 2;
|
|
342
478
|
const contents = cards.get(item.id);
|
|
343
479
|
const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
|
|
344
|
-
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);
|
|
345
482
|
const shape = item.shape === 'decision'
|
|
346
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}/>`
|
|
347
484
|
: item.shape === 'store'
|
|
@@ -349,8 +486,20 @@ export function render(fig, opts = {}) {
|
|
|
349
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}/>`
|
|
350
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}/>`;
|
|
351
488
|
const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
|
|
352
|
-
const sub =
|
|
353
|
-
|
|
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
|
+
: '';
|
|
494
|
+
// The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
|
|
495
|
+
const dot = bt ?? 'var(--accent)';
|
|
496
|
+
const my = n2(p.y + p.h / 2);
|
|
497
|
+
const mark = item.mark === 'start'
|
|
498
|
+
? `<circle cx="${n2(p.x - 12)}" cy="${my}" r="5" fill="${dot}"/>`
|
|
499
|
+
: item.mark === 'end'
|
|
500
|
+
? `<circle cx="${n2(p.x + p.w + 12)}" cy="${my}" r="6.25" fill="none" stroke="${dot}" stroke-width="1.5"/><circle cx="${n2(p.x + p.w + 12)}" cy="${my}" r="4" fill="${dot}"/>`
|
|
501
|
+
: '';
|
|
502
|
+
return shape + label + sub + mark + (contents ? card(p, cardTop, contents) : '');
|
|
354
503
|
});
|
|
355
504
|
/** The dashed content card: one group per content it will ever hold, each visible on its own beats. */
|
|
356
505
|
function card(p, top, contents) {
|
|
@@ -421,20 +570,30 @@ export function render(fig, opts = {}) {
|
|
|
421
570
|
const lit = cls(toned
|
|
422
571
|
? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
|
|
423
572
|
: anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
|
|
424
|
-
|
|
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}/>`;
|
|
425
579
|
// A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
+
}
|
|
438
597
|
return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
|
|
439
598
|
});
|
|
440
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`
|
|
@@ -459,8 +618,81 @@ export function render(fig, opts = {}) {
|
|
|
459
618
|
`<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">` +
|
|
460
619
|
`<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
|
|
461
620
|
});
|
|
621
|
+
// The timeline axis under the boxes, the fixed today marker and the moving playhead over them.
|
|
622
|
+
let axisSvg = '', todaySvg = '';
|
|
623
|
+
if (tl) {
|
|
624
|
+
const top = placed[0];
|
|
625
|
+
const x0 = top.x + gutterOf(fig.layout.children);
|
|
626
|
+
const ay = top.y + TL_AXIS_H;
|
|
627
|
+
fonts.push(11);
|
|
628
|
+
axisSvg =
|
|
629
|
+
`<path d="M ${n2(x0)} ${n2(ay - 1)} H ${n2(x0 + TL_AXIS_W)}" stroke="var(--border)"/>` +
|
|
630
|
+
tl.ticks
|
|
631
|
+
.map((k) => `<path d="M ${n2(x0 + k.x)} ${n2(ay - 5)} V ${n2(ay - 1)}" stroke="var(--border)"/>` +
|
|
632
|
+
`<text x="${n2(x0 + k.x + 3)}" y="${n2(top.y + 10)}" class="tick">${esc(k.label)}</text>`)
|
|
633
|
+
.join('');
|
|
634
|
+
const bottom = n2(top.y + top.h);
|
|
635
|
+
// The today marker is fixed at the figure's `today`.
|
|
636
|
+
if (tl.today != null) {
|
|
637
|
+
const tx = x0 + tl.today;
|
|
638
|
+
todaySvg =
|
|
639
|
+
`<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"/>` +
|
|
640
|
+
`<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
|
|
641
|
+
}
|
|
642
|
+
// The playhead moves to the start of each beat's item, then to the last date in the step hold.
|
|
643
|
+
if (tl.last != null && segs.length) {
|
|
644
|
+
const home = tl.last;
|
|
645
|
+
const startOf = new Map(tl.items.map((i) => [i.id, i.x + (i.milestone ? i.w / 2 : 0)]));
|
|
646
|
+
const dateOf = new Map(tl.items.map((i) => [i.id, i.date]));
|
|
647
|
+
const hold = STEP_HOLD_MS / 1000 / BASE_RATE;
|
|
648
|
+
const pts = [[0, home]];
|
|
649
|
+
const dates = []; // the label date in each time span
|
|
650
|
+
let at = home;
|
|
651
|
+
segs.forEach((s, i) => {
|
|
652
|
+
const item = playheadItem(tl.items, beats[s.si], s.bi);
|
|
653
|
+
const to = item ? startOf.get(item.id) : home;
|
|
654
|
+
const date = item ? dateOf.get(item.id) : tl.lastDate;
|
|
655
|
+
const last = i === segs.length - 1 || segs[i + 1].si !== s.si;
|
|
656
|
+
const end = last ? s.t1 - hold : s.t1; // the step hold starts here
|
|
657
|
+
pts.push([s.t0, at], [Math.min(s.t0 + RAMP, end), to], [end, to]);
|
|
658
|
+
const [la, lb] = labelSpan(s.t0, end, RAMP);
|
|
659
|
+
if (la < lb)
|
|
660
|
+
dates.push({ a: la, b: lb, d: date, x: to });
|
|
661
|
+
at = to;
|
|
662
|
+
if (last) {
|
|
663
|
+
pts.push([Math.min(end + RAMP, s.t1), home], [s.t1, home]);
|
|
664
|
+
const [ha, hb] = labelSpan(end, s.t1, RAMP);
|
|
665
|
+
if (ha < hb)
|
|
666
|
+
dates.push({ a: ha, b: hb, d: tl.lastDate, x: home });
|
|
667
|
+
at = home;
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
const name = `a${seen.size}`;
|
|
671
|
+
seen.set('playhead', name);
|
|
672
|
+
const kf = pts.map(([t, x]) => `${pct(t / total)} { transform: translateX(${n2(x - home)}px) }`).join(' ');
|
|
673
|
+
css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
|
|
674
|
+
const lx = x0 + home;
|
|
675
|
+
const labels = [...new Set(dates.map((x) => x.d))].map((d) => {
|
|
676
|
+
const on = dates.map((x) => x.d === d);
|
|
677
|
+
const key = `d${d}${on.map((x) => (x ? 1 : 0)).join('')}`;
|
|
678
|
+
if (!seen.has(key)) {
|
|
679
|
+
const nm = `a${seen.size}`;
|
|
680
|
+
seen.set(key, nm);
|
|
681
|
+
// Each span ends with a hidden frame: the label stays off while the line moves.
|
|
682
|
+
const fr = dates
|
|
683
|
+
.map((x) => `${pct(x.a / total)},${pct(x.b / total - 0.0001)} { opacity: ${x.d === d ? 1 : 0} } ${pct(x.b / total)} { opacity: 0 }`)
|
|
684
|
+
.join(' ');
|
|
685
|
+
css.push(`@keyframes ${nm} { ${fr} }\n.${nm} { animation: ${nm} ${n2(total)}s infinite step-end; }`);
|
|
686
|
+
}
|
|
687
|
+
// A label that would meet "today" moves to the tick row.
|
|
688
|
+
const raised = dateLabelRaised(dates.find((x) => x.d === d).x, tl.today, textWidth(d, 11), textWidth('today', 11));
|
|
689
|
+
return `<text x="${n2(lx + 3)}" y="${n2(top.y + (raised ? 10 : 22))}" opacity="0"${cls('today', seen.get(key))}>${esc(d)}</text>`;
|
|
690
|
+
});
|
|
691
|
+
todaySvg += `<g${cls(name)}><path d="M ${n2(lx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-width="1.5"/>${labels.join('')}</g>`;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
462
694
|
const bounds = placed[0];
|
|
463
|
-
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
|
|
464
696
|
const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
|
|
465
697
|
const mapW = Math.max(bounds.w + pad * 2, 560);
|
|
466
698
|
// `rail: 'only'` drops the map, but only when the rail has a hop to draw; a figure never renders empty.
|
|
@@ -476,7 +708,7 @@ export function render(fig, opts = {}) {
|
|
|
476
708
|
const shift = (W - (bounds.w + pad * 2)) / 2;
|
|
477
709
|
const railX = rail ? (W - rail.width) / 2 : 0;
|
|
478
710
|
// The step label and its narration, both switching with the beats.
|
|
479
|
-
const labels = rail
|
|
711
|
+
const labels = rail || synthetic
|
|
480
712
|
? []
|
|
481
713
|
: steps.map((_, si) => {
|
|
482
714
|
const on = segs.map((s) => s.si === si);
|
|
@@ -579,6 +811,9 @@ svg { ${vars(t0, '#eef5fd')} }
|
|
|
579
811
|
.row { fill: var(--fg); font-size: 11px; }
|
|
580
812
|
.row.mono { font-size: 10.5px; font-family: ui-monospace, Menlo, monospace; }
|
|
581
813
|
.muted { fill: var(--muted); }
|
|
814
|
+
.bar { fill: var(--fg); font-size: 13px; font-weight: 500; }
|
|
815
|
+
.tick { fill: var(--muted); font-size: 11px; }
|
|
816
|
+
.today { fill: var(--accent); font-size: 11px; font-weight: 600; }
|
|
582
817
|
.tag { font-size: 9px; font-weight: 600; letter-spacing: .03em; text-anchor: middle; }
|
|
583
818
|
.mark { fill: var(--accent); font-size: 11px; font-weight: 600; text-anchor: end; }
|
|
584
819
|
.chip { fill: ${ON_ACCENT}; font-size: 11.5px; text-anchor: middle; }
|
|
@@ -595,9 +830,13 @@ ${css.join('\n')}
|
|
|
595
830
|
${only
|
|
596
831
|
? ''
|
|
597
832
|
: `<g transform="translate(${n2(shift)} ${arcs ? 44 : 0})">
|
|
598
|
-
${
|
|
599
|
-
${
|
|
833
|
+
${axisSvg}
|
|
834
|
+
${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}
|
|
835
|
+
${tl ? edgeSvg.join('\n') : ''}
|
|
836
|
+
${boxes.filter((b, i) => b && !(tl && isGroup(placed[i].item))).join('\n')}
|
|
837
|
+
${tl ? '' : edgeSvg.join('\n')}
|
|
600
838
|
${packets.join('\n')}
|
|
839
|
+
${todaySvg}
|
|
601
840
|
</g>
|
|
602
841
|
`}${railSvg}
|
|
603
842
|
${labels.join('\n')}
|
|
@@ -610,9 +849,12 @@ ${said.join('\n')}
|
|
|
610
849
|
width: n2(W),
|
|
611
850
|
boxes: only ? [] : sceneBoxes,
|
|
612
851
|
edges: [
|
|
613
|
-
...(only ? [] : routed).
|
|
852
|
+
...(only ? [] : routed).flatMap((r) => {
|
|
614
853
|
const e = fig.edges[ids.indexOf(r.id)];
|
|
615
|
-
|
|
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 }) }];
|
|
616
858
|
}),
|
|
617
859
|
// Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
|
|
618
860
|
...(rail?.rows ?? []).flatMap((row, i) => {
|
|
@@ -636,14 +878,30 @@ ${said.join('\n')}
|
|
|
636
878
|
}),
|
|
637
879
|
],
|
|
638
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
|
+
}),
|
|
639
884
|
};
|
|
640
885
|
return { svg, scene };
|
|
641
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
|
+
}
|
|
642
894
|
/** One self-contained animated SVG string for the figure. Needs no React and no browser. */
|
|
643
895
|
export function toSvg(fig, opts = {}) {
|
|
644
896
|
return render(fig, opts).svg;
|
|
645
897
|
}
|
|
646
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. */
|
|
647
899
|
export function check(fig, opts = {}) {
|
|
648
|
-
|
|
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 })];
|
|
649
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": {
|