flowfig 0.6.0 → 0.7.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 -2
- package/dist/browser.d.ts +1 -3
- package/dist/browser.js +67 -59
- package/dist/check.d.ts +5 -3
- package/dist/check.js +20 -16
- package/dist/cli.js +32 -46
- package/dist/diff.js +0 -4
- package/dist/draw.d.ts +1 -3
- package/dist/draw.js +3 -7
- package/dist/geometry.d.ts +6 -8
- package/dist/geometry.js +75 -82
- package/dist/gif.d.ts +1 -2
- package/dist/gif.js +115 -42
- package/dist/guide.d.ts +0 -3
- package/dist/guide.js +0 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +20 -78
- package/dist/init.d.ts +3 -13
- package/dist/init.js +13 -18
- package/dist/load.d.ts +1 -3
- package/dist/load.js +2 -5
- package/dist/mcp.d.ts +1 -2
- package/dist/mcp.js +9 -12
- package/dist/model.d.ts +52 -109
- package/dist/model.js +20 -89
- package/dist/open.d.ts +2 -5
- package/dist/open.js +6 -11
- package/dist/page.d.ts +0 -1
- package/dist/page.js +1 -3
- package/dist/picker.d.ts +2 -4
- package/dist/picker.js +11 -14
- package/dist/png.js +9 -10
- package/dist/rail.d.ts +1 -3
- package/dist/rail.js +2 -16
- package/dist/scene.d.ts +8 -8
- package/dist/source.d.ts +0 -1
- package/dist/source.js +2 -2
- package/dist/svg.d.ts +5 -8
- package/dist/svg.js +50 -112
- package/dist/text.d.ts +0 -2
- package/dist/text.js +1 -4
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -4
- package/dist/version.d.ts +1 -2
- package/dist/version.js +2 -2
- package/package.json +4 -2
package/dist/svg.js
CHANGED
|
@@ -1,34 +1,24 @@
|
|
|
1
|
-
//
|
|
2
|
-
// inside a markdown image on GitHub, GitLab and Notion, where the React player cannot go. It gives
|
|
3
|
-
// up what needs a reader: no hover, no tabs, no pause — every step runs in one loop.
|
|
4
|
-
//
|
|
5
|
-
// It reads the same spec and the same `route()` as the React renderer, so the two cannot drift.
|
|
6
|
-
//
|
|
7
|
-
// Limit: text is measured by character class (src/text.ts) rather than by a browser, so this runs anywhere with plain node.
|
|
8
|
-
// Wrapping is therefore approximate; `flowfig check` uses the same measure, and the player check measures real text.
|
|
1
|
+
// Wrapping is approximate: text is measured by character class, not by a browser.
|
|
9
2
|
import { route } from './geometry.js';
|
|
10
3
|
import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
|
|
11
4
|
import { textWidth, wrap } from './text.js';
|
|
12
|
-
import {
|
|
5
|
+
import { checkRendered, planFor } from './check.js';
|
|
13
6
|
export { parseSource, links } from './source.js';
|
|
14
7
|
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, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf,
|
|
8
|
+
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, STUB_ROOM, laneBlock, laneEnd, tightCopies, nodeWidth, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
|
|
16
9
|
export { LIGHT, DARK } from './model.js';
|
|
17
10
|
const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
|
|
18
11
|
const LABEL_LINE = 18, SUB_LINE = 15;
|
|
19
12
|
const FRAME_TOP = 37, FRAME_BOTTOM = 18;
|
|
20
|
-
// The card-on fills
|
|
13
|
+
// The card-on fills approximate the player's 8 % accent tint.
|
|
21
14
|
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
|
-
/** What a card actually holds: rows, or its text. Only text survives outside React. */
|
|
23
15
|
const content = (c) => (isRows(c) ? c : str(c));
|
|
24
16
|
const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c]);
|
|
25
17
|
const n2 = (v) => Math.round(v * 10) / 10;
|
|
26
|
-
//
|
|
18
|
+
// Four decimals: one decimal rounds a 0.7 s hop away and the packet jumps.
|
|
27
19
|
const n4 = (v) => Math.round(v * 10000) / 10000;
|
|
28
20
|
const pct = (v) => Math.round(v * 10000) / 100 + '%';
|
|
29
|
-
/** A tag pill. The tag is drawn in bold capitals with letter spacing, so the width counts both. */
|
|
30
21
|
const pillW = (tag) => textWidth(tag.toUpperCase(), 9) + tag.length * 0.27 + 8;
|
|
31
|
-
/** A card's rows, wrapped for `width`, with the height they need. */
|
|
32
22
|
function layoutCard(c, width) {
|
|
33
23
|
const inner = width - CARD_SIDE * 2;
|
|
34
24
|
if (c == null)
|
|
@@ -39,7 +29,6 @@ function layoutCard(c, width) {
|
|
|
39
29
|
return { rows: [{ row: { text: body }, heads: false, lines, room: inner }], height: lines.length * LINE + CARD_PAD * 2 };
|
|
40
30
|
}
|
|
41
31
|
const rows = body.map((row) => {
|
|
42
|
-
// A word-sized tag heads its row so the text keeps the full width; a number or no tag sits inline.
|
|
43
32
|
const heads = (row.tag?.length ?? 0) > 2;
|
|
44
33
|
const tagW = row.tag && !heads ? pillW(row.tag) + 5 : 0;
|
|
45
34
|
const markW = row.mark && !heads ? textWidth(str(row.mark), 11) + 6 : 0;
|
|
@@ -68,8 +57,6 @@ function size(item, s) {
|
|
|
68
57
|
const inner = item.direction === 'column' ? { w: across, h: along } : { w: along, h: across };
|
|
69
58
|
return item.label != null ? { w: inner.w + FRAME_SIDE * 2, h: inner.h + FRAME_TOP + FRAME_BOTTOM } : inner;
|
|
70
59
|
}
|
|
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
60
|
function placeLanes(fig, x, y, s, out) {
|
|
74
61
|
const lanes = fig.layout.children;
|
|
75
62
|
const { cols, starts, gaps, lead, blocks } = s.plan;
|
|
@@ -87,7 +74,6 @@ function placeLanes(fig, x, y, s, out) {
|
|
|
87
74
|
.slice(0, c - starts[k])
|
|
88
75
|
.reduce((a, w, i) => a + w + gaps[starts[k] + i], 0));
|
|
89
76
|
});
|
|
90
|
-
// A figure with no box has no column: its band keeps the frame side on the right.
|
|
91
77
|
const width = Math.max(...blocks.map((k) => {
|
|
92
78
|
const ws = colsOf(k);
|
|
93
79
|
return (gutter +
|
|
@@ -104,7 +90,6 @@ function placeLanes(fig, x, y, s, out) {
|
|
|
104
90
|
ly += LANE_BLOCK_GAP - LANE_ROW_GAP;
|
|
105
91
|
for (const lane of lanes) {
|
|
106
92
|
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
93
|
if (blocks.length > 1 && !kids.length)
|
|
109
94
|
continue;
|
|
110
95
|
const inner = Math.max(LABEL_LINE + 20, ...kids.map((k) => k.h));
|
|
@@ -117,7 +102,6 @@ function placeLanes(fig, x, y, s, out) {
|
|
|
117
102
|
});
|
|
118
103
|
out[topAt].h = ly - LANE_ROW_GAP - y;
|
|
119
104
|
}
|
|
120
|
-
/** A timeline: the axis strip on top, one band per track, and the bars and milestones at the shared layout x. */
|
|
121
105
|
function placeTimeline(fig, x, y, out) {
|
|
122
106
|
const lanes = fig.layout.children;
|
|
123
107
|
const gutter = gutterOf(lanes);
|
|
@@ -178,24 +162,18 @@ function place(item, x, y, s, out) {
|
|
|
178
162
|
}
|
|
179
163
|
}
|
|
180
164
|
const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
|
181
|
-
/**
|
|
182
|
-
* The SVG string, and the scene it drew. The scene lets `flowfig check` test the same layout the SVG uses.
|
|
183
|
-
* The scene is for tools, and its shape may change. Use `toSvg` if you only need the SVG.
|
|
184
|
-
*/
|
|
165
|
+
/** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
|
|
185
166
|
export function render(fig, opts = {}) {
|
|
186
167
|
const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
|
|
187
168
|
const pad = opts.padding ?? 24;
|
|
188
169
|
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
170
|
const synthetic = tl != null && !fig.steps?.length;
|
|
191
171
|
const steps = synthetic ? timelineBeats(fig) : (fig.steps ?? []);
|
|
192
172
|
const beats = steps.map((s) => s.flow.map(toBeat));
|
|
193
|
-
// Every content a box will ever show, so its card can be sized to the biggest one up front.
|
|
194
173
|
const cards = new Map();
|
|
195
174
|
for (const b of beats.flat())
|
|
196
175
|
for (const [id, c] of Object.entries(b.show ?? {}))
|
|
197
176
|
cards.set(id, [...(cards.get(id) ?? []), c]);
|
|
198
|
-
// Boxes with many edges on one side get taller so the edges and their labels have room.
|
|
199
177
|
const out = {}, inn = {};
|
|
200
178
|
for (const e of fig.edges) {
|
|
201
179
|
out[e.from] = (out[e.from] ?? 0) + 1;
|
|
@@ -210,14 +188,13 @@ export function render(fig, opts = {}) {
|
|
|
210
188
|
const plan = lanes ? planFor(fig, opts) : null;
|
|
211
189
|
const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges), fig, plan, tall: new Set(plan?.tall) };
|
|
212
190
|
for (const [id, contents] of cards) {
|
|
213
|
-
const width = CARD_WIDTH;
|
|
191
|
+
const width = CARD_WIDTH;
|
|
214
192
|
const widths = [width];
|
|
215
193
|
cardH.set(id, Math.max(LINE + CARD_PAD * 2, ...contents.map((c) => layoutCard(c, Math.min(...widths) - 20).height)));
|
|
216
194
|
}
|
|
217
195
|
const placed = [];
|
|
218
196
|
place(fig.layout, pad, pad, sizes, placed);
|
|
219
197
|
let nodes = placed.filter((p) => !isGroup(p.item));
|
|
220
|
-
// A node's own width can differ from CARD_WIDTH (`width` in the spec), so re-measure once placed.
|
|
221
198
|
for (const p of nodes) {
|
|
222
199
|
if (!cards.has(p.item.id))
|
|
223
200
|
continue;
|
|
@@ -227,11 +204,9 @@ export function render(fig, opts = {}) {
|
|
|
227
204
|
const rects = {};
|
|
228
205
|
const tips = new Set(placed.filter((p) => !isGroup(p.item) && (p.item.shape === 'decision' || p.tl?.milestone)).map((p) => p.item.id));
|
|
229
206
|
const ids = fig.edges.map(edgeId);
|
|
230
|
-
// Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane gutters.
|
|
231
207
|
const stubs = plan?.stubs ?? new Map();
|
|
232
208
|
const gutter = plan ? gutterOf(fig.layout.children) : 0;
|
|
233
209
|
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
210
|
const laneGroups = fig.layout.children;
|
|
236
211
|
const bandOf = (eid, id, k) => {
|
|
237
212
|
const block = plan?.ends.get(eid)?.[k];
|
|
@@ -239,7 +214,6 @@ export function render(fig, opts = {}) {
|
|
|
239
214
|
return placed.find((p) => p.lane && p.item === lane && p.block === block);
|
|
240
215
|
};
|
|
241
216
|
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
217
|
const go = () => {
|
|
244
218
|
placed.length = 0;
|
|
245
219
|
place(fig.layout, pad, pad, sizes, placed);
|
|
@@ -258,11 +232,8 @@ export function render(fig, opts = {}) {
|
|
|
258
232
|
? placed.flatMap((p) => (!isGroup(p.item) ? [p] : p.lane ? [{ x: p.x, y: p.y, w: gutter, h: p.h }] : []))
|
|
259
233
|
: [
|
|
260
234
|
...placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))),
|
|
261
|
-
// A timeline elbow keeps clear of the bars and milestones it does not connect.
|
|
262
235
|
...placed.filter((p) => p.tl).map((p) => ({ x: p.x, y: p.y, w: p.w, h: p.h, box: true })),
|
|
263
|
-
],
|
|
264
|
-
// A stub pill stays in the lanes, right of the gutter.
|
|
265
|
-
stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined, lanes ? { bands: placed.filter((p) => p.lane), boxes: placed.filter((p) => !isGroup(p.item)) } : undefined);
|
|
236
|
+
], stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined, lanes ? { bands: placed.filter((p) => p.lane), boxes: placed.filter((p) => !isGroup(p.item)) } : undefined);
|
|
266
237
|
};
|
|
267
238
|
let routed = go();
|
|
268
239
|
for (let pass = 0; plan && pass < 3; pass++) {
|
|
@@ -274,11 +245,9 @@ export function render(fig, opts = {}) {
|
|
|
274
245
|
}
|
|
275
246
|
nodes = placed.filter((p) => !isGroup(p.item));
|
|
276
247
|
const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
|
|
277
|
-
// What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
|
|
278
248
|
const fonts = [14, ...(steps.length ? [13.5] : [])];
|
|
279
249
|
const sceneBoxes = nodes.map((p) => {
|
|
280
250
|
if (p.tl) {
|
|
281
|
-
// 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.
|
|
282
251
|
const label = str(p.item.label);
|
|
283
252
|
const mine = tl.items.find((i) => i.id === p.item.id);
|
|
284
253
|
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);
|
|
@@ -310,7 +279,6 @@ export function render(fig, opts = {}) {
|
|
|
310
279
|
});
|
|
311
280
|
if (placed.some((p) => isGroup(p.item) && p.item.label != null))
|
|
312
281
|
fonts.push(11);
|
|
313
|
-
// The timeline: every beat of every step, in order, with the player's hold at the end of each step.
|
|
314
282
|
const segs = [];
|
|
315
283
|
const hops = [];
|
|
316
284
|
const edgeOf = Object.fromEntries(fig.edges.map((e, i) => [ids[i], e]));
|
|
@@ -341,7 +309,6 @@ export function render(fig, opts = {}) {
|
|
|
341
309
|
});
|
|
342
310
|
});
|
|
343
311
|
const total = t || 1;
|
|
344
|
-
// What the figure shows during each segment, matching the React player exactly.
|
|
345
312
|
const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
|
|
346
313
|
const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
|
|
347
314
|
const litNodes = segs.map((seg, i) => {
|
|
@@ -361,10 +328,19 @@ export function render(fig, opts = {}) {
|
|
|
361
328
|
const said = beats[si].slice(0, bi + 1).findLastIndex((b) => b.say != null);
|
|
362
329
|
return str(said === -1 ? steps[si].caption : beats[si][said].say);
|
|
363
330
|
});
|
|
364
|
-
// One class per on/off pattern over the segments, so 30 boxes share a handful of keyframes.
|
|
365
331
|
const css = [];
|
|
366
332
|
const seen = new Map();
|
|
367
|
-
|
|
333
|
+
const runs = (values) => {
|
|
334
|
+
const out = [];
|
|
335
|
+
for (let i = 0; i < segs.length;) {
|
|
336
|
+
let j = i;
|
|
337
|
+
while (j + 1 < segs.length && values[j + 1] === values[i])
|
|
338
|
+
j++;
|
|
339
|
+
out.push(`${pct(segs[i].t0 / total)},${pct(segs[j].t1 / total - 0.0001)} { ${values[i]} }`);
|
|
340
|
+
i = j + 1;
|
|
341
|
+
}
|
|
342
|
+
return out.join(' ');
|
|
343
|
+
};
|
|
368
344
|
const anim = (on, onCss, offCss, prefix) => {
|
|
369
345
|
if (!segs.length || on.every((x) => !x))
|
|
370
346
|
return '';
|
|
@@ -372,17 +348,16 @@ export function render(fig, opts = {}) {
|
|
|
372
348
|
if (!seen.has(key)) {
|
|
373
349
|
const name = `a${seen.size}`;
|
|
374
350
|
seen.set(key, name);
|
|
375
|
-
const frames =
|
|
351
|
+
const frames = runs(on.map((x) => (x ? onCss : offCss)));
|
|
376
352
|
css.push(`@keyframes ${name} { ${frames} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
|
|
377
353
|
}
|
|
378
354
|
return seen.get(key);
|
|
379
355
|
};
|
|
380
|
-
|
|
356
|
+
// One class attribute only: two would be invalid XML.
|
|
381
357
|
const cls = (...names) => {
|
|
382
358
|
const list = names.filter(Boolean).join(' ');
|
|
383
359
|
return list ? ` class="${list}"` : '';
|
|
384
360
|
};
|
|
385
|
-
/** Like `anim`, for a property that takes a different value in each segment (a row that moves as phases fold). */
|
|
386
361
|
const frames = (values, prefix, base = values[0]) => {
|
|
387
362
|
if (!segs.length || values.every((v) => v === base))
|
|
388
363
|
return '';
|
|
@@ -390,20 +365,14 @@ export function render(fig, opts = {}) {
|
|
|
390
365
|
if (!seen.has(key)) {
|
|
391
366
|
const name = `a${seen.size}`;
|
|
392
367
|
seen.set(key, name);
|
|
393
|
-
const kf =
|
|
368
|
+
const kf = runs(values);
|
|
394
369
|
css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
|
|
395
370
|
}
|
|
396
371
|
return seen.get(key);
|
|
397
372
|
};
|
|
398
|
-
|
|
399
|
-
* The look of one box over the loop: off, trail (visited) or active (the packet arrived, until the segment ends).
|
|
400
|
-
* A segment ends at the beat end, or after the step hold for the last beat.
|
|
401
|
-
* Each frame holds one look. The gap before the frame that follows an active frame gives the FADE.
|
|
402
|
-
* FADE is in timeline seconds, which are wall seconds.
|
|
403
|
-
*/
|
|
373
|
+
// FADE and RAMP are wall seconds; the player matches them in ms.
|
|
404
374
|
const FADE = 0.4;
|
|
405
|
-
const RAMP = 0.4;
|
|
406
|
-
// 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.
|
|
375
|
+
const RAMP = 0.4;
|
|
407
376
|
const looks = (look, boxTone, hopTone) => {
|
|
408
377
|
const tint = boxTone ? toneTint(boxTone, 'var(--bg)') : 'var(--bg)';
|
|
409
378
|
if (look === 'off')
|
|
@@ -415,13 +384,21 @@ export function render(fig, opts = {}) {
|
|
|
415
384
|
? [toneTint(c, 'var(--surface)', 10), c, 2, `drop-shadow(0 0 4px ${c})`]
|
|
416
385
|
: ['var(--tint)', 'var(--accent)', 2, 'drop-shadow(0 0 4px var(--accent))'];
|
|
417
386
|
};
|
|
387
|
+
const hopsAt = new Map();
|
|
388
|
+
for (const h of hops) {
|
|
389
|
+
const k = `${h.si}:${h.bi}:${h.dest}`;
|
|
390
|
+
const list = hopsAt.get(k);
|
|
391
|
+
if (list)
|
|
392
|
+
list.push(h);
|
|
393
|
+
else
|
|
394
|
+
hopsAt.set(k, [h]);
|
|
395
|
+
}
|
|
418
396
|
const boxAnim = (id, shape, boxTone) => {
|
|
419
397
|
const pieces = [];
|
|
420
398
|
segs.forEach((s, i) => {
|
|
421
399
|
if (!litNodes[i].has(id))
|
|
422
400
|
return void pieces.push({ a: s.t0, b: s.t1, look: 'off' });
|
|
423
|
-
const here =
|
|
424
|
-
// A focused box turns active at the beat start; in a timeline, when the today line arrives.
|
|
401
|
+
const here = hopsAt.get(`${s.si}:${s.bi}:${id}`) ?? [];
|
|
425
402
|
const focused = beats[s.si][s.bi].focus?.includes(id);
|
|
426
403
|
const arrive = Math.min(...here.map((h) => h.t1), focused ? Math.min(s.t0 + (tl ? RAMP : 0), s.t1) : s.t1, s.t1);
|
|
427
404
|
if (arrive > s.t0)
|
|
@@ -431,18 +408,23 @@ export function render(fig, opts = {}) {
|
|
|
431
408
|
});
|
|
432
409
|
if (!pieces.length || pieces.every((q) => q.look === 'off'))
|
|
433
410
|
return '';
|
|
434
|
-
// The last frame is active: the fade ends at 100 %, so the loop wraps with no jump.
|
|
435
411
|
const end = pieces.at(-1);
|
|
436
412
|
if (end.look === 'active') {
|
|
437
413
|
end.b -= FADE;
|
|
438
414
|
pieces.push({ a: end.b, b: end.b + FADE, look: pieces[0].look, hop: pieces[0].hop, ramp: FADE - 0.0002 * total });
|
|
439
415
|
}
|
|
440
|
-
const
|
|
416
|
+
const merged = [];
|
|
417
|
+
pieces.forEach((q, k) => {
|
|
441
418
|
const ramp = q.ramp ?? (q.look !== 'active' && pieces[k - 1]?.look === 'active' ? Math.min(FADE, (q.b - q.a) / 2) : 0);
|
|
442
419
|
const [fill, stroke, width, filter] = looks(q.look, boxTone, q.hop);
|
|
443
420
|
const css = `${shape ? `fill: ${fill}; ` : ''}stroke: ${stroke}; stroke-width: ${width}${shape ? `; filter: ${filter}` : ''}`;
|
|
444
|
-
|
|
421
|
+
const last = merged.at(-1);
|
|
422
|
+
if (last?.css === css)
|
|
423
|
+
last.b = q.b;
|
|
424
|
+
else
|
|
425
|
+
merged.push({ a: q.a + ramp, b: q.b, css });
|
|
445
426
|
});
|
|
427
|
+
const kf = merged.map((f) => `${pct(f.a / total)},${pct(f.b / total - 0.0001)} { ${f.css} }`);
|
|
446
428
|
const key = (shape ? 'B' : 'R') + kf.join(' ');
|
|
447
429
|
if (!seen.has(key)) {
|
|
448
430
|
const name = `a${seen.size}`;
|
|
@@ -495,7 +477,6 @@ export function render(fig, opts = {}) {
|
|
|
495
477
|
: item.sub
|
|
496
478
|
? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>`
|
|
497
479
|
: '';
|
|
498
|
-
// The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
|
|
499
480
|
const dot = bt ?? 'var(--accent)';
|
|
500
481
|
const my = n2(p.y + p.h / 2);
|
|
501
482
|
const mark = item.mark === 'start'
|
|
@@ -505,7 +486,6 @@ export function render(fig, opts = {}) {
|
|
|
505
486
|
: '';
|
|
506
487
|
return shape + label + sub + mark + (contents ? card(p, cardTop, contents) : '');
|
|
507
488
|
});
|
|
508
|
-
/** The dashed content card: one group per content it will ever hold, each visible on its own beats. */
|
|
509
489
|
function card(p, top, contents) {
|
|
510
490
|
const id = p.item.id;
|
|
511
491
|
const x = p.x + 10, w = p.w - 20, h = cardH.get(id);
|
|
@@ -513,7 +493,6 @@ export function render(fig, opts = {}) {
|
|
|
513
493
|
const box = `<rect x="${n2(x)}" y="${n2(top)}" width="${n2(w)}" height="${n2(h)}" rx="6" fill="var(--surface)" stroke="var(--border)" stroke-dasharray="3 3"` +
|
|
514
494
|
cls(anim(filled, 'stroke: var(--accent); fill: var(--card-on)', 'stroke: var(--border); fill: var(--surface)', 'c')) +
|
|
515
495
|
`/>`;
|
|
516
|
-
// Every distinct content gets a layer; the segments decide which one is showing.
|
|
517
496
|
const layers = contents
|
|
518
497
|
.map((c, ci) => {
|
|
519
498
|
const on = segs.map((_, i) => contents.indexOf(shownAt[i][id]) === ci);
|
|
@@ -528,7 +507,6 @@ export function render(fig, opts = {}) {
|
|
|
528
507
|
: '';
|
|
529
508
|
return box + layers + dash;
|
|
530
509
|
}
|
|
531
|
-
/** One card's rows: a colored tag pill, the text, its muted meta, and the mark on the right. */
|
|
532
510
|
function rows(c, x, top, w) {
|
|
533
511
|
const { rows } = layoutCard(c, w);
|
|
534
512
|
let y = top + CARD_PAD;
|
|
@@ -565,22 +543,18 @@ export function render(fig, opts = {}) {
|
|
|
565
543
|
const on = segs.map((_, i) => litEdges[i].has(r.id));
|
|
566
544
|
const hidden = e.quiet && !on.every(Boolean);
|
|
567
545
|
const shown = hidden ? cls(anim(on, 'opacity: 1', 'opacity: 0', 'q')) : '';
|
|
568
|
-
// A toned hop colors its edge for its own beat only; the trail after it is the accent.
|
|
569
546
|
const tone = segs.map(({ si, bi }) => (beats[si][bi].hops.find((h) => h.edge === r.id && h.tone)?.tone ?? undefined));
|
|
570
547
|
const col = tone.map((x) => (x ? TONES[x] : 'var(--accent)'));
|
|
571
548
|
const off = `stroke: var(--muted); stroke-width: ${EDGE_OFF}`;
|
|
572
|
-
// With no tone the map and the rail share one keyframe (same key); a toned edge needs its own values.
|
|
573
549
|
const toned = tone.some(Boolean);
|
|
574
550
|
const lit = cls(toned
|
|
575
551
|
? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
|
|
576
552
|
: anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
|
|
577
|
-
// A cross-block edge draws its two stubs; its full path only guides the packet, so the packet jumps between the blocks.
|
|
578
553
|
const path = r.stub
|
|
579
554
|
? r.stub.parts
|
|
580
555
|
.map((d) => `<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
|
|
581
556
|
.join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
|
|
582
557
|
: `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
|
|
583
|
-
// A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
|
|
584
558
|
const pill = (x, y, lw, text) => `<rect x="${n2(x - lw / 2)}" y="${n2(y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
|
|
585
559
|
cls(toned
|
|
586
560
|
? 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)')
|
|
@@ -600,8 +574,6 @@ export function render(fig, opts = {}) {
|
|
|
600
574
|
}
|
|
601
575
|
return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
|
|
602
576
|
});
|
|
603
|
-
// A packet per hop: it waits offstage, crosses its edge in `speed`, rests at the end of the edge to the end of its beat, then leaves. Any `data`
|
|
604
|
-
// rides above it in a chip, which is how the figure says what is moving.
|
|
605
577
|
const packets = hops.map((h, i) => {
|
|
606
578
|
const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
|
|
607
579
|
const name = `p${i}`;
|
|
@@ -622,7 +594,6 @@ export function render(fig, opts = {}) {
|
|
|
622
594
|
`<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">` +
|
|
623
595
|
`<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
|
|
624
596
|
});
|
|
625
|
-
// The timeline axis, the fixed today marker and the moving playhead. Their lines sit behind the bars, their labels on top.
|
|
626
597
|
let axisSvg = '', lineSvg = '', todaySvg = '';
|
|
627
598
|
if (tl) {
|
|
628
599
|
const top = placed[0];
|
|
@@ -636,14 +607,11 @@ export function render(fig, opts = {}) {
|
|
|
636
607
|
`<text x="${n2(x0 + k.x + 3)}" y="${n2(top.y + 10)}" class="tick">${esc(k.label)}</text>`)
|
|
637
608
|
.join('');
|
|
638
609
|
const bottom = n2(top.y + top.h);
|
|
639
|
-
// The today marker is fixed at the figure's `today`.
|
|
640
610
|
if (tl.today != null) {
|
|
641
611
|
const tx = x0 + tl.today;
|
|
642
612
|
lineSvg = `<path d="M ${n2(tx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-opacity="0.6" stroke-width="1" stroke-dasharray="3 3"/>`;
|
|
643
613
|
todaySvg = `<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
|
|
644
614
|
}
|
|
645
|
-
// The playhead moves to the start of each beat's item, then to the last date in the step hold. The loop starts with
|
|
646
|
-
// the playhead at the first beat's item, so the first frame shows the first beat; the last hold moves it back there.
|
|
647
615
|
if (tl.last != null && segs.length) {
|
|
648
616
|
const home = tl.last;
|
|
649
617
|
const startOf = new Map(tl.items.map((i) => [i.id, i.x + (i.milestone ? i.w / 2 : 0)]));
|
|
@@ -652,22 +620,20 @@ export function render(fig, opts = {}) {
|
|
|
652
620
|
const first = loopStartItem(tl.items, steps);
|
|
653
621
|
const start = first ? startOf.get(first.id) : home;
|
|
654
622
|
const pts = [[0, start]];
|
|
655
|
-
const dates = [];
|
|
623
|
+
const dates = [];
|
|
656
624
|
let at = start;
|
|
657
625
|
segs.forEach((s, i) => {
|
|
658
626
|
const item = playheadItem(tl.items, beats[s.si], s.bi);
|
|
659
627
|
const to = item ? startOf.get(item.id) : home;
|
|
660
628
|
const date = item ? dateOf.get(item.id) : tl.lastDate;
|
|
661
629
|
const last = i === segs.length - 1 || segs[i + 1].si !== s.si;
|
|
662
|
-
const end = last ? s.t1 - hold : s.t1;
|
|
630
|
+
const end = last ? s.t1 - hold : s.t1;
|
|
663
631
|
pts.push([s.t0, at], [Math.min(s.t0 + RAMP, end), to], [end, to]);
|
|
664
|
-
// The label waits for the line, except at the loop start, where the line is already in place.
|
|
665
632
|
const [la, lb] = i === 0 && to === start ? [s.t0, end] : labelSpan(s.t0, end, RAMP);
|
|
666
633
|
if (la < lb)
|
|
667
634
|
dates.push({ a: la, b: lb, d: date, x: to });
|
|
668
635
|
at = to;
|
|
669
636
|
if (last) {
|
|
670
|
-
// The last hold of the loop ends with the move back to the loop start.
|
|
671
637
|
const back = i === segs.length - 1 ? Math.max(end + RAMP, s.t1 - RAMP) : s.t1;
|
|
672
638
|
pts.push([Math.min(end + RAMP, s.t1), home], [back, home]);
|
|
673
639
|
const [ha, hb] = labelSpan(end, back, RAMP);
|
|
@@ -689,13 +655,11 @@ export function render(fig, opts = {}) {
|
|
|
689
655
|
if (!seen.has(key)) {
|
|
690
656
|
const nm = `a${seen.size}`;
|
|
691
657
|
seen.set(key, nm);
|
|
692
|
-
// Each span ends with a hidden frame: the label stays off while the line moves.
|
|
693
658
|
const fr = dates
|
|
694
659
|
.map((x) => `${pct(x.a / total)},${pct(x.b / total - 0.0001)} { opacity: ${x.d === d ? 1 : 0} } ${pct(x.b / total)} { opacity: 0 }`)
|
|
695
660
|
.join(' ');
|
|
696
661
|
css.push(`@keyframes ${nm} { ${fr} }\n.${nm} { animation: ${nm} ${n2(total)}s infinite step-end; }`);
|
|
697
662
|
}
|
|
698
|
-
// A label that would meet "today" moves to the tick row.
|
|
699
663
|
const raised = dateLabelRaised(dates.find((x) => x.d === d).x, tl.today, textWidth(d, 11), textWidth('today', 11));
|
|
700
664
|
return `<text x="${n2(lx + 3)}" y="${n2(top.y + (raised ? 10 : 22))}" opacity="0"${cls('today', seen.get(key))}>${esc(d)}</text>`;
|
|
701
665
|
});
|
|
@@ -704,22 +668,19 @@ export function render(fig, opts = {}) {
|
|
|
704
668
|
}
|
|
705
669
|
}
|
|
706
670
|
const bounds = placed[0];
|
|
707
|
-
const arcs = fig.edges.some((e, i) => (e.around || plan?.around.has(ids[i])) && !stubs.has(ids[i]));
|
|
671
|
+
const arcs = fig.edges.some((e, i) => (e.around || plan?.around.has(ids[i])) && !stubs.has(ids[i]));
|
|
708
672
|
const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
|
|
709
673
|
const mapW = Math.max(bounds.w + pad * 2, 560);
|
|
710
|
-
// `rail: 'only'` drops the map, but only when the rail has a hop to draw; a figure never renders empty.
|
|
711
674
|
const rail = fig.rail ? layoutRail(fig, fig.rail === 'only' ? 560 : mapW) : null;
|
|
712
675
|
const only = fig.rail === 'only' && rail != null;
|
|
713
|
-
// With the rail, the rail shows the step label, so the caption holds the narration only.
|
|
714
676
|
const capTop = rail ? 20 : 26;
|
|
715
677
|
const capH = steps.length ? capTop + 4 + Math.max(0, ...capLines) * 20 : 0;
|
|
716
678
|
const W = only ? Math.max(560, rail.width) : Math.max(mapW, rail?.width ?? 0);
|
|
717
679
|
const mapH = only ? 0 : bounds.h + pad * 2 + (arcs ? 44 : 0);
|
|
718
|
-
const top = only ? pad : mapH + RAIL.gap;
|
|
680
|
+
const top = only ? pad : mapH + RAIL.gap;
|
|
719
681
|
const H = only ? pad + rail.height + capH : mapH + (rail ? RAIL.gap + rail.height : 0) + capH;
|
|
720
682
|
const shift = (W - (bounds.w + pad * 2)) / 2;
|
|
721
683
|
const railX = rail ? (W - rail.width) / 2 : 0;
|
|
722
|
-
// The step label and its narration, both switching with the beats.
|
|
723
684
|
const labels = rail || synthetic
|
|
724
685
|
? []
|
|
725
686
|
: steps.map((_, si) => {
|
|
@@ -733,23 +694,19 @@ export function render(fig, opts = {}) {
|
|
|
733
694
|
lines.map((l, li) => `<text x="${n2(W / 2)}" y="${n2(H - capH + capTop + 16 + li * 20)}" class="caption">${esc(l)}</text>`).join('') +
|
|
734
695
|
`</g>`);
|
|
735
696
|
});
|
|
736
|
-
// The rail: lifelines under the map. Every part reuses a map part: the frame for a band, the edge-label pill for a payload, the card
|
|
737
|
-
// tag for `async`, the filled card for a parallel group. So the rail adds no color and no font size.
|
|
738
697
|
const railSvg = !rail
|
|
739
698
|
? ''
|
|
740
699
|
: (() => {
|
|
741
700
|
const parts = [];
|
|
742
|
-
const cols = rail.head - RAIL.cols / 2 + 4;
|
|
701
|
+
const cols = rail.head - RAIL.cols / 2 + 4;
|
|
743
702
|
for (const b of rail.bands)
|
|
744
703
|
parts.push(`<rect x="${n2(b.rect.x)}" y="${n2(top + b.rect.y)}" width="${n2(b.rect.w)}" height="${n2(b.rect.h)}" rx="10" fill="var(--surface)" stroke="var(--border)"/>` +
|
|
745
704
|
`<text x="${n2(b.rect.x + 10)}" y="${n2(top + b.rect.y + 14)}" class="frame">${esc(b.label.toUpperCase())}</text>`);
|
|
746
705
|
for (const c of rail.columns)
|
|
747
706
|
parts.push(`<text x="${n2(c.x)}" y="${n2(top + cols)}" class="railcol">${esc(c.label)}</text>`);
|
|
748
|
-
// Where each row sits in each segment: it moves when another phase opens, and hides while its phase is folded.
|
|
749
707
|
const at = (i) => segs.map((s) => railState(rail, s.si)[i]);
|
|
750
708
|
const base = (i) => railState(rail, 0)[i].y;
|
|
751
709
|
const moveRow = (i) => frames(at(i).map((st) => `transform: translateY(${n2(st.y - base(i))}px); opacity: ${st.shown ? 1 : 0}`), 'm');
|
|
752
|
-
// The lifelines run through the message rows only, so a phase header reads as one clear divider.
|
|
753
710
|
rail.rows.forEach((row, i) => {
|
|
754
711
|
if (row.kind !== 'message')
|
|
755
712
|
return;
|
|
@@ -767,8 +724,7 @@ export function render(fig, opts = {}) {
|
|
|
767
724
|
if (row.kind === 'phase') {
|
|
768
725
|
const folded = segs.map((s) => rail.folds && s.si !== row.step);
|
|
769
726
|
const rule = (x1) => `<path d="M ${n2(x1)} ${n2(y + 15)} H ${n2(row.line.end)}" stroke="var(--border)"/>`;
|
|
770
|
-
|
|
771
|
-
const first = new Map(); // beat -> the n of its first message
|
|
727
|
+
const first = new Map();
|
|
772
728
|
for (const r of rail.rows)
|
|
773
729
|
if (r.kind === 'message' && r.step === row.step && !first.has(r.beat))
|
|
774
730
|
first.set(r.beat, r.n);
|
|
@@ -792,7 +748,6 @@ export function render(fig, opts = {}) {
|
|
|
792
748
|
const col = row.tone ? TONES[row.tone] : 'var(--accent)';
|
|
793
749
|
const lit = anim(now, `stroke: ${col}; stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, row.tone ? 'e' + col : 'e');
|
|
794
750
|
const tagW = ASYNC_TAG_W;
|
|
795
|
-
// The tag sits on the tail side of the pill, so it never hides the arrowhead.
|
|
796
751
|
const tagX = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
|
|
797
752
|
const tag = row.async
|
|
798
753
|
? `<rect x="${n2(tagX)}" y="${n2(ly - 6)}" width="${n2(tagW)}" height="12" rx="4" fill="var(--bg)"/>` +
|
|
@@ -863,10 +818,8 @@ ${said.join('\n')}
|
|
|
863
818
|
edges: [
|
|
864
819
|
...(only ? [] : routed).flatMap((r) => {
|
|
865
820
|
const e = fig.edges[ids.indexOf(r.id)];
|
|
866
|
-
// A cross-block edge is two scene edges: each stub with its pill.
|
|
867
821
|
if (r.stub)
|
|
868
822
|
return r.stub.pts.map((pts, k) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: r.stub.pills[k] }));
|
|
869
|
-
// A timeline edge is drawn behind, but `check` still tests its elbow against the boxes it does not connect.
|
|
870
823
|
return [
|
|
871
824
|
{
|
|
872
825
|
id: r.id,
|
|
@@ -878,11 +831,9 @@ ${said.join('\n')}
|
|
|
878
831
|
},
|
|
879
832
|
];
|
|
880
833
|
}),
|
|
881
|
-
// Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
|
|
882
834
|
...(rail?.rows ?? []).flatMap((row, i) => {
|
|
883
835
|
if (row.kind !== 'message')
|
|
884
836
|
return [];
|
|
885
|
-
// Scene coordinates are the map's layout coordinates: the page minus the map's translate(shift, arcs).
|
|
886
837
|
const dx = only ? railX : railX - shift, dy = only ? top : top - (arcs ? 44 : 0);
|
|
887
838
|
const y = dy + railState(rail, row.step)[i].y + RAIL.row / 2;
|
|
888
839
|
const [x1, x2] = [dx + rail.columns[row.from].x, dx + rail.columns[row.to].x];
|
|
@@ -906,24 +857,11 @@ ${said.join('\n')}
|
|
|
906
857
|
};
|
|
907
858
|
return { svg, scene };
|
|
908
859
|
}
|
|
909
|
-
/** The lanes wrap plan at the width and text size of `opts`, or null if the figure does not draw as lanes. A rail sets a least width. */
|
|
910
|
-
function planFor(fig, opts) {
|
|
911
|
-
if (fig.timeline || !fig.lanes || !isLanesLayout(fig.layout))
|
|
912
|
-
return null;
|
|
913
|
-
const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
|
|
914
|
-
return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
|
|
915
|
-
}
|
|
916
860
|
/** One self-contained animated SVG string for the figure. Needs no React and no browser. */
|
|
917
861
|
export function toSvg(fig, opts = {}) {
|
|
918
862
|
return render(fig, opts).svg;
|
|
919
863
|
}
|
|
920
|
-
/** Every fault `flowfig check` knows about, for this figure as the SVG lays it out.
|
|
864
|
+
/** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. */
|
|
921
865
|
export function check(fig, opts = {}) {
|
|
922
|
-
|
|
923
|
-
rule: 'lane-end-block',
|
|
924
|
-
severity: 'warning',
|
|
925
|
-
ids: [id],
|
|
926
|
-
message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
|
|
927
|
-
}));
|
|
928
|
-
return [...checkSpec(fig), ...checkScene(render(fig, opts).scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
|
|
866
|
+
return checkRendered(fig, opts, render(fig, opts).scene);
|
|
929
867
|
}
|
package/dist/text.d.ts
CHANGED
|
@@ -1,4 +1,2 @@
|
|
|
1
|
-
/** The width of `s` in px at `fontSize`. */
|
|
2
1
|
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
2
|
export declare function wrap(s: string, width: number, fontSize: number, mono?: boolean): string[];
|
package/dist/text.js
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
//
|
|
2
|
-
// the system sans-serif stack. Monospace is exact at 0.6em. The React player measures real text instead.
|
|
1
|
+
// Estimated widths: no browser here. The React player measures real text.
|
|
3
2
|
const FULL = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]|\p{Extended_Pictographic}/u;
|
|
4
3
|
const NARROW = /[ilj.,:;'|!()[\]ftrI ]/;
|
|
5
4
|
const WIDE = /[WMmw@%]/;
|
|
6
5
|
const TALL = /[A-Z0-9#&?]/;
|
|
7
|
-
/** The width of `s` in px at `fontSize`. */
|
|
8
6
|
export function textWidth(s, fontSize, mono = false) {
|
|
9
7
|
let em = 0;
|
|
10
8
|
for (const ch of s)
|
|
11
9
|
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
10
|
return em * fontSize;
|
|
13
11
|
}
|
|
14
|
-
/** Break a string into lines that fit `width`, keeping the newlines it already has. */
|
|
15
12
|
export function wrap(s, width, fontSize, mono = false) {
|
|
16
13
|
const out = [];
|
|
17
14
|
for (const para of s.split('\n')) {
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FlowProps } from './model.ts';
|
|
2
2
|
import type { Finding } from './scene.ts';
|
|
3
3
|
export { parseSource, headingSlug, owners, links, type Link } from './source.ts';
|
|
4
|
-
/** The links against the files under `root`. A path outside
|
|
4
|
+
/** The links against the files under `root`. A path outside `root` counts as a missing file. */
|
|
5
5
|
export declare function verify(fig: FlowProps, { root }?: {
|
|
6
6
|
root?: string;
|
|
7
7
|
}): Finding[];
|
package/dist/verify.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
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
1
|
import { readFileSync, statSync } from 'node:fs';
|
|
3
2
|
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
3
|
import { headingSlug, links } from './source.js';
|
|
5
4
|
export { parseSource, headingSlug, owners, links } from './source.js';
|
|
6
5
|
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
7
|
-
/** The file text, or null for a missing path, a folder or an unreadable file. */
|
|
8
6
|
function read(full) {
|
|
9
7
|
try {
|
|
10
8
|
return statSync(full).isFile() ? readFileSync(full, 'utf8') : null;
|
|
@@ -13,12 +11,11 @@ function read(full) {
|
|
|
13
11
|
return null;
|
|
14
12
|
}
|
|
15
13
|
}
|
|
16
|
-
/** True if a heading line of the Markdown text has this slug. */
|
|
17
14
|
const hasHeading = (text, slug) => text.split('\n').some((line) => {
|
|
18
15
|
const m = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
|
|
19
16
|
return m != null && headingSlug(m[1]) === slug;
|
|
20
17
|
});
|
|
21
|
-
/** The links against the files under `root`. A path outside
|
|
18
|
+
/** The links against the files under `root`. A path outside `root` counts as a missing file. */
|
|
22
19
|
export function verify(fig, { root = process.cwd() } = {}) {
|
|
23
20
|
const out = [];
|
|
24
21
|
const all = links(fig);
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare const VERSION = "0.6.0";
|
|
1
|
+
export declare const VERSION = "0.7.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
export const VERSION = '0.
|
|
1
|
+
// The release list bumps this with package.json.
|
|
2
|
+
export const VERSION = '0.7.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowfig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
"format:check": "prettier --check .",
|
|
59
59
|
"svg": "node scripts/figure-svg.mjs",
|
|
60
60
|
"test": "npm run build && node --test src/*.test.ts",
|
|
61
|
-
"typecheck": "tsc --noEmit"
|
|
61
|
+
"typecheck": "tsc --noEmit",
|
|
62
|
+
"lint": "oxlint src scripts"
|
|
62
63
|
},
|
|
63
64
|
"peerDependencies": {
|
|
64
65
|
"react": ">=18"
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"@types/react": "^19.0.0",
|
|
72
73
|
"@types/react-dom": "^19.0.0",
|
|
73
74
|
"@vitejs/plugin-react": "^6.1.1",
|
|
75
|
+
"oxlint": "1.86.0",
|
|
74
76
|
"playwright": "^1.50.0",
|
|
75
77
|
"prettier": "^3.7.4",
|
|
76
78
|
"react": "^19.0.0",
|