flowfig 0.7.0 → 0.8.1

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.
@@ -0,0 +1,11 @@
1
+ import { type CodeFile, type Read } from './code.ts';
2
+ export type Import = {
3
+ name: string;
4
+ local: string;
5
+ path: string | null;
6
+ };
7
+ export declare const OUTSIDE = "#outside";
8
+ export declare function resolveTs(root: string, from: string, spec: string, read: Read): string | null;
9
+ export declare function resolvePy(root: string, from: string, mod: string, read: Read, top?: string): string | null;
10
+ export declare function importsOf(root: string, file: CodeFile, read: Read): Import[];
11
+ export declare function leadsTo(root: string, fromPath: string, toPath: string, name: string, read?: Read, cache?: Map<string, CodeFile | null>, depth?: number): string | null;
@@ -0,0 +1,235 @@
1
+ import { builtinModules } from 'node:module';
2
+ import { posix, resolve } from 'node:path';
3
+ import { codeFile, esc, langOf, outside, readFile } from './code.js';
4
+ export const OUTSIDE = '#outside';
5
+ const TS_EXT = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx', '/index.js'];
6
+ const readIn = (root, path, read) => (outside(root, path) ? null : read(resolve(root, path)));
7
+ const has = (root, path, read) => readIn(root, path, read) != null;
8
+ function tsConfig(root, read) {
9
+ const paths = [];
10
+ let baseUrl;
11
+ let cfg = 'tsconfig.json';
12
+ for (let k = 0; k < 4 && cfg; k++) {
13
+ const raw = readIn(root, cfg, read);
14
+ if (raw == null)
15
+ break;
16
+ const dir = posix.dirname(cfg);
17
+ const own = /"baseUrl"\s*:\s*"([^"]*)"/.exec(raw)?.[1];
18
+ if (own != null)
19
+ baseUrl ??= posix.join(dir, own);
20
+ const block = /"paths"\s*:\s*\{([^}]*)\}/.exec(raw)?.[1] ?? '';
21
+ for (const m of block.matchAll(/"([^"]+)"\s*:\s*\[\s*"([^"]+)"/g))
22
+ paths.push({ base: own != null ? posix.join(dir, own) : dir, key: m[1], target: m[2] });
23
+ const ext = /"extends"\s*:\s*"(\.[^"]+)"/.exec(raw)?.[1];
24
+ cfg = ext ? posix.join(dir, ext.endsWith('.json') ? ext : `${ext}.json`) : '';
25
+ }
26
+ return { paths, baseUrl };
27
+ }
28
+ export function resolveTs(root, from, spec, read) {
29
+ const bases = [];
30
+ if (spec.startsWith('.'))
31
+ bases.push(posix.join(posix.dirname(from), spec));
32
+ else {
33
+ const cfg = tsConfig(root, read);
34
+ for (const { base, key, target } of cfg.paths) {
35
+ const star = key.indexOf('*');
36
+ const pre = star < 0 ? key : key.slice(0, star);
37
+ const post = star < 0 ? '' : key.slice(star + 1);
38
+ if (star < 0 ? spec !== key : !(spec.startsWith(pre) && spec.endsWith(post) && spec.length >= key.length - 1))
39
+ continue;
40
+ bases.push(posix.join(base, star < 0 ? target : target.replace('*', spec.slice(pre.length, spec.length - post.length))));
41
+ }
42
+ if (cfg.baseUrl != null)
43
+ bases.push(posix.join(cfg.baseUrl, spec));
44
+ }
45
+ for (const b of bases) {
46
+ const base = b.replace(/\.(js|mjs|cjs|jsx)$/, '');
47
+ const hit = TS_EXT.map((e) => base + e).find((p) => has(root, p, read));
48
+ if (hit)
49
+ return hit;
50
+ }
51
+ return spec.startsWith('node:') || builtinModules.includes(spec.split('/')[0]) ? OUTSIDE : null;
52
+ }
53
+ function pyTop(root, from, read) {
54
+ let d = posix.dirname(from);
55
+ while (d !== '.' && has(root, `${d}/__init__.py`, read))
56
+ d = posix.dirname(d);
57
+ return d;
58
+ }
59
+ export function resolvePy(root, from, mod, read, top) {
60
+ const hit = (b) => [`${b}.py`, `${b}/__init__.py`].find((p) => has(root, p, read)) ?? null;
61
+ if (mod.startsWith('.')) {
62
+ const dots = /^\.+/.exec(mod)[0].length;
63
+ let d = posix.dirname(from);
64
+ for (let k = 1; k < dots; k++) {
65
+ if (d === '.')
66
+ return null;
67
+ d = posix.dirname(d);
68
+ }
69
+ return hit(posix.join(d, mod.slice(dots).replace(/\./g, '/')));
70
+ }
71
+ const rel = mod.replace(/\./g, '/');
72
+ const d = top ?? pyTop(root, from, read);
73
+ return hit(rel) ?? (d === '.' ? null : hit(posix.join(d, rel)));
74
+ }
75
+ function goModule(root, from, read) {
76
+ let dir = posix.dirname(from);
77
+ for (;;) {
78
+ const text = readIn(root, posix.join(dir, 'go.mod'), read);
79
+ if (text != null)
80
+ return { mod: /module\s+(\S+)/.exec(text)?.[1], dir, text };
81
+ if (dir === '.')
82
+ return { dir, text: '' };
83
+ dir = posix.dirname(dir);
84
+ }
85
+ }
86
+ function goWork(root, from, read) {
87
+ for (let d = posix.dirname(from);; d = posix.dirname(d)) {
88
+ const text = readIn(root, posix.join(d, 'go.work'), read);
89
+ if (text != null)
90
+ return [...text.matchAll(/^\s*(?:use\s+)?"?(\.[^\s")]*)"?\s*$/gm)].flatMap((m) => {
91
+ const dir = posix.join(d, m[1]);
92
+ const mod = /module\s+(\S+)/.exec(readIn(root, posix.join(dir, 'go.mod'), read) ?? '')?.[1];
93
+ return mod ? [{ mod, dir }] : [];
94
+ });
95
+ if (d === '.')
96
+ return [];
97
+ }
98
+ }
99
+ const goRequired = (text, p) => [...text.matchAll(/^\s*(?:require\s+)?([\w.~-]+\/[\w.~/-]+)\s+v\S+/gm)].some((m) => (p === m[1] || p.startsWith(`${m[1]}/`)) && !new RegExp(`^\\s*(?:replace\\s+)?${esc(m[1])}\\b[^\\n]*=>`, 'm').test(text));
100
+ export function importsOf(root, file, read) {
101
+ const { keep, lang, path } = file;
102
+ const out = [];
103
+ const add = (name, local, to) => out.push({ name, local, path: to });
104
+ if (lang === 'ts') {
105
+ for (const m of keep.matchAll(/\bimport\s+(type\s+)?([\s\S]*?)\s+from\s+(["'])([^"']+)\3/g)) {
106
+ const to = resolveTs(root, path, m[4], read);
107
+ const clause = m[2];
108
+ const def = /^([A-Za-z_$][\w$]*)\s*(,|$)/.exec(clause);
109
+ if (def)
110
+ add('default', def[1], to);
111
+ const ns = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause);
112
+ if (ns)
113
+ add('*', ns[1], to);
114
+ const named = /\{([\s\S]*)\}/.exec(clause);
115
+ if (named)
116
+ for (const part of named[1].split(',')) {
117
+ const p = part.trim().replace(/^type\s+/, '');
118
+ if (!p)
119
+ continue;
120
+ const [a, b] = p.split(/\s+as\s+/);
121
+ add(a.trim(), (b ?? a).trim(), to);
122
+ }
123
+ }
124
+ for (const m of keep.matchAll(/\b(?:const|let|var)\s+(\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?(?:require|import)\((["'])([^"']+)\2\)/g)) {
125
+ const to = resolveTs(root, path, m[3], read);
126
+ if (m[1].startsWith('{'))
127
+ for (const p of m[1].slice(1, -1).split(',')) {
128
+ const [a, b] = p.split(':').map((x) => x.trim());
129
+ if (a)
130
+ add(a, b ?? a, to);
131
+ }
132
+ else
133
+ add('*', m[1], to);
134
+ }
135
+ }
136
+ else if (lang === 'py') {
137
+ const top = pyTop(root, path, read);
138
+ for (const m of keep.matchAll(/^[ \t]*from\s+([\w.]+)\s+import\s+(\([^)]*\)|[^\n]+)/gm)) {
139
+ const to = resolvePy(root, path, m[1], read, top);
140
+ for (const part of m[2].replace(/[()]/g, '').split(',')) {
141
+ const p = part.trim();
142
+ if (!p)
143
+ continue;
144
+ const [a, b] = p.split(/\s+as\s+/).map((x) => x.trim());
145
+ const sub = resolvePy(root, path, (m[1].endsWith('.') ? m[1] : `${m[1]}.`) + a, read, top);
146
+ if (sub)
147
+ add('*', b ?? a, sub);
148
+ else
149
+ add(a, b ?? a, to);
150
+ }
151
+ }
152
+ for (const m of keep.matchAll(/^[ \t]*import\s+([\w.]+)(\s+as\s+(\w+))?/gm))
153
+ add('*', m[3] ?? m[1].split('.')[0], resolvePy(root, path, m[1], read, top));
154
+ }
155
+ else if (lang === 'go') {
156
+ const { mod, dir, text } = goModule(root, path, read);
157
+ let work;
158
+ for (const m of keep.matchAll(/^\s*(?:import\s+)?(\w+|\.|_)?\s*"([^"]+)"/gm)) {
159
+ const p = m[2];
160
+ const inside = (w) => w.mod && (p === w.mod || p.startsWith(`${w.mod}/`));
161
+ const own = [
162
+ { mod, dir },
163
+ ...(p.split('/')[0].includes('.') && !inside({ mod, dir }) ? (work ??= goWork(root, path, read)) : []),
164
+ ].find(inside);
165
+ const to = own
166
+ ? posix.join(own.dir, p.slice(own.mod.length + 1))
167
+ : p.split('/')[0].includes('.') && !goRequired(text, p)
168
+ ? null
169
+ : OUTSIDE;
170
+ add('*', m[1] ?? p.split('/').at(-1), to);
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+ function reexports(root, file, name, read) {
176
+ const N = esc(name);
177
+ const out = [];
178
+ if (name === '*')
179
+ return out;
180
+ if (file.lang === 'py') {
181
+ if (new RegExp(`^(?:(?:async\\s+)?def|class)\\s+${N}\\b|^${N}\\s*[:=]`, 'm').test(file.code))
182
+ return out;
183
+ for (const i of importsOf(root, file, read))
184
+ if (i.local === name || i.local === '*')
185
+ out.push([i.path, i.local === '*' ? '?' : i.name]);
186
+ return out.length > 1 ? [[null, '?']] : out;
187
+ }
188
+ if (file.lang !== 'ts')
189
+ return out;
190
+ if (new RegExp(`^export\\s+(?:default\\s+)?(?:async\\s+)?(?:function\\s*\\*?|class|const|let|var|enum)\\s*${N}\\b`, 'm').test(file.code))
191
+ return out;
192
+ for (const m of file.keep.matchAll(/\bexport\s+(?:type\s+)?(\*(?:\s+as\s+([\w$]+))?|\{([^}]*)\})\s+from\s+(["'])([^"']+)\4/g)) {
193
+ const p = resolveTs(root, file.path, m[5], read);
194
+ if (m[3] != null) {
195
+ for (const part of m[3].split(',')) {
196
+ const [a, b] = part
197
+ .trim()
198
+ .replace(/^type\s+/, '')
199
+ .split(/\s+as\s+/);
200
+ if (a && (b ?? a).trim() === name)
201
+ out.push([p, a.trim()]);
202
+ }
203
+ }
204
+ else if (m[2] == null ? name !== 'default' : m[2] === name)
205
+ out.push([p, m[2] == null ? name : '*']);
206
+ }
207
+ for (const m of file.keep.matchAll(/\bexport\s*\{([^}]*)\}(?!\s*from)/g))
208
+ for (const part of m[1].split(',')) {
209
+ const [a, b] = part.trim().split(/\s+as\s+/);
210
+ if (!a || (b ?? a).trim() !== name)
211
+ continue;
212
+ const i = importsOf(root, file, read).find((x) => x.local === a.trim());
213
+ if (i)
214
+ out.push([i.path, i.name]);
215
+ }
216
+ return out;
217
+ }
218
+ export function leadsTo(root, fromPath, toPath, name, read = readFile, cache = new Map(), depth = 0) {
219
+ if (fromPath === toPath)
220
+ return name;
221
+ if (langOf(fromPath) == null)
222
+ return posix.dirname(toPath) === fromPath ? name : null;
223
+ const file = depth < 5 ? codeFile(root, fromPath, read, cache) : null;
224
+ if (!file)
225
+ return null;
226
+ let star = false;
227
+ for (const [path, inner] of reexports(root, file, name, read)) {
228
+ const r = path == null || inner === '?' ? null : leadsTo(root, path, toPath, inner, read, cache, depth + 1);
229
+ if (r === '?' || inner === '?')
230
+ star = true;
231
+ else if (r != null)
232
+ return r;
233
+ }
234
+ return star ? '?' : null;
235
+ }
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
4
- import { route } from './geometry.js';
4
+ import { arcRoom, avoidOf, route } from './geometry.js';
5
5
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
6
6
  import { textWidth } from './text.js';
7
7
  import { checkScene, checkSpec, checkTheme } from './check.js';
8
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, } from './model.js';
8
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, labelLines, fitCap, LABEL_LINE, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, edgeTip, } from './model.js';
9
9
  export { LIGHT, DARK } from './model.js';
10
10
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
11
11
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
@@ -95,7 +95,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
95
95
  : []),
96
96
  ]), [layout, tl, edges, timeline, today]);
97
97
  const step = active == null ? null : steps[active];
98
+ const allBeats = useMemo(() => steps.flatMap((s) => s.flow.map(toBeat)), [steps]);
98
99
  const beats = useMemo(() => (step?.flow ?? []).map(toBeat), [step]);
100
+ const cap = useMemo(() => fitCap({ layout, edges, steps, lanes, timeline }, 830), [layout, edges, steps, lanes, timeline]);
99
101
  const carded = useMemo(() => {
100
102
  const all = new Map();
101
103
  for (const b of steps.flatMap((s) => s.flow.map(toBeat)))
@@ -155,25 +157,18 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
155
157
  h: r.height / k,
156
158
  };
157
159
  });
158
- const avoid = [];
160
+ const extra = [];
159
161
  el.querySelectorAll('[data-fig-outside]').forEach((n) => {
160
162
  const r = n.getBoundingClientRect();
161
- avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
163
+ extra.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
162
164
  });
163
- if (tl)
164
- for (const n of nodes(layout))
165
- if (rects[n.id])
166
- avoid.push({ ...rects[n.id], box: true });
167
165
  const stubs = lanePlan?.stubs ?? new Map();
168
- if (stubs.size) {
169
- for (const n of nodes(layout))
170
- if (rects[n.id])
171
- avoid.push(rects[n.id]);
166
+ if (stubs.size)
172
167
  el.querySelectorAll('[data-fig-gutter]').forEach((n) => {
173
168
  const r = n.getBoundingClientRect();
174
- avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
169
+ extra.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
175
170
  });
176
- }
171
+ const avoid = avoidOf(nodes(layout).flatMap((n) => (rects[n.id] ? [rects[n.id]] : [])), extra, stubs.size > 0);
177
172
  const bands = {};
178
173
  el.querySelectorAll('[data-fig-copy]').forEach((n) => {
179
174
  const r = n.getBoundingClientRect();
@@ -208,7 +203,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
208
203
  stub: stubs.get(ids[i]).map(labelPillW),
209
204
  bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)],
210
205
  }),
