flowfig 0.2.0 → 0.4.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 +80 -6
- package/dist/browser.d.ts +33 -0
- package/dist/browser.js +201 -0
- package/dist/check.js +82 -4
- package/dist/cli.js +180 -5
- package/dist/diff.js +2 -2
- package/dist/draw.d.ts +12 -0
- package/dist/draw.js +195 -0
- package/dist/geometry.d.ts +4 -1
- package/dist/geometry.js +39 -12
- package/dist/gif.d.ts +12 -0
- package/dist/gif.js +245 -0
- package/dist/guide.d.ts +3 -3
- package/dist/guide.js +35 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +280 -35
- package/dist/init.d.ts +5 -0
- package/dist/init.js +58 -22
- package/dist/model.d.ts +107 -3
- package/dist/model.js +185 -7
- 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/rail.d.ts +2 -1
- package/dist/rail.js +15 -1
- package/dist/scene.d.ts +2 -0
- package/dist/source.d.ts +2 -0
- package/dist/source.js +5 -0
- package/dist/svg.js +313 -40
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/svg.js
CHANGED
|
@@ -12,18 +12,20 @@ import { textWidth } 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, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, toBeat, } 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, laneColumns, LANE_GAP, LANE_PAD, LANE_ROW_GAP, 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
18
|
const LABEL_LINE = 18, SUB_LINE = 15, NODE_MIN_W = 100, NODE_MAX_W = 190;
|
|
19
19
|
const FRAME_TOP = 37, FRAME_SIDE = 18, FRAME_BOTTOM = 18;
|
|
20
|
-
// The card-on fills are fixed colors that approximate the player's 8% accent tint
|
|
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};`;
|
|
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
|
+
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. */
|
|
23
23
|
const content = (c) => (isRows(c) ? c : str(c));
|
|
24
24
|
const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c]);
|
|
25
25
|
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
|
+
const n4 = (v) => Math.round(v * 10000) / 10000;
|
|
28
|
+
const pct = (v) => Math.round(v * 10000) / 100 + '%';
|
|
27
29
|
/** Break a string into lines that fit `width`, keeping the newlines it already has. */
|
|
28
30
|
function wrap(s, width, fontSize, mono = false) {
|
|
29
31
|
const out = [];
|
|
@@ -92,7 +94,70 @@ function size(item, s) {
|
|
|
92
94
|
const inner = item.direction === 'column' ? { w: across, h: along } : { w: along, h: across };
|
|
93
95
|
return item.label != null ? { w: inner.w + FRAME_SIDE * 2, h: inner.h + FRAME_TOP + FRAME_BOTTOM } : inner;
|
|
94
96
|
}
|
|
97
|
+
/** Swimlanes: the bands span the width, the label sits in a left gutter, and a box sits at its time column. */
|
|
98
|
+
function placeLanes(fig, x, y, s, out) {
|
|
99
|
+
const lanes = fig.layout.children;
|
|
100
|
+
const cols = laneColumns(fig);
|
|
101
|
+
const colW = Array.from({ length: Math.max(-1, ...cols.values()) + 1 }, () => 0);
|
|
102
|
+
for (const lane of lanes)
|
|
103
|
+
for (const b of lane.children)
|
|
104
|
+
colW[cols.get(b.id)] = Math.max(colW[cols.get(b.id)], size(b, s).w);
|
|
105
|
+
const gutter = gutterOf(lanes);
|
|
106
|
+
const colX = colW.map((_, c) => gutter + colW.slice(0, c).reduce((a, w) => a + w + LANE_GAP, 0));
|
|
107
|
+
const width = gutter + colW.reduce((a, w) => a + w, 0) + LANE_GAP * Math.max(0, colW.length - 1) + FRAME_SIDE;
|
|
108
|
+
const topAt = out.length;
|
|
109
|
+
out.push({ x, y, w: width, h: 0, item: fig.layout });
|
|
110
|
+
let ly = y;
|
|
111
|
+
for (const lane of lanes) {
|
|
112
|
+
const kids = lane.children.map((b) => ({ b, ...size(b, s) }));
|
|
113
|
+
const inner = Math.max(LABEL_LINE + 20, ...kids.map((k) => k.h));
|
|
114
|
+
const h = inner + LANE_PAD * 2;
|
|
115
|
+
out.push({ x, y: ly, w: width, h, item: lane, lane: true });
|
|
116
|
+
for (const k of kids)
|
|
117
|
+
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 });
|
|
118
|
+
ly += h + LANE_ROW_GAP;
|
|
119
|
+
}
|
|
120
|
+
out[topAt].h = ly - LANE_ROW_GAP - y;
|
|
121
|
+
}
|
|
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
|
+
/** A timeline: the axis strip on top, one band per track, and the bars and milestones at the shared layout x. */
|
|
125
|
+
function placeTimeline(fig, x, y, out) {
|
|
126
|
+
const lanes = fig.layout.children;
|
|
127
|
+
const gutter = gutterOf(lanes);
|
|
128
|
+
const lay = timelineLayout(fig, TL_AXIS_W);
|
|
129
|
+
const width = gutter + TL_AXIS_W + FRAME_SIDE;
|
|
130
|
+
const topAt = out.length;
|
|
131
|
+
out.push({ x, y, w: width, h: 0, item: fig.layout });
|
|
132
|
+
let ly = y + TL_AXIS_H;
|
|
133
|
+
lanes.forEach((lane, track) => {
|
|
134
|
+
const rows = lay.rows[track];
|
|
135
|
+
const h = rows * TL_BAR_H + (rows - 1) * TL_ROW_GAP + LANE_PAD * 2;
|
|
136
|
+
out.push({ x, y: ly, w: width, h, item: lane, lane: true });
|
|
137
|
+
for (const it of lay.items) {
|
|
138
|
+
if (it.track !== track)
|
|
139
|
+
continue;
|
|
140
|
+
const node = lane.children.find((k) => !isGroup(k) && k.id === it.id);
|
|
141
|
+
const top = ly + LANE_PAD + it.row * (TL_BAR_H + TL_ROW_GAP);
|
|
142
|
+
const bh = it.milestone ? it.w : TL_BAR_H;
|
|
143
|
+
out.push({
|
|
144
|
+
x: x + gutter + it.x,
|
|
145
|
+
y: top + (TL_BAR_H - bh) / 2,
|
|
146
|
+
w: it.w,
|
|
147
|
+
h: bh,
|
|
148
|
+
item: node,
|
|
149
|
+
tl: { milestone: it.milestone, labelInside: it.labelInside },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
ly += h + LANE_ROW_GAP;
|
|
153
|
+
});
|
|
154
|
+
out[topAt].h = ly - LANE_ROW_GAP - y;
|
|
155
|
+
}
|
|
95
156
|
function place(item, x, y, s, out) {
|
|
157
|
+
if (item === s.fig.layout && s.fig.timeline && isLanesLayout(s.fig.layout))
|
|
158
|
+
return placeTimeline(s.fig, x, y, out);
|
|
159
|
+
if (item === s.fig.layout && s.fig.lanes && isLanesLayout(s.fig.layout))
|
|
160
|
+
return placeLanes(s.fig, x, y, s, out);
|
|
96
161
|
const { w, h } = size(item, s);
|
|
97
162
|
out.push({ x, y, w, h, item });
|
|
98
163
|
if (!isGroup(item))
|
|
@@ -124,7 +189,10 @@ const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helv
|
|
|
124
189
|
export function render(fig, opts = {}) {
|
|
125
190
|
const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
|
|
126
191
|
const pad = opts.padding ?? 24;
|
|
127
|
-
const
|
|
192
|
+
const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
|
|
193
|
+
// A timeline with no steps of its own walks its dated items in date order.
|
|
194
|
+
const synthetic = tl != null && !fig.steps?.length;
|
|
195
|
+
const steps = synthetic ? timelineBeats(fig) : (fig.steps ?? []);
|
|
128
196
|
const beats = steps.map((s) => s.flow.map(toBeat));
|
|
129
197
|
// Every content a box will ever show, so its card can be sized to the biggest one up front.
|
|
130
198
|
const cards = new Map();
|
|
@@ -142,7 +210,7 @@ export function render(fig, opts = {}) {
|
|
|
142
210
|
return n > 2 ? n * 30 : 0;
|
|
143
211
|
};
|
|
144
212
|
const cardH = new Map();
|
|
145
|
-
const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges) };
|
|
213
|
+
const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges), fig };
|
|
146
214
|
for (const [id, contents] of cards) {
|
|
147
215
|
const width = CARD_WIDTH; // refined below once the node's own width is known
|
|
148
216
|
const widths = [width];
|
|
@@ -164,13 +232,29 @@ export function render(fig, opts = {}) {
|
|
|
164
232
|
for (const p of placed)
|
|
165
233
|
if (p.item.id)
|
|
166
234
|
rects[p.item.id] = p;
|
|
167
|
-
const tips = new Set(placed.filter((p) => !isGroup(p.item) && p.item.shape === 'decision').map((p) => p.item.id));
|
|
235
|
+
const tips = new Set(placed.filter((p) => !isGroup(p.item) && (p.item.shape === 'decision' || p.tl?.milestone)).map((p) => p.item.id));
|
|
168
236
|
const ids = fig.edges.map(edgeId);
|
|
169
|
-
const routed = route(fig.edges.map((e, i) => ({
|
|
237
|
+
const routed = route(fig.edges.map((e, i) => ({
|
|
238
|
+
id: ids[i],
|
|
239
|
+
from: e.from,
|
|
240
|
+
to: e.to,
|
|
241
|
+
around: e.around,
|
|
242
|
+
...(tl && { sides: ['r', 'l'], elbow: true }),
|
|
243
|
+
})), rects, tips, placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))));
|
|
170
244
|
const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
|
|
171
245
|
// What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
|
|
172
246
|
const fonts = [14, ...(steps.length ? [13.5] : [])];
|
|
173
247
|
const sceneBoxes = nodes.map((p) => {
|
|
248
|
+
if (p.tl) {
|
|
249
|
+
// 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.
|
|
250
|
+
const label = str(p.item.label);
|
|
251
|
+
const mine = tl.items.find((i) => i.id === p.item.id);
|
|
252
|
+
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);
|
|
253
|
+
const limit = Math.min(placed[0].x + placed[0].w - FRAME_SIDE, ...next);
|
|
254
|
+
const room = p.tl.labelInside ? p.w - 16 : limit - (p.x + p.w) - 6;
|
|
255
|
+
fonts.push(13);
|
|
256
|
+
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
|
+
}
|
|
174
258
|
const room = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
|
|
175
259
|
const texts = [{ text: str(p.item.label), fontSize: 14, room }];
|
|
176
260
|
if (p.item.sub) {
|
|
@@ -191,17 +275,31 @@ export function render(fig, opts = {}) {
|
|
|
191
275
|
// The timeline: every beat of every step, in order, with the player's hold at the end of each step.
|
|
192
276
|
const segs = [];
|
|
193
277
|
const hops = [];
|
|
278
|
+
const edgeOf = Object.fromEntries(fig.edges.map((e, i) => [ids[i], e]));
|
|
194
279
|
let t = 0;
|
|
195
280
|
beats.forEach((stepBeats, si) => {
|
|
196
281
|
stepBeats.forEach((b, bi) => {
|
|
197
|
-
const dur = (b.ms ?? fig.speed ?? 900) / 1000 / BASE_RATE;
|
|
198
282
|
const last = bi === stepBeats.length - 1;
|
|
283
|
+
const dur = beatMs(b, opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
|
|
284
|
+
const hold = last ? STEP_HOLD_MS / 1000 / BASE_RATE : 0;
|
|
199
285
|
b.hops.forEach((h, lane) => {
|
|
200
286
|
if (byId[h.edge])
|
|
201
|
-
hops.push({
|
|
287
|
+
hops.push({
|
|
288
|
+
si,
|
|
289
|
+
bi,
|
|
290
|
+
lane,
|
|
291
|
+
edge: h.edge,
|
|
292
|
+
dest: h.back ? edgeOf[h.edge].from : edgeOf[h.edge].to,
|
|
293
|
+
back: h.back,
|
|
294
|
+
data: h.data,
|
|
295
|
+
tone: h.tone && TONES[h.tone],
|
|
296
|
+
t0: t,
|
|
297
|
+
t1: t + speed,
|
|
298
|
+
tEnd: t + dur,
|
|
299
|
+
});
|
|
202
300
|
});
|
|
203
|
-
segs.push({ t0: t, t1: t + dur +
|
|
204
|
-
t += dur +
|
|
301
|
+
segs.push({ t0: t, t1: t + dur + hold, si, bi });
|
|
302
|
+
t += dur + hold;
|
|
205
303
|
});
|
|
206
304
|
});
|
|
207
305
|
const total = t || 1;
|
|
@@ -209,7 +307,12 @@ export function render(fig, opts = {}) {
|
|
|
209
307
|
const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
|
|
210
308
|
const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
|
|
211
309
|
const litNodes = segs.map((seg, i) => {
|
|
212
|
-
const on = new Set([
|
|
310
|
+
const on = new Set([
|
|
311
|
+
...(steps[seg.si].nodes ?? []),
|
|
312
|
+
...Object.keys(shownAt[i]),
|
|
313
|
+
...(beats[seg.si][seg.bi].light ?? []),
|
|
314
|
+
...(beats[seg.si][seg.bi].focus ?? []),
|
|
315
|
+
]);
|
|
213
316
|
fig.edges.forEach((e, ei) => {
|
|
214
317
|
if (litEdges[i].has(ids[ei]))
|
|
215
318
|
on.add(e.from).add(e.to);
|
|
@@ -242,8 +345,8 @@ export function render(fig, opts = {}) {
|
|
|
242
345
|
return list ? ` class="${list}"` : '';
|
|
243
346
|
};
|
|
244
347
|
/** Like `anim`, for a property that takes a different value in each segment (a row that moves as phases fold). */
|
|
245
|
-
const frames = (values, prefix) => {
|
|
246
|
-
if (!segs.length || values.every((v) => v ===
|
|
348
|
+
const frames = (values, prefix, base = values[0]) => {
|
|
349
|
+
if (!segs.length || values.every((v) => v === base))
|
|
247
350
|
return '';
|
|
248
351
|
const key = prefix + values.join('|');
|
|
249
352
|
if (!seen.has(key)) {
|
|
@@ -254,6 +357,62 @@ export function render(fig, opts = {}) {
|
|
|
254
357
|
}
|
|
255
358
|
return seen.get(key);
|
|
256
359
|
};
|
|
360
|
+
/**
|
|
361
|
+
* The look of one box over the loop: off, trail (visited) or active (the packet arrived, until the segment ends).
|
|
362
|
+
* A segment ends at the beat end, or after the step hold for the last beat.
|
|
363
|
+
* Each frame holds one look. The gap before the frame that follows an active frame gives the FADE.
|
|
364
|
+
* FADE is in timeline seconds, which are wall seconds.
|
|
365
|
+
*/
|
|
366
|
+
const FADE = 0.4;
|
|
367
|
+
const RAMP = 0.4; // the today line moves in 400 ms, in wall seconds like FADE
|
|
368
|
+
// 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.
|
|
369
|
+
const looks = (look, boxTone, hopTone) => {
|
|
370
|
+
const tint = boxTone ? toneTint(boxTone, 'var(--bg)') : 'var(--bg)';
|
|
371
|
+
if (look === 'off')
|
|
372
|
+
return [tint, boxTone ?? 'var(--border)', 1, 'drop-shadow(0 0 0 transparent)'];
|
|
373
|
+
if (look === 'trail')
|
|
374
|
+
return [tint, boxTone ?? 'var(--accent)', 1, 'drop-shadow(0 0 0 transparent)'];
|
|
375
|
+
const c = hopTone ?? boxTone;
|
|
376
|
+
return c
|
|
377
|
+
? [toneTint(c, 'var(--surface)', 10), c, 2, `drop-shadow(0 0 4px ${c})`]
|
|
378
|
+
: ['var(--tint)', 'var(--accent)', 2, 'drop-shadow(0 0 4px var(--accent))'];
|
|
379
|
+
};
|
|
380
|
+
const boxAnim = (id, shape, boxTone) => {
|
|
381
|
+
const pieces = [];
|
|
382
|
+
segs.forEach((s, i) => {
|
|
383
|
+
if (!litNodes[i].has(id))
|
|
384
|
+
return void pieces.push({ a: s.t0, b: s.t1, look: 'off' });
|
|
385
|
+
const here = hops.filter((h) => h.dest === id && h.si === s.si && h.bi === s.bi);
|
|
386
|
+
// A focused box turns active at the beat start; in a timeline, when the today line arrives.
|
|
387
|
+
const focused = beats[s.si][s.bi].focus?.includes(id);
|
|
388
|
+
const arrive = Math.min(...here.map((h) => h.t1), focused ? Math.min(s.t0 + (tl ? RAMP : 0), s.t1) : s.t1, s.t1);
|
|
389
|
+
if (arrive > s.t0)
|
|
390
|
+
pieces.push({ a: s.t0, b: arrive, look: 'trail' });
|
|
391
|
+
if (arrive < s.t1)
|
|
392
|
+
pieces.push({ a: arrive, b: s.t1, look: 'active', hop: here.find((h) => h.tone)?.tone });
|
|
393
|
+
});
|
|
394
|
+
if (!pieces.length || pieces.every((q) => q.look === 'off'))
|
|
395
|
+
return '';
|
|
396
|
+
// The last frame is active: the fade ends at 100 %, so the loop wraps with no jump.
|
|
397
|
+
const end = pieces.at(-1);
|
|
398
|
+
if (end.look === 'active') {
|
|
399
|
+
end.b -= FADE;
|
|
400
|
+
pieces.push({ a: end.b, b: end.b + FADE, look: pieces[0].look, hop: pieces[0].hop, ramp: FADE - 0.0002 * total });
|
|
401
|
+
}
|
|
402
|
+
const kf = pieces.map((q, k) => {
|
|
403
|
+
const ramp = q.ramp ?? (q.look !== 'active' && pieces[k - 1]?.look === 'active' ? Math.min(FADE, (q.b - q.a) / 2) : 0);
|
|
404
|
+
const [fill, stroke, width, filter] = looks(q.look, boxTone, q.hop);
|
|
405
|
+
const css = `${shape ? `fill: ${fill}; ` : ''}stroke: ${stroke}; stroke-width: ${width}${shape ? `; filter: ${filter}` : ''}`;
|
|
406
|
+
return `${pct((q.a + ramp) / total)},${pct(q.b / total - 0.0001)} { ${css} }`;
|
|
407
|
+
});
|
|
408
|
+
const key = (shape ? 'B' : 'R') + kf.join(' ');
|
|
409
|
+
if (!seen.has(key)) {
|
|
410
|
+
const name = `a${seen.size}`;
|
|
411
|
+
seen.set(key, name);
|
|
412
|
+
css.push(`@keyframes ${name} { ${kf.join(' ')} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
|
|
413
|
+
}
|
|
414
|
+
return seen.get(key);
|
|
415
|
+
};
|
|
257
416
|
const boxes = placed.map((p) => {
|
|
258
417
|
const { item } = p;
|
|
259
418
|
if (isGroup(item)) {
|
|
@@ -261,24 +420,47 @@ export function render(fig, opts = {}) {
|
|
|
261
420
|
return '';
|
|
262
421
|
const on = item.id ? segs.map((_, i) => litNodes[i].has(item.id)) : [];
|
|
263
422
|
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)"` +
|
|
264
|
-
cls(anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
|
|
265
|
-
`/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
|
|
423
|
+
cls(p.lane && 'lane', anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
|
|
424
|
+
`/><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>`);
|
|
266
425
|
}
|
|
267
|
-
const
|
|
268
|
-
const stroke = cls(
|
|
426
|
+
const bt = item.tone && TONES[item.tone];
|
|
427
|
+
const stroke = cls(boxAnim(item.id, true, bt));
|
|
428
|
+
if (p.tl) {
|
|
429
|
+
const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
|
|
430
|
+
const stroke0 = bt ?? 'var(--border)';
|
|
431
|
+
const label = str(item.label);
|
|
432
|
+
const inside = p.tl.labelInside;
|
|
433
|
+
const cx = p.x + p.w / 2, cy = p.y + p.h / 2;
|
|
434
|
+
const shape = p.tl.milestone
|
|
435
|
+
? `<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}/>`
|
|
436
|
+
: `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="6" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
|
|
437
|
+
const tx = inside ? p.x + 8 : p.x + p.w + 6;
|
|
438
|
+
return shape + `<text x="${n2(tx)}" y="${n2(cy + 4.5)}" class="bar">${esc(label)}</text>`;
|
|
439
|
+
}
|
|
440
|
+
const rim = item.shape === 'store' ? cls(boxAnim(item.id, false, bt)) : '';
|
|
441
|
+
const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
|
|
442
|
+
const stroke0 = bt ?? 'var(--border)';
|
|
269
443
|
const cx = p.x + p.w / 2;
|
|
270
444
|
const contents = cards.get(item.id);
|
|
271
445
|
const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
|
|
272
446
|
const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (item.sub ? -2 : 5);
|
|
273
447
|
const shape = item.shape === 'decision'
|
|
274
|
-
? `<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="
|
|
448
|
+
? `<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}/>`
|
|
275
449
|
: item.shape === 'store'
|
|
276
|
-
? `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(p.w)} 0 v ${n2(p.h - 24)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(-p.w)} 0 z" fill="
|
|
277
|
-
`<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="
|
|
278
|
-
: `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="
|
|
450
|
+
? `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(p.w)} 0 v ${n2(p.h - 24)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(-p.w)} 0 z" fill="${fill0}" stroke="${stroke0}"${stroke}/>` +
|
|
451
|
+
`<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
|
+
: `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
|
|
279
453
|
const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
|
|
280
454
|
const sub = item.sub ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>` : '';
|
|
281
|
-
|
|
455
|
+
// The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
|
|
456
|
+
const dot = bt ?? 'var(--accent)';
|
|
457
|
+
const my = n2(p.y + p.h / 2);
|
|
458
|
+
const mark = item.mark === 'start'
|
|
459
|
+
? `<circle cx="${n2(p.x - 12)}" cy="${my}" r="5" fill="${dot}"/>`
|
|
460
|
+
: item.mark === 'end'
|
|
461
|
+
? `<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}"/>`
|
|
462
|
+
: '';
|
|
463
|
+
return shape + label + sub + mark + (contents ? card(p, cardTop, contents) : '');
|
|
282
464
|
});
|
|
283
465
|
/** The dashed content card: one group per content it will ever hold, each visible on its own beats. */
|
|
284
466
|
function card(p, top, contents) {
|
|
@@ -340,27 +522,37 @@ export function render(fig, opts = {}) {
|
|
|
340
522
|
const on = segs.map((_, i) => litEdges[i].has(r.id));
|
|
341
523
|
const hidden = e.quiet && !on.every(Boolean);
|
|
342
524
|
const shown = hidden ? cls(anim(on, 'opacity: 1', 'opacity: 0', 'q')) : '';
|
|
343
|
-
|
|
525
|
+
// A toned hop colors its edge for its own beat only; the trail after it is the accent.
|
|
526
|
+
const tone = segs.map(({ si, bi }) => (beats[si][bi].hops.find((h) => h.edge === r.id && h.tone)?.tone ?? undefined));
|
|
527
|
+
const col = tone.map((x) => (x ? TONES[x] : 'var(--accent)'));
|
|
528
|
+
const off = `stroke: var(--muted); stroke-width: ${EDGE_OFF}`;
|
|
529
|
+
// With no tone the map and the rail share one keyframe (same key); a toned edge needs its own values.
|
|
530
|
+
const toned = tone.some(Boolean);
|
|
531
|
+
const lit = cls(toned
|
|
532
|
+
? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
|
|
533
|
+
: anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
|
|
344
534
|
const path = `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
|
|
345
535
|
// A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
|
|
346
|
-
const label = e.label == null
|
|
536
|
+
const label = e.label == null || tl
|
|
347
537
|
? ''
|
|
348
538
|
: (() => {
|
|
349
539
|
const lw = labelPillW(str(e.label));
|
|
350
540
|
labelRects[r.id] = { x: r.mid.x - lw / 2, y: r.mid.y - 9, w: lw, h: 18 };
|
|
351
541
|
fonts.push(11);
|
|
352
542
|
return (`<rect x="${n2(r.mid.x - lw / 2)}" y="${n2(r.mid.y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
|
|
353
|
-
cls(
|
|
543
|
+
cls(toned
|
|
544
|
+
? 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)')
|
|
545
|
+
: anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
|
|
354
546
|
`/><text x="${n2(r.mid.x)}" y="${n2(r.mid.y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(str(e.label))}</text>`);
|
|
355
547
|
})();
|
|
356
548
|
return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
|
|
357
549
|
});
|
|
358
|
-
// A packet per hop: it waits offstage, crosses its edge
|
|
550
|
+
// 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`
|
|
359
551
|
// rides above it in a chip, which is how the figure says what is moving.
|
|
360
552
|
const packets = hops.map((h, i) => {
|
|
361
|
-
const t0 = h.t0 / total, t1 = h.t1 / total;
|
|
553
|
+
const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
|
|
362
554
|
const name = `p${i}`;
|
|
363
|
-
css.push(`@keyframes ${name} { 0%,${pct(t0)} { opacity: 0 } ${pct(t0 + 0.0001)},${pct(
|
|
555
|
+
css.push(`@keyframes ${name} { 0%,${pct(t0)} { opacity: 0 } ${pct(t0 + 0.0001)},${pct(tEnd - 0.0001)} { opacity: 1 } ${pct(tEnd)},100% { opacity: 0 } }\n` +
|
|
364
556
|
`.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
|
|
365
557
|
const chip = (() => {
|
|
366
558
|
const label = str(h.data);
|
|
@@ -370,13 +562,86 @@ export function render(fig, opts = {}) {
|
|
|
370
562
|
const lines = wrap(label, 210, 11.5);
|
|
371
563
|
const w = Math.max(...lines.map((l) => textWidth(l, 11.5))) + 18;
|
|
372
564
|
const boxH = lines.length * 15 + 8;
|
|
373
|
-
return (`<rect x="${n2(-w / 2)}" y="${n2(-boxH - 12)}" width="${n2(w)}" height="${n2(boxH)}" rx="8" fill="var(--accent)"/>` +
|
|
565
|
+
return (`<rect x="${n2(-w / 2)}" y="${n2(-boxH - 12)}" width="${n2(w)}" height="${n2(boxH)}" rx="8" fill="${h.tone ? toneFill(h.tone) : 'var(--accent)'}"/>` +
|
|
374
566
|
lines.map((l, li) => `<text x="0" y="${n2(-boxH - 12 + 15 * li + 15)}" class="chip">${esc(l)}</text>`).join(''));
|
|
375
567
|
})();
|
|
376
|
-
return (`<g${cls(name)} opacity="0"><circle r="10" fill="var(--accent)" opacity="0.2"/><circle r="4.5" fill="var(--accent)"/>${chip}` +
|
|
377
|
-
`<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${
|
|
568
|
+
return (`<g${cls(name)} opacity="0"><circle r="10" fill="${h.tone ?? 'var(--accent)'}" opacity="0.2"/><circle r="4.5" fill="${h.tone ?? 'var(--accent)'}"/>${chip}` +
|
|
569
|
+
`<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">` +
|
|
378
570
|
`<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
|
|
379
571
|
});
|
|
572
|
+
// The timeline axis under the boxes, the fixed today marker and the moving playhead over them.
|
|
573
|
+
let axisSvg = '', todaySvg = '';
|
|
574
|
+
if (tl) {
|
|
575
|
+
const top = placed[0];
|
|
576
|
+
const x0 = top.x + gutterOf(fig.layout.children);
|
|
577
|
+
const ay = top.y + TL_AXIS_H;
|
|
578
|
+
fonts.push(11);
|
|
579
|
+
axisSvg =
|
|
580
|
+
`<path d="M ${n2(x0)} ${n2(ay - 1)} H ${n2(x0 + TL_AXIS_W)}" stroke="var(--border)"/>` +
|
|
581
|
+
tl.ticks
|
|
582
|
+
.map((k) => `<path d="M ${n2(x0 + k.x)} ${n2(ay - 5)} V ${n2(ay - 1)}" stroke="var(--border)"/>` +
|
|
583
|
+
`<text x="${n2(x0 + k.x + 3)}" y="${n2(top.y + 10)}" class="tick">${esc(k.label)}</text>`)
|
|
584
|
+
.join('');
|
|
585
|
+
const bottom = n2(top.y + top.h);
|
|
586
|
+
// The today marker is fixed at the figure's `today`.
|
|
587
|
+
if (tl.today != null) {
|
|
588
|
+
const tx = x0 + tl.today;
|
|
589
|
+
todaySvg =
|
|
590
|
+
`<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"/>` +
|
|
591
|
+
`<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
|
|
592
|
+
}
|
|
593
|
+
// The playhead moves to the start of each beat's item, then to the last date in the step hold.
|
|
594
|
+
if (tl.last != null && segs.length) {
|
|
595
|
+
const home = tl.last;
|
|
596
|
+
const startOf = new Map(tl.items.map((i) => [i.id, i.x + (i.milestone ? i.w / 2 : 0)]));
|
|
597
|
+
const dateOf = new Map(tl.items.map((i) => [i.id, i.date]));
|
|
598
|
+
const hold = STEP_HOLD_MS / 1000 / BASE_RATE;
|
|
599
|
+
const pts = [[0, home]];
|
|
600
|
+
const dates = []; // the label date in each time span
|
|
601
|
+
let at = home;
|
|
602
|
+
segs.forEach((s, i) => {
|
|
603
|
+
const item = playheadItem(tl.items, beats[s.si], s.bi);
|
|
604
|
+
const to = item ? startOf.get(item.id) : home;
|
|
605
|
+
const date = item ? dateOf.get(item.id) : tl.lastDate;
|
|
606
|
+
const last = i === segs.length - 1 || segs[i + 1].si !== s.si;
|
|
607
|
+
const end = last ? s.t1 - hold : s.t1; // the step hold starts here
|
|
608
|
+
pts.push([s.t0, at], [Math.min(s.t0 + RAMP, end), to], [end, to]);
|
|
609
|
+
const [la, lb] = labelSpan(s.t0, end, RAMP);
|
|
610
|
+
if (la < lb)
|
|
611
|
+
dates.push({ a: la, b: lb, d: date, x: to });
|
|
612
|
+
at = to;
|
|
613
|
+
if (last) {
|
|
614
|
+
pts.push([Math.min(end + RAMP, s.t1), home], [s.t1, home]);
|
|
615
|
+
const [ha, hb] = labelSpan(end, s.t1, RAMP);
|
|
616
|
+
if (ha < hb)
|
|
617
|
+
dates.push({ a: ha, b: hb, d: tl.lastDate, x: home });
|
|
618
|
+
at = home;
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
const name = `a${seen.size}`;
|
|
622
|
+
seen.set('playhead', name);
|
|
623
|
+
const kf = pts.map(([t, x]) => `${pct(t / total)} { transform: translateX(${n2(x - home)}px) }`).join(' ');
|
|
624
|
+
css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
|
|
625
|
+
const lx = x0 + home;
|
|
626
|
+
const labels = [...new Set(dates.map((x) => x.d))].map((d) => {
|
|
627
|
+
const on = dates.map((x) => x.d === d);
|
|
628
|
+
const key = `d${d}${on.map((x) => (x ? 1 : 0)).join('')}`;
|
|
629
|
+
if (!seen.has(key)) {
|
|
630
|
+
const nm = `a${seen.size}`;
|
|
631
|
+
seen.set(key, nm);
|
|
632
|
+
// Each span ends with a hidden frame: the label stays off while the line moves.
|
|
633
|
+
const fr = dates
|
|
634
|
+
.map((x) => `${pct(x.a / total)},${pct(x.b / total - 0.0001)} { opacity: ${x.d === d ? 1 : 0} } ${pct(x.b / total)} { opacity: 0 }`)
|
|
635
|
+
.join(' ');
|
|
636
|
+
css.push(`@keyframes ${nm} { ${fr} }\n.${nm} { animation: ${nm} ${n2(total)}s infinite step-end; }`);
|
|
637
|
+
}
|
|
638
|
+
// A label that would meet "today" moves to the tick row.
|
|
639
|
+
const raised = dateLabelRaised(dates.find((x) => x.d === d).x, tl.today, textWidth(d, 11), textWidth('today', 11));
|
|
640
|
+
return `<text x="${n2(lx + 3)}" y="${n2(top.y + (raised ? 10 : 22))}" opacity="0"${cls('today', seen.get(key))}>${esc(d)}</text>`;
|
|
641
|
+
});
|
|
642
|
+
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>`;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
380
645
|
const bounds = placed[0];
|
|
381
646
|
const arcs = fig.edges.some((e) => e.around);
|
|
382
647
|
const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
|
|
@@ -394,7 +659,7 @@ export function render(fig, opts = {}) {
|
|
|
394
659
|
const shift = (W - (bounds.w + pad * 2)) / 2;
|
|
395
660
|
const railX = rail ? (W - rail.width) / 2 : 0;
|
|
396
661
|
// The step label and its narration, both switching with the beats.
|
|
397
|
-
const labels = rail
|
|
662
|
+
const labels = rail || synthetic
|
|
398
663
|
? []
|
|
399
664
|
: steps.map((_, si) => {
|
|
400
665
|
const on = segs.map((s) => s.si === si);
|
|
@@ -463,7 +728,8 @@ export function render(fig, opts = {}) {
|
|
|
463
728
|
const next = segs.map((s) => s.si < row.step || (s.si === row.step && s.bi < row.beat));
|
|
464
729
|
const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
|
|
465
730
|
const ly = y + RAIL.row / 2;
|
|
466
|
-
const
|
|
731
|
+
const col = row.tone ? TONES[row.tone] : 'var(--accent)';
|
|
732
|
+
const lit = anim(now, `stroke: ${col}; stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, row.tone ? 'e' + col : 'e');
|
|
467
733
|
const tagW = ASYNC_TAG_W;
|
|
468
734
|
// The tag sits on the tail side of the pill, so it never hides the arrowhead.
|
|
469
735
|
const tagX = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
|
|
@@ -473,7 +739,7 @@ export function render(fig, opts = {}) {
|
|
|
473
739
|
`<text x="${n2(tagX + tagW / 2)}" y="${n2(ly + 3)}" class="tag" fill="${TONES.gray}">ASYNC</text>`
|
|
474
740
|
: '';
|
|
475
741
|
const pill = row.pill
|
|
476
|
-
? `<rect x="${n2(row.pill.x)}" y="${n2(ly - 9)}" width="${n2(row.pill.w)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"${cls(anim(now,
|
|
742
|
+
? `<rect x="${n2(row.pill.x)}" y="${n2(ly - 9)}" width="${n2(row.pill.w)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"${cls(anim(now, `fill: ${row.tone ? toneFill(col) : col}; stroke: ${col}`, 'fill: var(--bg); stroke: var(--border)', row.tone ? 'l' + col : 'l'))}/>` +
|
|
477
743
|
`<text x="${n2(row.pill.x + row.pill.w / 2)}" y="${n2(ly + 4)}"${cls('edgelabel', anim(now, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(row.text)}</text>`
|
|
478
744
|
: '';
|
|
479
745
|
parts.push(`<g${cls('railrow', moveRow(i))}><g${cls(anim(next, 'opacity: .45', 'opacity: 1', 'r'))}>` +
|
|
@@ -496,6 +762,9 @@ svg { ${vars(t0, '#eef5fd')} }
|
|
|
496
762
|
.row { fill: var(--fg); font-size: 11px; }
|
|
497
763
|
.row.mono { font-size: 10.5px; font-family: ui-monospace, Menlo, monospace; }
|
|
498
764
|
.muted { fill: var(--muted); }
|
|
765
|
+
.bar { fill: var(--fg); font-size: 13px; font-weight: 500; }
|
|
766
|
+
.tick { fill: var(--muted); font-size: 11px; }
|
|
767
|
+
.today { fill: var(--accent); font-size: 11px; font-weight: 600; }
|
|
499
768
|
.tag { font-size: 9px; font-weight: 600; letter-spacing: .03em; text-anchor: middle; }
|
|
500
769
|
.mark { fill: var(--accent); font-size: 11px; font-weight: 600; text-anchor: end; }
|
|
501
770
|
.chip { fill: ${ON_ACCENT}; font-size: 11.5px; text-anchor: middle; }
|
|
@@ -507,14 +776,18 @@ svg { ${vars(t0, '#eef5fd')} }
|
|
|
507
776
|
.end { text-anchor: end; }
|
|
508
777
|
${css.join('\n')}
|
|
509
778
|
</style>
|
|
510
|
-
<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 1 L 9 5 L 0 9 z" fill="
|
|
779
|
+
<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 1 L 9 5 L 0 9 z" fill="context-stroke"/></marker></defs>
|
|
511
780
|
<rect width="100%" height="100%" fill="var(--bg)"/>
|
|
512
781
|
${only
|
|
513
782
|
? ''
|
|
514
783
|
: `<g transform="translate(${n2(shift)} ${arcs ? 44 : 0})">
|
|
515
|
-
${
|
|
516
|
-
${
|
|
784
|
+
${axisSvg}
|
|
785
|
+
${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}
|
|
786
|
+
${tl ? edgeSvg.join('\n') : ''}
|
|
787
|
+
${boxes.filter((b, i) => b && !(tl && isGroup(placed[i].item))).join('\n')}
|
|
788
|
+
${tl ? '' : edgeSvg.join('\n')}
|
|
517
789
|
${packets.join('\n')}
|
|
790
|
+
${todaySvg}
|
|
518
791
|
</g>
|
|
519
792
|
`}${railSvg}
|
|
520
793
|
${labels.join('\n')}
|
|
@@ -529,7 +802,7 @@ ${said.join('\n')}
|
|
|
529
802
|
edges: [
|
|
530
803
|
...(only ? [] : routed).map((r) => {
|
|
531
804
|
const e = fig.edges[ids.indexOf(r.id)];
|
|
532
|
-
return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id] };
|
|
805
|
+
return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labelRects[r.id], ...(tl && { behind: true }) };
|
|
533
806
|
}),
|
|
534
807
|
// Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
|
|
535
808
|
...(rail?.rows ?? []).flatMap((row, i) => {
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { FlowProps } from './model.ts';
|
|
2
2
|
import type { Finding } from './scene.ts';
|
|
3
|
-
export { parseSource, owners, links, type Link } from './source.ts';
|
|
3
|
+
export { parseSource, headingSlug, owners, links, type Link } from './source.ts';
|
|
4
4
|
/** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
|
|
5
5
|
export declare function verify(fig: FlowProps, { root }?: {
|
|
6
6
|
root?: string;
|
package/dist/verify.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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
3
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
-
import { links } from './source.js';
|
|
5
|
-
export { parseSource, owners, links } from './source.js';
|
|
4
|
+
import { headingSlug, links } from './source.js';
|
|
5
|
+
export { parseSource, headingSlug, owners, links } from './source.js';
|
|
6
6
|
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
7
7
|
/** The file text, or null for a missing path, a folder or an unreadable file. */
|
|
8
8
|
function read(full) {
|
|
@@ -13,6 +13,11 @@ function read(full) {
|
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
/** True if a heading line of the Markdown text has this slug. */
|
|
17
|
+
const hasHeading = (text, slug) => text.split('\n').some((line) => {
|
|
18
|
+
const m = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
|
|
19
|
+
return m != null && headingSlug(m[1]) === slug;
|
|
20
|
+
});
|
|
16
21
|
/** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
|
|
17
22
|
export function verify(fig, { root = process.cwd() } = {}) {
|
|
18
23
|
const out = [];
|
|
@@ -29,7 +34,9 @@ export function verify(fig, { root = process.cwd() } = {}) {
|
|
|
29
34
|
const text = files.get(full);
|
|
30
35
|
if (text == null)
|
|
31
36
|
out.push({ rule: 'missing-file', severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: file not found` });
|
|
32
|
-
else if (l.symbol &&
|
|
37
|
+
else if (l.symbol &&
|
|
38
|
+
!new RegExp(`(^|[^\\w$])${escape(l.symbol)}(?![\\w$])`).test(text) &&
|
|
39
|
+
!(/\.(md|markdown)$/i.test(l.path) && hasHeading(text, l.symbol)))
|
|
33
40
|
out.push({ rule: 'missing-symbol', severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: symbol not found` });
|
|
34
41
|
}
|
|
35
42
|
return out;
|
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.4.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.4.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowfig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|