flowfig 0.9.0 → 0.11.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 +309 -61
- package/dist/atlas.d.ts +6 -0
- package/dist/atlas.js +122 -0
- package/dist/auto.d.ts +3 -0
- package/dist/auto.js +397 -0
- package/dist/browser.js +3 -1
- package/dist/check.d.ts +2 -1
- package/dist/check.js +61 -19
- package/dist/cli.js +29 -6
- package/dist/coverage.js +10 -7
- package/dist/diff.d.ts +10 -0
- package/dist/diff.js +137 -7
- package/dist/geometry.d.ts +4 -0
- package/dist/geometry.js +45 -17
- package/dist/index.d.ts +1 -1
- package/dist/index.js +177 -83
- package/dist/load.js +4 -3
- package/dist/mermaid.d.ts +19 -0
- package/dist/mermaid.js +617 -0
- package/dist/model.d.ts +19 -6
- package/dist/model.js +60 -17
- package/dist/open.d.ts +1 -0
- package/dist/open.js +11 -8
- package/dist/page.d.ts +2 -1
- package/dist/page.js +5 -4
- package/dist/svg.d.ts +7 -2
- package/dist/svg.js +76 -26
- package/dist/trace.js +10 -3
- package/dist/verify.d.ts +2 -0
- package/dist/verify.js +36 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/model.js
CHANGED
|
@@ -95,7 +95,7 @@ export function laneColumns(fig) {
|
|
|
95
95
|
return cols;
|
|
96
96
|
}
|
|
97
97
|
export const LABEL_LINE = 18;
|
|
98
|
-
export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
|
|
98
|
+
export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190, DIAMOND_MAX_W = 170;
|
|
99
99
|
export const LANE_BLOCK_GAP = 40;
|
|
100
100
|
export const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
|
|
101
101
|
/** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
|
|
@@ -106,14 +106,16 @@ export function nodeWidth(item, carded, cap = NODE_MAX_W) {
|
|
|
106
106
|
return CARD_WIDTH;
|
|
107
107
|
const label = textWidth(str(item.label), 14) + 32;
|
|
108
108
|
const sub = textWidth(str(item.sub), 12) + 32;
|
|
109
|
-
return Math.min(item.shape === 'decision' ?
|
|
109
|
+
return Math.min(item.shape === 'decision' ? DIAMOND_MAX_W : cap, Math.max(NODE_MIN_W, label, sub));
|
|
110
110
|
}
|
|
111
111
|
export function labelLines(item, w) {
|
|
112
112
|
const label = str(item.label);
|
|
113
|
-
if (item.shape === 'decision'
|
|
113
|
+
if (item.shape === 'decision')
|
|
114
|
+
return wrap(label, w - 102 + 0.5, 14);
|
|
115
|
+
if (textWidth(label, 14) <= w - 32 + 0.5)
|
|
114
116
|
return [label];
|
|
115
117
|
const lines = wrap(label, w - 32, 14);
|
|
116
|
-
return lines.
|
|
118
|
+
return lines.every((l) => textWidth(l, 14) <= w - 32) ? lines : [label];
|
|
117
119
|
}
|
|
118
120
|
export function itemWidth(item, carded, edges, cap = NODE_MAX_W) {
|
|
119
121
|
if (!isGroup(item))
|
|
@@ -137,21 +139,28 @@ export function fitCap(fig, width, padding = 24) {
|
|
|
137
139
|
return cap;
|
|
138
140
|
}
|
|
139
141
|
export const diamondRoom = (w, h, far) => w * Math.max(0, 1 - far / (h / 2)) - 8;
|
|
140
|
-
/** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
|
|
141
142
|
export function diamondLines(item, w) {
|
|
142
143
|
const sub = item.sub ? str(item.sub) : '';
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
const label = labelLines(item, w);
|
|
145
|
+
const k = label.length - 1;
|
|
146
|
+
const at = (dy, size) => Math.max(Math.abs(dy - 0.75 * size), dy + 0.25 * size);
|
|
147
|
+
for (let n = sub ? 1 : 0, h = 0;;) {
|
|
148
|
+
h = Math.max(h, 62 + 15 * n + LABEL_LINE * k);
|
|
149
|
+
const dy0 = (n ? 5.5 : 5) - 7.5 * n - (LABEL_LINE / 2) * k;
|
|
150
|
+
const last = n ? at(dy0 + LABEL_LINE * k + 15 * n, 12) : 0;
|
|
148
151
|
const lines = n ? wrap(sub, diamondRoom(w, h, last), 12) : [];
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
const labels = label.map((text, i) => ({ text, dy: dy0 + LABEL_LINE * i, far: at(dy0 + LABEL_LINE * i, 14) }));
|
|
153
|
+
const subs = lines.map((text, j) => ({
|
|
154
|
+
text,
|
|
155
|
+
dy: dy0 + LABEL_LINE * k + 15 * (j + 1),
|
|
156
|
+
far: at(dy0 + LABEL_LINE * k + 15 * (j + 1), 12),
|
|
157
|
+
}));
|
|
158
|
+
if (lines.length > n && n < 6)
|
|
159
|
+
n++;
|
|
160
|
+
else if (h < w && labels.some((l) => textWidth(l.text, 14) > diamondRoom(w, h, l.far)))
|
|
161
|
+
h += 4;
|
|
162
|
+
else
|
|
163
|
+
return { h, label: labels, subs };
|
|
155
164
|
}
|
|
156
165
|
}
|
|
157
166
|
/** The lane copies whose stub end has no clear place. The renderer grows each by STUB_ROOM. */
|
|
@@ -451,7 +460,7 @@ export const counts = (p) => {
|
|
|
451
460
|
messages: st.reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
|
|
452
461
|
};
|
|
453
462
|
};
|
|
454
|
-
const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
|
|
463
|
+
export const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
|
|
455
464
|
const LINE_CLEAR = 16;
|
|
456
465
|
export function groupGap(g, edges) {
|
|
457
466
|
const side = new Map();
|
|
@@ -503,3 +512,37 @@ export const altText = (fig) => {
|
|
|
503
512
|
desc: [`The figure has these boxes: ${boxes.join(', ')}.`, ...lines].join('\n'),
|
|
504
513
|
};
|
|
505
514
|
};
|
|
515
|
+
const keyOf = (v, path) => {
|
|
516
|
+
if (typeof v === 'function' || typeof v === 'symbol')
|
|
517
|
+
return undefined;
|
|
518
|
+
if (typeof v === 'bigint')
|
|
519
|
+
return `${v}n`;
|
|
520
|
+
if (typeof v !== 'object' || v === null)
|
|
521
|
+
return v;
|
|
522
|
+
if (path.includes(v))
|
|
523
|
+
return '[cycle]';
|
|
524
|
+
const at = [...path, v];
|
|
525
|
+
if (Array.isArray(v))
|
|
526
|
+
return v.map((x) => keyOf(x, at));
|
|
527
|
+
const el = v;
|
|
528
|
+
const proto = Object.getPrototypeOf(v);
|
|
529
|
+
if (!el.$$typeof && proto !== Object.prototype && proto !== null)
|
|
530
|
+
return '[object]';
|
|
531
|
+
const src = el.$$typeof ? { type: typeof el.type === 'string' ? el.type : el.type?.name, key: el.key, props: el.props } : v;
|
|
532
|
+
return Object.fromEntries(Object.entries(src).flatMap(([k, x]) => (k === 'ref' || k.startsWith('_') ? [] : [[k, keyOf(x, at)]])));
|
|
533
|
+
};
|
|
534
|
+
export const specKey = (...parts) => JSON.stringify(keyOf(parts, []));
|
|
535
|
+
// Edge labels are 11 px, the smallest reader text at scale 1.
|
|
536
|
+
const READER_PX = 11;
|
|
537
|
+
const DESKTOP_PX = 9;
|
|
538
|
+
const DESKTOP_BOX = 600;
|
|
539
|
+
export const textFloor = (box) => (box < DESKTOP_BOX ? READER_PX : DESKTOP_PX);
|
|
540
|
+
export const fitScale = (wide, box, map) => Math.max(wide ? 1 : textFloor(box) / READER_PX, Math.min(1, box / map));
|
|
541
|
+
export const shownFont = (size, place, scale) => size * (place === 'map' ? scale.map : place === 'rail' ? scale.rail : 1);
|
|
542
|
+
const folds = (box, row) => box * READER_PX < row * textFloor(box);
|
|
543
|
+
export const nextWide = (wide, box, map) => {
|
|
544
|
+
const row = Math.abs(wide) || map;
|
|
545
|
+
if (!folds(box, row))
|
|
546
|
+
return 0;
|
|
547
|
+
return wide > 0 && map >= wide ? -wide : wide || map;
|
|
548
|
+
};
|
package/dist/open.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare function openerFor(platform: NodeJS.Platform, file: string): Open
|
|
|
7
7
|
/** The temp page name: each character other than a letter, digit, - or _ becomes -. */
|
|
8
8
|
export declare function pageName(svgPath: string): string;
|
|
9
9
|
export declare const openedLine: (page: string) => string;
|
|
10
|
+
export declare function openFile(path: string): Promise<void>;
|
|
10
11
|
/** Write the page to a new temp folder, then start the opener. Returns the written paths. */
|
|
11
12
|
export declare function openSvg(svgPath: string, html?: string): Promise<string[]>;
|
|
12
13
|
export declare function runOpen(argv: string[]): Promise<number>;
|
package/dist/open.js
CHANGED
|
@@ -20,6 +20,16 @@ export function pageName(svgPath) {
|
|
|
20
20
|
.replace(/[^\w-]/g, '-') + '.html');
|
|
21
21
|
}
|
|
22
22
|
export const openedLine = (page) => `${page} — opened in the default browser`;
|
|
23
|
+
export async function openFile(path) {
|
|
24
|
+
const o = process.env.FLOWFIG_OPENER
|
|
25
|
+
? { cmd: process.env.FLOWFIG_OPENER, args: [path], verbatim: false }
|
|
26
|
+
: openerFor(process.platform, path);
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
const child = spawn(o.cmd, o.args, { detached: true, windowsHide: true, stdio: 'ignore', windowsVerbatimArguments: o.verbatim });
|
|
29
|
+
child.once('spawn', () => (child.unref(), resolve()));
|
|
30
|
+
child.once('error', (e) => reject(new Error(`${e.message}. Open ${path} in a browser.`)));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
23
33
|
/** Write the page to a new temp folder, then start the opener. Returns the written paths. */
|
|
24
34
|
export async function openSvg(svgPath, html) {
|
|
25
35
|
const page = pageHtml(readFileSync(svgPath, 'utf8'), basename(svgPath));
|
|
@@ -30,14 +40,7 @@ export async function openSvg(svgPath, html) {
|
|
|
30
40
|
writeFileSync(temp, page);
|
|
31
41
|
if (html)
|
|
32
42
|
writeFileSync(html, page);
|
|
33
|
-
|
|
34
|
-
? { cmd: process.env.FLOWFIG_OPENER, args: [temp], verbatim: false }
|
|
35
|
-
: openerFor(process.platform, temp);
|
|
36
|
-
await new Promise((resolve, reject) => {
|
|
37
|
-
const child = spawn(o.cmd, o.args, { detached: true, windowsHide: true, stdio: 'ignore', windowsVerbatimArguments: o.verbatim });
|
|
38
|
-
child.once('spawn', () => (child.unref(), resolve()));
|
|
39
|
-
child.once('error', (e) => reject(new Error(`${e.message}. Open ${temp} in a browser.`)));
|
|
40
|
-
});
|
|
43
|
+
await openFile(temp);
|
|
41
44
|
return html ? [temp, html] : [temp];
|
|
42
45
|
}
|
|
43
46
|
export async function runOpen(argv) {
|
package/dist/page.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export declare
|
|
1
|
+
export declare const esc: (s: string) => string;
|
|
2
|
+
export declare function pageHtml(svg: string, title: string, before?: string, after?: string, head?: string): string;
|
package/dist/page.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2
|
-
export function pageHtml(svg, title) {
|
|
1
|
+
export const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2
|
+
export function pageHtml(svg, title, before = '', after = '', head = '') {
|
|
3
3
|
// Use element selectors only: figure styles use class names.
|
|
4
4
|
return `<!doctype html>
|
|
5
5
|
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
7
|
<title>${esc(title)}</title>
|
|
7
|
-
<style>
|
|
8
|
+
${head}<style>
|
|
8
9
|
:root { color-scheme: light dark }
|
|
9
10
|
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: Canvas }
|
|
10
11
|
svg { display: block; max-width: 100%; height: auto }
|
|
11
12
|
</style>
|
|
12
|
-
${svg}
|
|
13
|
+
${before}${svg}${after}
|
|
13
14
|
`;
|
|
14
15
|
}
|
package/dist/svg.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { type CheckOptions } from './check.ts';
|
|
2
2
|
import type { Finding, Scene } from './scene.ts';
|
|
3
|
+
import type { DiffMarks } from './diff.ts';
|
|
3
4
|
export type { CheckOptions } from './check.ts';
|
|
4
5
|
export type { Finding, Scene } from './scene.ts';
|
|
5
6
|
export type * from './model.ts';
|
|
6
7
|
export { parseSource, links, type Link } from './source.ts';
|
|
7
|
-
export { diff, formatDiff, type Change } from './diff.ts';
|
|
8
|
+
export { diff, formatDiff, mergeFigures, type Change, type DiffMark, type DiffMarks } from './diff.ts';
|
|
8
9
|
import { type FigTheme, type FlowProps } from './model.ts';
|
|
9
10
|
export { LIGHT, DARK } from './model.ts';
|
|
10
11
|
/** Options for `toSvg`, `render` and `check`. */
|
|
@@ -18,9 +19,13 @@ export type SvgOptions = {
|
|
|
18
19
|
/** Lanes: page width and smallest text size that set the wrap. Default: 830 and 10. */
|
|
19
20
|
width?: number;
|
|
20
21
|
minText?: number;
|
|
22
|
+
/** Draws a still diff with a legend. `mergeFigures` makes the marks. */
|
|
23
|
+
marks?: DiffMarks;
|
|
24
|
+
/** Links a box to an href, by box id. `flowfig atlas` sets it. */
|
|
25
|
+
links?: Record<string, string>;
|
|
21
26
|
};
|
|
22
27
|
/** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
|
|
23
|
-
export declare function render(
|
|
28
|
+
export declare function render(spec: FlowProps, opts?: SvgOptions): {
|
|
24
29
|
svg: string;
|
|
25
30
|
scene: Scene;
|
|
26
31
|
};
|
package/dist/svg.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
// Wrapping is approximate: text is measured by character class, not by a browser.
|
|
2
|
-
import { arcRoom, avoidOf, route } from './geometry.js';
|
|
2
|
+
import { arcRoom, avoidOf, markSide, route } from './geometry.js';
|
|
3
3
|
import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
|
|
4
4
|
import { textWidth, wrap } from './text.js';
|
|
5
5
|
import { checkRendered, planFor } from './check.js';
|
|
6
|
+
import { autoLayout, rowBreaks } from './auto.js';
|
|
6
7
|
export { parseSource, links } from './source.js';
|
|
7
|
-
export { diff, formatDiff } from './diff.js';
|
|
8
|
+
export { diff, formatDiff, mergeFigures } from './diff.js';
|
|
8
9
|
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, LABEL_LINE, itemWidth, labelLines, fitCap, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, altText, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, edgeTip, } from './model.js';
|
|
9
10
|
export { LIGHT, DARK } from './model.js';
|
|
10
11
|
const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
|
|
11
12
|
const SUB_LINE = 15;
|
|
13
|
+
const MIN_W = 320;
|
|
12
14
|
const FRAME_TOP = 37, FRAME_BOTTOM = 18;
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
+
const color = (t, k) => {
|
|
16
|
+
const v = String(t[k]);
|
|
17
|
+
const depth = [...v].reduce((d, c) => (d < 0 ? d : d + (c === '(' ? 1 : c === ')' ? -1 : 0)), 0);
|
|
18
|
+
return /^[#a-zA-Z0-9 ,.%()/+-]+$/.test(v) && !/url\(/i.test(v) && depth === 0 ? v : LIGHT[k];
|
|
19
|
+
};
|
|
20
|
+
const vars = (t, cardOn) => `--accent:${color(t, 'accent')}; --fg:${color(t, 'fg')}; --muted:${color(t, 'muted')}; --bg:${color(t, 'bg')}; --surface:${color(t, 'surface')}; --border:${color(t, 'border')}; --card-on:${cardOn}; --tint:color-mix(in srgb, var(--accent) 10%, var(--surface));`;
|
|
15
21
|
const content = (c) => (isRows(c) ? c : str(c));
|
|
16
22
|
const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c]);
|
|
17
23
|
const n2 = (v) => Math.round(v * 10) / 10;
|
|
@@ -159,13 +165,39 @@ function place(item, x, y, s, out) {
|
|
|
159
165
|
}
|
|
160
166
|
}
|
|
161
167
|
}
|
|
168
|
+
const MARK_COLOR = { added: TONES.green, removed: TONES.red, changed: TONES.orange };
|
|
169
|
+
const marked = (mark, inner) => mark === 'removed' ? `<g opacity="0.5" stroke-dasharray="5 4">${inner}</g>` : mark ? `<g stroke-width="2">${inner}</g>` : inner;
|
|
170
|
+
function legendOf(m) {
|
|
171
|
+
const count = (kind) => Object.values(m.boxes).filter((k) => k === kind).length + Object.values(m.edges).filter((k) => k === kind).length;
|
|
172
|
+
const items = ['added', 'removed', 'changed'].filter(count).map((k) => ({ color: MARK_COLOR[k], text: `${k} ${count(k)}` }));
|
|
173
|
+
if (m.steps)
|
|
174
|
+
items.push({ color: 'none', text: 'steps: see the text diff' });
|
|
175
|
+
return items.length ? items : [{ color: 'none', text: 'no change in boxes and edges' }];
|
|
176
|
+
}
|
|
177
|
+
const legendWidths = (items) => items.map((i) => textWidth(i.text, 12) + (i.color === 'none' ? 0 : 16));
|
|
178
|
+
const legendWidth = (items) => legendWidths(items).reduce((a, b) => a + b + 20, -20);
|
|
179
|
+
function legendSvg(items, W, H) {
|
|
180
|
+
if (!items.length)
|
|
181
|
+
return '';
|
|
182
|
+
const widths = legendWidths(items);
|
|
183
|
+
let x = (W - legendWidth(items)) / 2;
|
|
184
|
+
return items
|
|
185
|
+
.map((i, k) => {
|
|
186
|
+
const sq = i.color === 'none' ? '' : `<rect x="${n2(x)}" y="${n2(H - 24)}" width="10" height="10" rx="2" fill="${i.color}"/>`;
|
|
187
|
+
const out = `${sq}<text x="${n2(x + (i.color === 'none' ? 0 : 16))}" y="${n2(H - 15)}" font-size="12" fill="var(--muted)">${esc(i.text)}</text>\n`;
|
|
188
|
+
x += widths[k] + 20;
|
|
189
|
+
return out;
|
|
190
|
+
})
|
|
191
|
+
.join('');
|
|
192
|
+
}
|
|
162
193
|
const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
|
163
194
|
/** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
|
|
164
|
-
export function render(
|
|
195
|
+
export function render(spec, opts = {}) {
|
|
196
|
+
const fig = autoLayout(spec);
|
|
165
197
|
const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
|
|
166
198
|
const pad = opts.padding ?? 24;
|
|
167
199
|
const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
|
|
168
|
-
const synthetic = tl != null && !fig.steps?.length;
|
|
200
|
+
const synthetic = tl != null && !fig.steps?.length && !opts.marks;
|
|
169
201
|
const steps = synthetic ? timelineBeats(fig) : (fig.steps ?? []);
|
|
170
202
|
const beats = steps.map((s) => s.flow.map(toBeat));
|
|
171
203
|
const cards = new Map();
|
|
@@ -233,6 +265,7 @@ export function render(fig, opts = {}) {
|
|
|
233
265
|
to: end(ids[i], e.to, false),
|
|
234
266
|
around: e.around ?? (plan?.around.has(ids[i]) ? 'below' : undefined),
|
|
235
267
|
...(tl && { sides: ['r', 'l'], elbow: true }),
|
|
268
|
+
...(rowBreaks.has(e) && { drop: true }),
|
|
236
269
|
...(stubs.has(ids[i]) && { stub: stubs.get(ids[i]).map(labelPillW), bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)] }),
|
|
237
270
|
...(!tl && e.label != null && { labelW: labelPillW(str(e.label)) }),
|
|
238
271
|
})), rects, tips, avoidOf(placed.filter((p) => !isGroup(p.item)), stubs.size
|
|
@@ -264,10 +297,10 @@ export function render(fig, opts = {}) {
|
|
|
264
297
|
const dl = dia ? diamondLines(p.item, p.w) : null;
|
|
265
298
|
const room = (far) => diamondRoom(p.w, p.h, far);
|
|
266
299
|
const flat = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
|
|
267
|
-
const texts = labelLines(p.item, p.w).map((text) => ({
|
|
300
|
+
const texts = labelLines(p.item, p.w).map((text, i) => ({
|
|
268
301
|
text,
|
|
269
302
|
fontSize: 14,
|
|
270
|
-
room: dl ? room(dl.label.far) : flat,
|
|
303
|
+
room: dl ? room(dl.label[i].far) : flat,
|
|
271
304
|
}));
|
|
272
305
|
if (dl)
|
|
273
306
|
for (const l of dl.subs)
|
|
@@ -451,8 +484,13 @@ export function render(fig, opts = {}) {
|
|
|
451
484
|
cls(p.lane && 'lane', anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
|
|
452
485
|
`/><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>`);
|
|
453
486
|
}
|
|
454
|
-
const
|
|
455
|
-
const
|
|
487
|
+
const diffMark = opts.marks?.boxes[item.id];
|
|
488
|
+
const href = opts.links?.[item.id];
|
|
489
|
+
const tip = (inner) => {
|
|
490
|
+
const body = marked(diffMark, item.source ? `<g><title>${esc(item.source)}</title>${inner}</g>` : inner);
|
|
491
|
+
return href == null ? body : `<a href="${esc(href)}" tabindex="-1">${body}</a>`;
|
|
492
|
+
};
|
|
493
|
+
const bt = diffMark ? MARK_COLOR[diffMark] : item.tone && TONES[item.tone];
|
|
456
494
|
const stroke = cls(boxAnim(item.id, true, bt));
|
|
457
495
|
if (p.tl) {
|
|
458
496
|
const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
|
|
@@ -473,7 +511,7 @@ export function render(fig, opts = {}) {
|
|
|
473
511
|
const contents = cards.get(item.id);
|
|
474
512
|
const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
|
|
475
513
|
const dl = item.shape === 'decision' && !contents ? diamondLines(item, p.w) : null;
|
|
476
|
-
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);
|
|
514
|
+
const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (dl ? dl.label[0].dy : item.sub ? -2 : 5);
|
|
477
515
|
const shape = item.shape === 'decision'
|
|
478
516
|
? `<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}/>`
|
|
479
517
|
: item.shape === 'store'
|
|
@@ -482,7 +520,7 @@ export function render(fig, opts = {}) {
|
|
|
482
520
|
: `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
|
|
483
521
|
const lines = labelLines(item, p.w);
|
|
484
522
|
const extra = (lines.length - 1) * LABEL_LINE;
|
|
485
|
-
const firstY = item.shape === 'store' || contents ? labelY : labelY - extra / 2;
|
|
523
|
+
const firstY = item.shape === 'store' || contents || dl ? labelY : labelY - extra / 2;
|
|
486
524
|
const label = lines.map((l, i) => `<text x="${n2(cx)}" y="${n2(firstY + i * LABEL_LINE)}" class="label">${esc(l)}</text>`).join('');
|
|
487
525
|
const sub = dl
|
|
488
526
|
? dl.subs.map((l) => `<text x="${n2(cx)}" y="${n2(p.y + p.h / 2 + l.dy)}" class="sub">${esc(l.text)}</text>`).join('')
|
|
@@ -490,11 +528,13 @@ export function render(fig, opts = {}) {
|
|
|
490
528
|
? `<text x="${n2(cx)}" y="${n2(firstY + extra + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>`
|
|
491
529
|
: '';
|
|
492
530
|
const dot = bt ?? 'var(--accent)';
|
|
493
|
-
const
|
|
531
|
+
const side = item.mark && markSide(item.id, item.mark, routed);
|
|
532
|
+
const mx = n2(side === 'l' ? p.x - 12 : side === 'r' ? p.x + p.w + 12 : cx);
|
|
533
|
+
const my = n2(side === 't' ? p.y - 12 : side === 'b' ? p.y + p.h + 12 : p.y + p.h / 2);
|
|
494
534
|
const mark = item.mark === 'start'
|
|
495
|
-
? `<circle cx="${
|
|
535
|
+
? `<circle cx="${mx}" cy="${my}" r="5" fill="${dot}"/>`
|
|
496
536
|
: item.mark === 'end'
|
|
497
|
-
? `<circle cx="${
|
|
537
|
+
? `<circle cx="${mx}" cy="${my}" r="6.25" fill="none" stroke="${dot}" stroke-width="1.5"/><circle cx="${mx}" cy="${my}" r="4" fill="${dot}"/>`
|
|
498
538
|
: '';
|
|
499
539
|
return tip(shape + label + sub + mark + (contents ? card(p, cardTop, contents) : ''));
|
|
500
540
|
});
|
|
@@ -562,18 +602,21 @@ export function render(fig, opts = {}) {
|
|
|
562
602
|
const lit = cls(toned
|
|
563
603
|
? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
|
|
564
604
|
: anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
|
|
605
|
+
const em = opts.marks?.edges[r.id];
|
|
606
|
+
const line = em && (em !== 'changed' || e.label == null) ? MARK_COLOR[em] : 'var(--muted)';
|
|
565
607
|
const tip = edgeTip(r.id, e.source, beats.flat());
|
|
566
608
|
const path0 = r.stub
|
|
567
609
|
? r.stub.parts
|
|
568
|
-
.map((d) => `<path d="${d}" fill="none" stroke="
|
|
610
|
+
.map((d) => `<path d="${d}" fill="none" stroke="${line}" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
|
|
569
611
|
.join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
|
|
570
|
-
: `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="
|
|
612
|
+
: `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="${line}" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
|
|
571
613
|
const path = tip ? `<g><title>${esc(tip)}</title>${path0}</g>` : path0;
|
|
572
|
-
const
|
|
614
|
+
const pillColor = em ? MARK_COLOR[em] : undefined;
|
|
615
|
+
const pill = (x, y, lw, text) => `<rect x="${n2(x - lw / 2)}" y="${n2(y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="${pillColor ? toneTint(pillColor, 'var(--bg)', 14) : 'var(--bg)'}" stroke="${pillColor ?? 'var(--border)'}"` +
|
|
573
616
|
cls(toned
|
|
574
617
|
? 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)')
|
|
575
618
|
: anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
|
|
576
|
-
`/><text x="${n2(x)}" y="${n2(y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(text)}</text>`;
|
|
619
|
+
`/><text x="${n2(x)}" y="${n2(y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}${pillColor ? ' style="fill: var(--fg)"' : ''}>${esc(text)}</text>`;
|
|
577
620
|
let label = '';
|
|
578
621
|
if (r.stub) {
|
|
579
622
|
fonts.push(11);
|
|
@@ -586,7 +629,7 @@ export function render(fig, opts = {}) {
|
|
|
586
629
|
fonts.push(11);
|
|
587
630
|
label = pill(r.mid.x, r.mid.y, lw, str(e.label));
|
|
588
631
|
}
|
|
589
|
-
return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
|
|
632
|
+
return marked(em === 'removed' ? em : undefined, hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label);
|
|
590
633
|
});
|
|
591
634
|
const packets = hops.map((h, i) => {
|
|
592
635
|
const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
|
|
@@ -683,16 +726,21 @@ export function render(fig, opts = {}) {
|
|
|
683
726
|
}
|
|
684
727
|
const bounds = placed[0];
|
|
685
728
|
const [arcT, arcB, arcL, arcR] = ['above', 'below', 'left', 'right'].map((w) => arcRoom(routed, w));
|
|
686
|
-
const
|
|
687
|
-
const
|
|
729
|
+
const legend = opts.marks ? legendOf(opts.marks) : [];
|
|
730
|
+
const stepLabels = fig.rail || synthetic ? [] : steps.map((s) => textWidth(str(s.label), 13, true));
|
|
731
|
+
const floor = Math.max(MIN_W, ...[...stepLabels, legend.length ? legendWidth(legend) : 0].map((w) => w + 16));
|
|
732
|
+
const mapW = Math.max(bounds.w + pad * 2 + arcL + arcR, floor);
|
|
688
733
|
const rail = fig.rail ? layoutRail(fig, fig.rail === 'only' ? 560 : mapW) : null;
|
|
689
734
|
const only = fig.rail === 'only' && rail != null;
|
|
735
|
+
const wrapW = Math.max(only ? 560 : MIN_W, bounds.w);
|
|
736
|
+
const capLines = [...new Set(captions)].flatMap((c) => wrap(c, wrapW, 13.5).length);
|
|
690
737
|
const capTop = rail ? 20 : 26;
|
|
691
738
|
const capH = steps.length ? capTop + 4 + Math.max(0, ...capLines) * 20 : 0;
|
|
692
|
-
const W = only ? Math.max(560, rail.width) : Math.max(mapW, rail?.width ?? 0);
|
|
739
|
+
const W = only ? Math.max(560, floor, rail.width) : Math.max(mapW, rail?.width ?? 0);
|
|
693
740
|
const mapH = only ? 0 : bounds.h + pad * 2 + arcT + arcB;
|
|
694
741
|
const top = only ? pad : mapH + RAIL.gap;
|
|
695
|
-
const
|
|
742
|
+
const legendH = opts.marks ? 34 : 0;
|
|
743
|
+
const H = (only ? pad + rail.height + capH : mapH + (rail ? RAIL.gap + rail.height : 0) + capH) + legendH;
|
|
696
744
|
const shift = (W - (bounds.w + pad * 2 + arcL + arcR)) / 2 + arcL;
|
|
697
745
|
const railX = rail ? (W - rail.width) / 2 : 0;
|
|
698
746
|
const labels = rail || synthetic
|
|
@@ -703,7 +751,7 @@ export function render(fig, opts = {}) {
|
|
|
703
751
|
});
|
|
704
752
|
const said = [...new Set(captions)].map((text) => {
|
|
705
753
|
const on = captions.map((c) => c === text);
|
|
706
|
-
const lines = wrap(text,
|
|
754
|
+
const lines = wrap(text, wrapW, 13.5);
|
|
707
755
|
return (`<g opacity="0"${cls(anim(on, 'opacity: 1', 'opacity: 0', 'y'))}>` +
|
|
708
756
|
lines.map((l, li) => `<text x="${n2(W / 2)}" y="${n2(H - capH + capTop + 16 + li * 20)}" class="caption">${esc(l)}</text>`).join('') +
|
|
709
757
|
`</g>`);
|
|
@@ -828,10 +876,12 @@ ${todaySvg}
|
|
|
828
876
|
`}${railSvg}
|
|
829
877
|
${labels.join('\n')}
|
|
830
878
|
${said.join('\n')}
|
|
831
|
-
</svg>
|
|
879
|
+
${legendSvg(legend, W, H)}</svg>
|
|
832
880
|
`;
|
|
833
881
|
if (rail)
|
|
834
882
|
fonts.push(12, 13);
|
|
883
|
+
if (legend.length)
|
|
884
|
+
fonts.push(12);
|
|
835
885
|
const scene = {
|
|
836
886
|
width: n2(W),
|
|
837
887
|
...(!only && { area: { x: -shift, y: -arcT, w: W, h: mapH } }),
|
package/dist/trace.js
CHANGED
|
@@ -258,11 +258,18 @@ export function trace(start, { root = process.cwd(), depth = 2, max = 40, read =
|
|
|
258
258
|
}
|
|
259
259
|
const count = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
|
|
260
260
|
export function traceLines(r, depth, max) {
|
|
261
|
-
const
|
|
262
|
-
const
|
|
261
|
+
const file = (id) => id.slice(0, id.indexOf('#'));
|
|
262
|
+
const lineNo = (e) => (e.at.startsWith(`${file(e.from)}:`) ? e.at.slice(file(e.from).length + 1) : e.at);
|
|
263
|
+
const width = Math.max(0, ...r.edges.map((e) => lineNo(e).length));
|
|
263
264
|
const symbols = new Set([r.start, ...r.edges.map((e) => e.to)]).size;
|
|
265
|
+
let last = '';
|
|
264
266
|
return [
|
|
265
|
-
...r.edges.
|
|
267
|
+
...r.edges.flatMap((e) => {
|
|
268
|
+
const head = e.from === last ? [] : [e.from];
|
|
269
|
+
last = e.from;
|
|
270
|
+
const to = file(e.to) === file(e.from) ? e.to.slice(e.to.indexOf('#') + 1) : e.to;
|
|
271
|
+
return [...head, ` ${lineNo(e).padEnd(width)} ${to}`];
|
|
272
|
+
}),
|
|
266
273
|
...r.unsure.map((u) => `unsure ${u.at} ${u.reason}`),
|
|
267
274
|
...r.open.map((o) => `open ${o.at} ${o.reason}`),
|
|
268
275
|
...r.stops.map((s) => `stop ${s.reason} ${s.reason === 'depth' ? depth : max}: ${count(s.count, 'symbol')} not followed`),
|
package/dist/verify.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type Coverage = {
|
|
|
16
16
|
};
|
|
17
17
|
export declare const coverageLine: (figure: string, c: Coverage) => string;
|
|
18
18
|
export declare const unsureLines: (c: Coverage) => string[];
|
|
19
|
+
export declare const SPEC_MARK = "<metadata id=\"figure-spec\">";
|
|
20
|
+
export declare function detailFindings(fig: FlowProps, root: string, read?: Read): Finding[];
|
|
19
21
|
/** The links against the files under `root`. A path outside `root` counts as a missing file. */
|
|
20
22
|
export declare function verifyReport(fig: FlowProps, { root, read, cache, }?: {
|
|
21
23
|
root?: string;
|
package/dist/verify.js
CHANGED
|
@@ -3,8 +3,25 @@ import { edgeId, nodes, toBeat } from './model.js';
|
|
|
3
3
|
import { headingSlug, links } from './source.js';
|
|
4
4
|
import { codeFile, isDefined, outside, readFile } from './code.js';
|
|
5
5
|
import { edgeResult } from './edges.js';
|
|
6
|
+
import { reexports } from './imports.js';
|
|
6
7
|
export { parseSource, headingSlug, owners, links } from './source.js';
|
|
7
8
|
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
9
|
+
const defines = (root, file, symbol, read, cache, depth = 0) => {
|
|
10
|
+
if (!file || depth > 5)
|
|
11
|
+
return null;
|
|
12
|
+
if (isDefined(file, symbol))
|
|
13
|
+
return true;
|
|
14
|
+
let doubt = false;
|
|
15
|
+
for (const [path, inner] of reexports(root, file, symbol, read)) {
|
|
16
|
+
const target = path == null || inner === '?' ? null : codeFile(root, path, read, cache);
|
|
17
|
+
const r = inner === '*' ? (target ? true : null) : defines(root, target, inner, read, cache, depth + 1);
|
|
18
|
+
if (r)
|
|
19
|
+
return true;
|
|
20
|
+
if (r == null)
|
|
21
|
+
doubt = true;
|
|
22
|
+
}
|
|
23
|
+
return doubt ? null : false;
|
|
24
|
+
};
|
|
8
25
|
const hasHeading = (text, slug) => text.split('\n').some((line) => {
|
|
9
26
|
const m = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
|
|
10
27
|
return m != null && headingSlug(m[1]) === slug;
|
|
@@ -15,14 +32,27 @@ export const coverageLine = (figure, c) => `${figure}: ${c.boxesDefined} of ${c.
|
|
|
15
32
|
? `; edges: ${c.found} found, ${c.notFound} not found, ${c.unsure} unsure, ${c.notChecked} not checked`
|
|
16
33
|
: '');
|
|
17
34
|
export const unsureLines = (c) => c.unsureEdges.map((e) => `unsure edge "${e.id}": ${e.reason}`);
|
|
35
|
+
export const SPEC_MARK = '<metadata id="figure-spec">';
|
|
36
|
+
export function detailFindings(fig, root, read = readFile) {
|
|
37
|
+
return nodes(fig.layout).flatMap((n) => {
|
|
38
|
+
if (typeof n.detail !== 'string')
|
|
39
|
+
return [];
|
|
40
|
+
const text = outside(root, n.detail) ? null : read(resolve(root, n.detail));
|
|
41
|
+
const why = text == null ? 'file not found' : text.includes(SPEC_MARK) ? '' : 'no figure spec in the file';
|
|
42
|
+
return why
|
|
43
|
+
? [{ rule: 'missing-detail', severity: 'warning', ids: [n.id], message: `box "${n.id}" -> ${n.detail}: ${why}` }]
|
|
44
|
+
: [];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
18
47
|
/** The links against the files under `root`. A path outside `root` counts as a missing file. */
|
|
19
48
|
export function verifyReport(fig, { root = process.cwd(), read = readFile, cache = new Map(), } = {}) {
|
|
20
49
|
const findings = [];
|
|
21
50
|
const coverage = emptyCoverage();
|
|
51
|
+
findings.push(...detailFindings(fig, root, read));
|
|
22
52
|
const all = links(fig);
|
|
23
53
|
if (!all.length)
|
|
24
54
|
return {
|
|
25
|
-
findings: [{ rule: 'no-source', severity: 'warning', ids: [], message: 'no box, edge or hop has a source' }],
|
|
55
|
+
findings: [...findings, { rule: 'no-source', severity: 'warning', ids: [], message: 'no box, edge or hop has a source' }],
|
|
26
56
|
coverage,
|
|
27
57
|
};
|
|
28
58
|
const files = new Map();
|
|
@@ -32,14 +62,17 @@ export function verifyReport(fig, { root = process.cwd(), read = readFile, cache
|
|
|
32
62
|
files.set(full, outside(root, l.path) ? null : read(full));
|
|
33
63
|
const text = files.get(full);
|
|
34
64
|
const before = findings.length;
|
|
65
|
+
let doubt = false;
|
|
35
66
|
const fail = (rule, what) => findings.push({ rule, severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: ${what}` });
|
|
36
67
|
if (text == null)
|
|
37
68
|
fail('missing-file', 'file not found');
|
|
38
69
|
else if (l.symbol) {
|
|
39
70
|
const code = codeFile(root, l.path, read, cache);
|
|
40
71
|
if (code) {
|
|
41
|
-
|
|
72
|
+
const r = defines(root, code, l.symbol, read, cache);
|
|
73
|
+
if (r === false)
|
|
42
74
|
fail('missing-symbol', 'symbol not defined');
|
|
75
|
+
doubt = r == null;
|
|
43
76
|
}
|
|
44
77
|
else if (!new RegExp(`(^|[^\\w$])${escape(l.symbol)}(?![\\w$])`).test(text) &&
|
|
45
78
|
!(/\.(md|markdown)$/i.test(l.path) && hasHeading(text, l.symbol)))
|
|
@@ -47,7 +80,7 @@ export function verifyReport(fig, { root = process.cwd(), read = readFile, cache
|
|
|
47
80
|
}
|
|
48
81
|
if (l.owner.startsWith('box "')) {
|
|
49
82
|
coverage.boxes++;
|
|
50
|
-
if (findings.length === before)
|
|
83
|
+
if (findings.length === before && !doubt)
|
|
51
84
|
coverage.boxesDefined++;
|
|
52
85
|
}
|
|
53
86
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.11.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// The release list bumps this with package.json.
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.11.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowfig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Your coding agent draws animated diagrams from your code, and flowfig checks each box and arrow against that code. CI fails when a diagram drifts from the code. The output is one animated SVG with no script, for a README, PR or issue. Works with Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|