211
- ...((stubs.size || lanePlan) && e.label != null && { labelW: labelPillW(str(e.label)) }),
206
+ ...(!tl && e.label != null && { labelW: labelPillW(str(e.label)) }),
212
207
  })), rects, tips, avoid, pillArea, lanePlan ? { bands: Object.values(laneBands), boxes: nodes(layout).flatMap((n) => (rects[n.id] ? [rects[n.id]] : [])) } : undefined);
213
208
  setRouted(next);
214
209
  if (lanePlan) {
@@ -285,7 +280,17 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
285
280
  const e = edges[ids.indexOf(r.id)];
286
281
  if (r.stub)
287
282
  return r.stub.pts.map((pts, j) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: labels[`${r.id}:${j}`] }));
288
- return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) }];
283
+ return [
284
+ {
285
+ id: r.id,
286
+ from: e.from,
287
+ to: e.to,
288
+ curve: r.curve,
289
+ label: labels[r.id],
290
+ ...(tl && { behind: true }),
291
+ ...(r.elbow && { elbow: r.elbow }),
292
+ },
293
+ ];
289
294
  }),
290
295
  ...(rail?.rows ?? []).flatMap((row) => {
291
296
  const r = row.kind === 'message' ? labels[`rail:${row.n}`] : undefined;
@@ -458,7 +463,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
458
463
  const inside = it.labelInside;
459
464
  const outside = (_jsx("span", { "data-fig-outside": "", style: { position: 'absolute', left: '100%', marginLeft: 6, whiteSpace: 'nowrap', fontSize: 13, fontWeight: 500, color: v('fg') }, children: label }));
460
465
  const top = LANE_PAD + it.row * (TL_BAR_H + TL_ROW_GAP);
461
- return (_jsxs("div", { "data-fig": n.id, "data-diamond": it.milestone || undefined, onMouseEnter: () => setHover(n.id), onMouseLeave: () => setHover(null), style: {
466
+ return (_jsxs("div", { "data-fig": n.id, title: n.source, "data-diamond": it.milestone || undefined, onMouseEnter: () => setHover(n.id), onMouseLeave: () => setHover(null), style: {
462
467
  position: 'absolute',
463
468
  left: it.x,
464
469
  top: it.milestone ? top + (TL_BAR_H - it.w) / 2 : top,
@@ -648,13 +653,15 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
648
653
  const store = item.shape === 'store';
649
654
  const card = carded.has(item.id);
650
655
  const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
656
+ const w = diamond || card || item.width != null || str(item.label) ? nodeWidth(item, card, cap) + (diamond ? 70 : 0) : undefined;
657
+ const lines = w != null && !diamond && str(item.label) ? labelLines(item, w) : null;
651
658
  const bt = item.tone && TONES[item.tone];
652
- return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
659
+ return (_jsxs("div", { "data-fig": item.id, title: item.source, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
653
660
  position: 'relative',
654
661
  isolation: 'isolate',
655
662
  minWidth: 100,
656
- maxWidth: card || diamond ? undefined : 190,
657
- width: diamond ? nodeWidth(item, card) + 70 : (item.width ?? (card ? CARD_WIDTH : undefined)),
663
+ maxWidth: w == null ? cap : undefined,
664
+ width: w,
658
665
  minHeight: dl ? Math.max(dl.h, (minHeight(item.id) ?? 0) + 24) : minHeight(item.id),
659
666
  boxSizing: 'border-box',
660
667
  display: 'flex',
@@ -707,7 +714,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
707
714
  border: `1.5px solid ${bt ?? v('accent')}`,
708
715
  background: `radial-gradient(circle, ${bt ?? v('accent')} 0 4px, transparent 4.5px)`,
709
716
  }),
710
- } })), _jsx("div", { children: item.label }), dl
717
+ } })), lines ? (lines.map((l, i) => (_jsx("div", { style: { whiteSpace: 'nowrap', lineHeight: `${LABEL_LINE}px` }, children: l }, i)))) : (_jsx("div", { style: { lineHeight: `${LABEL_LINE}px` }, children: item.label })), dl
711
718
  ? dl.subs.map((l, i) => (_jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), lineHeight: '15px', whiteSpace: 'nowrap' }, children: l.text }, i)))
712
719
  : item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
713
720
  marginTop: 8,
@@ -780,11 +787,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
780
787
  margin: '0 auto',
781
788
  transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
782
789
  transformOrigin: 'top left',
783
- padding: 4,
784
- paddingTop: edges.some((e, i) => e.around === 'above' && !lanePlan?.stubs.has(ids[i])) ? 44 : 4,
785
- paddingBottom: edges.some((e, i) => (e.around === 'below' || (!e.around && lanePlan?.around.has(ids[i]))) && !lanePlan?.stubs.has(ids[i]))
786
- ? 44
787
- : 4,
790
+ paddingTop: arcRoom(routed, 'above') || 4,
791
+ paddingBottom: arcRoom(routed, 'below') || 4,
792
+ paddingLeft: arcRoom(routed, 'left') || 4,
793
+ paddingRight: arcRoom(routed, 'right') || 4,
788
794
  }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
789
795
  position: 'absolute',
790
796
  inset: 0,
@@ -797,11 +803,12 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
797
803
  const on = litEdges.has(r.id);
798
804
  const tone = hopTone(r.id);
799
805
  const hidden = !on && edges[ids.indexOf(r.id)].quiet;
806
+ const tip = edgeTip(r.id, edges[ids.indexOf(r.id)].source, allBeats);
800
807
  const look = (d, key, guides = false) => (_jsx("path", { ref: guides
801
808
  ? (p) => {
802
809
  paths.current[r.id] = p;
803
810
  }
804
- : undefined, d: d, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s' } }, key));
811
+ : undefined, d: d, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s', pointerEvents: tip ? 'stroke' : undefined }, children: tip && _jsx("title", { children: tip }) }, key));
805
812
  const guide = (_jsx("path", { ref: (p) => {
806
813
  paths.current[r.id] = p;
807
814
  }, d: r.d, fill: "none", stroke: "none" }, r.id));
@@ -899,7 +906,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
899
906
  const ly = y + RAIL.row / 2;
900
907
  const tone = state === 'now' && row.tone ? TONES[row.tone] : undefined;
901
908
  const g = row.group != null ? rail.groups[row.group] : null;
902
- return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
909
+ const tip = edgeTip(row.edge, edges[ids.indexOf(row.edge)].source, allBeats);
910
+ return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [tip && _jsx("title", { children: tip }), g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
903
911
  (() => {
904
912
  const tagW = ASYNC_TAG_W;
905
913
  const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
package/dist/load.d.ts CHANGED
@@ -8,3 +8,5 @@ export declare function svgWithSpec(props: FlowProps, opts?: SvgOptions, svg?: s
8
8
  export declare function sortFindings(findings: Finding[], strict: boolean): Finding[];
9
9
  /** One line per finding, the counts line (unless `withCounts` is false), and the `figure:` line. No color: the CLI adds it. */
10
10
  export declare function reportLines(props: FlowProps, findings: Finding[], withCounts?: boolean): string[];
11
+ export declare function summaryLines(props: FlowProps): string[];
12
+ export declare function verifyLines(props: FlowProps, figure: string, root?: string): string[];
package/dist/load.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { counts } from './model.js';
2
+ import { counts, toBeat } from './model.js';
3
+ import { coverageLine, owners, unsureLines, verifyReport } from './verify.js';
3
4
  import { toSvg } from './svg.js';
4
5
  const SPEC_OPEN = '<metadata id="figure-spec"><![CDATA[';
5
6
  const SPEC_CLOSE = ']]></metadata>';
@@ -54,3 +55,25 @@ export function reportLines(props, findings, withCounts = true) {
54
55
  out.push(`figure: ${n(c.boxes, 'box').replace('boxs', 'boxes')}, ${n(c.groups, 'group')}, ${n(c.edges, 'edge')}, ${n(c.steps, 'step')}, ${n(c.messages, 'message')}`);
55
56
  return out;
56
57
  }
58
+ const text = (x) => (typeof x === 'string' ? x : '');
59
+ export function summaryLines(props) {
60
+ const edges = props.edges.map((e) => {
61
+ const label = text(e.label);
62
+ return `${e.from} -> ${e.to}${label ? `: ${label}` : ''}`;
63
+ });
64
+ const steps = (props.steps ?? []).map((s) => {
65
+ const hops = s.flow.reduce((k, b) => k + toBeat(b).hops.length, 0);
66
+ return `step "${text(s.label)}": ${hops} ${hops === 1 ? 'hop' : 'hops'}`;
67
+ });
68
+ return [...edges, ...steps];
69
+ }
70
+ export function verifyLines(props, figure, root = process.cwd()) {
71
+ if (!owners(props).some(([, source, via]) => source != null || via != null))
72
+ return [];
73
+ const { findings, coverage } = verifyReport(props, { root });
74
+ return [
75
+ ...findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${figure}: ${f.message}`),
76
+ coverageLine(figure, coverage),
77
+ ...unsureLines(coverage),
78
+ ];
79
+ }
package/dist/mcp.d.ts CHANGED
@@ -13,6 +13,10 @@ export declare const TOOLS: ({
13
13
  inputSchema: {
14
14
  type: string;
15
15
  properties: {
16
+ topic: {
17
+ type: string;
18
+ enum: string[];
19
+ };
16
20
  paths?: undefined;
17
21
  root?: undefined;
18
22
  strict?: undefined;
@@ -72,6 +76,7 @@ export declare const TOOLS: ({
72
76
  type: string;
73
77
  description: string;
74
78
  };
79
+ topic?: undefined;
75
80
  out: {
76
81
  type: string;
77
82
  description: string;
@@ -89,6 +94,7 @@ export declare const TOOLS: ({
89
94
  inputSchema: {
90
95
  type: string;
91
96
  properties: {
97
+ topic?: undefined;
92
98
  paths: {
93
99
  type: string;
94
100
  items: {
@@ -114,6 +120,7 @@ export declare const TOOLS: ({
114
120
  inputSchema: {
115
121
  type: string;
116
122
  properties: {
123
+ topic?: undefined;
117
124
  paths?: undefined;
118
125
  root?: undefined;
119
126
  strict?: undefined;
package/dist/mcp.js CHANGED
@@ -2,11 +2,11 @@ import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { diff, formatDiff } from './diff.js';
5
- import { GUIDE } from './guide.js';
6
- import { loadSpec, reportLines, sortFindings, svgWithSpec } from './load.js';
5
+ import { GUIDE, TOPICS } from './guide.js';
6
+ import { loadSpec, reportLines, sortFindings, summaryLines, svgWithSpec, verifyLines } from './load.js';
7
7
  import { check, render } from './svg.js';
8
8
  import { checkRendered } from './check.js';
9
- import { verify } from './verify.js';
9
+ import { coverageLine, unsureLines, verifyReport } from './verify.js';
10
10
  import { VERSION } from './version.js';
11
11
  const specSchema = {
12
12
  description: 'The figure: the props object, { props }, or a path to a .json file or an SVG that flowfig wrote.',
@@ -19,7 +19,11 @@ const checkProps = {
19
19
  minText: { type: 'number', description: 'The smallest text in px at that width. Default 10.' },
20
20
  };
21
21
  export const TOOLS = [
22
- { name: 'docs', description: 'The flowfig guide. Read it before the first render.', inputSchema: { type: 'object', properties: {} } },
22
+ {
23
+ name: 'docs',
24
+ description: 'The flowfig guide. Read it before the first render. A topic gives the detail for one kind of figure.',
25
+ inputSchema: { type: 'object', properties: { topic: { type: 'string', enum: Object.keys(TOPICS) } } },
26
+ },
23
27
  {
24
28
  name: 'check',
25
29
  description: 'List the faults of a figure: unknown ids, text overflow, crossings, label overlap, small text, low contrast. Fix every error before render.',
@@ -84,8 +88,14 @@ const types = (args, tool, want) => {
84
88
  }
85
89
  };
86
90
  function run(name, args) {
87
- if (name === 'docs')
88
- return ok(GUIDE);
91
+ if (name === 'docs') {
92
+ const topic = args.topic;
93
+ if (topic == null)
94
+ return ok(GUIDE);
95
+ if (typeof topic !== 'string' || !Object.hasOwn(TOPICS, topic))
96
+ throw new Bad(`docs: topic must be one of ${Object.keys(TOPICS).join(', ')}`);
97
+ return ok(TOPICS[topic]);
98
+ }
89
99
  if (name === 'check' || name === 'render') {
90
100
  need(args, name, name === 'render' ? ['spec', 'out'] : ['spec']);
91
101
  types(args, name, { width: 'number', minText: 'number', out: 'string', strict: 'boolean' });
@@ -106,7 +116,7 @@ function run(name, args) {
106
116
  catch (e) {
107
117
  throw new Fault(`${out}: ${e.message}`);
108
118
  }
109
- return ok([...lines, `${out} — ${(svg.length / 1024).toFixed(1)} kB`].join('\n'));
119
+ return ok([...lines, ...summaryLines(props), ...verifyLines(props, out), `${out} — ${(svg.length / 1024).toFixed(1)} kB`].join('\n'));
110
120
  }
111
121
  if (name === 'verify') {
112
122
  need(args, name, ['paths']);
@@ -115,6 +125,7 @@ function run(name, args) {
115
125
  types(args, name, { root: 'string', strict: 'boolean' });
116
126
  const root = args.root == null ? process.cwd() : String(args.root);
117
127
  const lines = [];
128
+ const counts = [];
118
129
  let errors = 0;
119
130
  for (const p of args.paths) {
120
131
  let props;
@@ -128,13 +139,15 @@ function run(name, args) {
128
139
  lines.push(`${'error'.padEnd(8)} ${'missing-file'.padEnd(18)} ${e.message}`);
129
140
  continue;
130
141
  }
131
- const findings = sortFindings([...check(props), ...verify(props, { root })], args.strict === true);
142
+ const report = verifyReport(props, { root });
143
+ counts.push(coverageLine(p, report.coverage), ...unsureLines(report.coverage));
144
+ const findings = sortFindings([...check(props), ...report.findings], args.strict === true);
132
145
  errors += findings.filter((f) => f.severity === 'error').length;
133
146
  lines.push(...findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${p}: ${f.message}`));
134
147
  }
135
148
  const n = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`;
136
149
  const total = lines.length;
137
- lines.push(`${n(errors, 'error')}, ${n(total - errors, 'warning')}`);
150
+ lines.push(`${n(errors, 'error')}, ${n(total - errors, 'warning')}`, ...counts);
138
151
  return ok(lines.join('\n'), errors > 0);
139
152
  }
140
153
  if (name === 'diff') {
package/dist/model.d.ts CHANGED
@@ -58,6 +58,8 @@ export type FigEdge = {
58
58
  quiet?: boolean;
59
59
  /** Code this draws: `path` or `path#symbol` from the repo root. Checked by `flowfig verify`. */
60
60
  source?: string;
61
+ /** The route, queue, topic or table that carries this edge. Checked by `flowfig verify`. */
62
+ via?: string;
61
63
  };
62
64
  /** One packet on one edge. A string is the edge `id`. */
63
65
  export type FigHop = string | {
@@ -73,6 +75,8 @@ export type FigHop = string | {
73
75
  async?: boolean;
74
76
  /** Code this draws: `path` or `path#symbol` from the repo root. Checked by `flowfig verify`. */
75
77
  source?: string;
78
+ /** The route, queue, topic or table that carries this edge. Checked by `flowfig verify`. */
79
+ via?: string;
76
80
  };
77
81
  /** One moment of a step. No edges means a pause. */
78
82
  export type FigBeat = {
@@ -161,9 +165,11 @@ export type Beat = Omit<FigBeat, 'edges'> & {
161
165
  tone?: FigTone;
162
166
  async?: boolean;
163
167
  source?: string;
168
+ via?: string;
164
169
  }[];
165
170
  };
166
171
  export declare const toBeat: (b: FigHop | FigHop[] | FigBeat) => Beat;
172
+ export declare const edgeTip: (id: string, source: string | undefined, beats: Beat[]) => string | undefined;
167
173
  export declare const isRows: (c: FigContent) => c is FigRow[];
168
174
  export declare const edgeId: (e: FigEdge) => string;
169
175
  export declare const isGroup: (x: FigNode | FigGroup) => x is FigGroup;
@@ -227,11 +233,17 @@ export declare const isLanesLayout: (g: FigGroup) => boolean;
227
233
  export declare const validAt: (at: unknown) => at is number;
228
234
  /** The time column of each box: first use in the steps, then layout order. `at` wins. */
229
235
  export declare function laneColumns(fig: FlowProps): Map<string, number>;
236
+ export declare const LABEL_LINE = 18;
230
237
  export declare const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
231
238
  export declare const LANE_BLOCK_GAP = 40;
232
239
  export declare const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
233
240
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
234
- export declare function nodeWidth(item: FigNode, carded: boolean): number;
241
+ export declare function nodeWidth(item: FigNode, carded: boolean, cap?: number): number;
242
+ export declare function labelLines(item: FigNode, w: number): string[];
243
+ export declare function itemWidth(item: FigNode | FigGroup, carded: {
244
+ has(id: string): boolean;
245
+ }, edges: FigEdge[], cap?: number): number;
246
+ export declare function fitCap(fig: FlowProps, width: number, padding?: number): number;
235
247
  export declare const diamondRoom: (w: number, h: number, far: number) => number;
236
248
  /** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
237
249
  export declare function diamondLines(item: FigNode, w: number): {
@@ -338,5 +350,4 @@ export declare const counts: (p: FlowProps) => {
338
350
  steps: number;
339
351
  messages: number;
340
352
  };
341
- /** A row's default gap grows to hold its widest edge label, plus 8 px each side. */
342
353
  export declare function groupGap(g: FigGroup, edges: FigEdge[]): number;