flowfig 0.11.0 → 0.12.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/dist/looks.js ADDED
@@ -0,0 +1,32 @@
1
+ const base = {
2
+ font: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
3
+ };
4
+ export const TOKENS = {
5
+ light: {
6
+ ...base,
7
+ bg: '#fafbfd',
8
+ surface: '#ffffff',
9
+ border: '#dfe7f0',
10
+ ink: '#1d2433',
11
+ muted: '#5d687a',
12
+ accent: '#0074d9',
13
+ 'on-accent': '#ffffff',
14
+ bad: '#c8352f',
15
+ good: '#23704e',
16
+ play: '#1d2433',
17
+ },
18
+ dark: {
19
+ ...base,
20
+ bg: '#131820',
21
+ surface: '#1c2330',
22
+ border: '#2a3343',
23
+ ink: '#e9eef6',
24
+ muted: '#9aa6b8',
25
+ accent: '#4da3f0',
26
+ 'on-accent': '#0b1220',
27
+ bad: '#ee5d57',
28
+ good: '#5cc48f',
29
+ play: '#6b778c',
30
+ },
31
+ };
32
+ export const HUD = { wide: 880, roomy: 1200, panel: 376, slim: 300, pad: 16, chips: 52, sheet: 160, short: 560, tall: 760 };
package/dist/mcp.d.ts CHANGED
@@ -81,6 +81,10 @@ export declare const TOOLS: ({
81
81
  type: string;
82
82
  description: string;
83
83
  };
84
+ scene: {
85
+ type: string;
86
+ description: string;
87
+ };
84
88
  paths?: undefined;
85
89
  root?: undefined;
86
90
  old?: undefined;
package/dist/mcp.js CHANGED
@@ -6,7 +6,7 @@ import { GUIDE, TOPICS } from './guide.js';
6
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 { coverageLine, unsureLines, verifyReport } from './verify.js';
9
+ import { coverageLine, levelLines, 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.',
@@ -34,7 +34,11 @@ export const TOOLS = [
34
34
  description: 'Check a figure, then write it as one animated SVG file. Returns the check lines and the path. On an error, writes nothing.',
35
35
  inputSchema: {
36
36
  type: 'object',
37
- properties: { ...checkProps, out: { type: 'string', description: 'The SVG path to write.' } },
37
+ properties: {
38
+ ...checkProps,
39
+ out: { type: 'string', description: 'The SVG path to write.' },
40
+ scene: { type: 'string', description: 'The id of a box with inside: render that level as a plain figure.' },
41
+ },
38
42
  required: ['spec', 'out'],
39
43
  },
40
44
  },
@@ -98,11 +102,15 @@ function run(name, args) {
98
102
  }
99
103
  if (name === 'check' || name === 'render') {
100
104
  need(args, name, name === 'render' ? ['spec', 'out'] : ['spec']);
101
- types(args, name, { width: 'number', minText: 'number', out: 'string', strict: 'boolean' });
105
+ types(args, name, { width: 'number', minText: 'number', out: 'string', scene: 'string', strict: 'boolean' });
102
106
  const props = spec(args.spec, name);
103
- const opts = { width: args.width, minText: args.minText };
107
+ const opts = {
108
+ width: args.width,
109
+ minText: args.minText,
110
+ ...(name === 'render' && { scene: args.scene }),
111
+ };
104
112
  const rendered = render(props, opts);
105
- const findings = sortFindings(checkRendered(props, opts, rendered.scene), args.strict === true);
113
+ const findings = sortFindings(checkRendered(props, opts, rendered.scene, rendered.levels), args.strict === true);
106
114
  const errors = findings.some((f) => f.severity === 'error');
107
115
  const lines = reportLines(props, findings);
108
116
  if (name === 'check' || errors)
@@ -140,7 +148,7 @@ function run(name, args) {
140
148
  continue;
141
149
  }
142
150
  const report = verifyReport(props, { root });
143
- counts.push(coverageLine(p, report.coverage), ...unsureLines(report.coverage));
151
+ counts.push(coverageLine(p, report.coverage), ...levelLines(report.coverage), ...unsureLines(report.coverage));
144
152
  const findings = sortFindings([...check(props), ...report.findings], args.strict === true);
145
153
  errors += findings.filter((f) => f.severity === 'error').length;
146
154
  lines.push(...findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${p}: ${f.message}`));
package/dist/model.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { Rect } from './geometry.ts';
3
+ export declare const LOOKS: readonly ['person', 'shop', 'desk', 'truck', 'phone', 'server', 'cabinet', 'team', 'tower', 'queue', 'gear', 'cloud'];
4
+ export type FigLook = (typeof LOOKS)[number];
3
5
  /** A box. A box with `show` in a step becomes a content card. */
4
6
  export type FigNode = {
5
7
  /** Name that edges, steps and beats use for this box. Unique in the figure. */
@@ -28,6 +30,14 @@ export type FigNode = {
28
30
  source?: string;
29
31
  /** A figure with more detail: an SVG path from the repo root. */
30
32
  detail?: string;
33
+ /** The parts inside the box, down to 3 levels. */
34
+ inside?: FigInside;
35
+ /** The picture of this box or group in <flowfig-explain>. The diagram ignores it. */
36
+ look?: FigLook;
37
+ /** The question on the gate in <flowfig-explain>. Default: the label of a `decision` box. */
38
+ check?: string;
39
+ /** The state after this box, such as "Oil in stock". The <flowfig-explain> ending shows it. */
40
+ done?: string;
31
41
  };
32
42
  /** A frame that lays out its children in a row or a column. */
33
43
  export type FigGroup = {
@@ -45,6 +55,28 @@ export type FigGroup = {
45
55
  children: (FigNode | FigGroup)[];
46
56
  /** On the root layout: flowfig places the boxes. A labeled group is a frame. */
47
57
  auto?: true;
58
+ /** The picture of this box or group in <flowfig-explain>. The diagram ignores it. */
59
+ look?: FigLook;
60
+ };
61
+ /** A physical thing that moves on an edge in <flowfig-explain>. */
62
+ export type FigCarry = {
63
+ /** The name of the thing, in plain words. */
64
+ thing: string;
65
+ /** The picture of one unit. Default: `box`. */
66
+ form?: 'box' | 'barrel' | 'notes';
67
+ /** How many units move, 1 to 4. Default: 1. */
68
+ count?: number;
69
+ /** The id of the box or group where the thing starts. Default: the edge `from`. */
70
+ from?: string;
71
+ };
72
+ /** The parts inside a box. A reader opens the box to see them. */
73
+ export type FigInside = {
74
+ /** The parts and how they line up. The rules of `FlowProps.layout` apply, `auto` too. */
75
+ layout: FigGroup;
76
+ /** The arrows between the parts. One end can name an outer neighbor. */
77
+ edges?: FigEdge[];
78
+ /** The stories inside the box. Each step is a tab when the box is open. */
79
+ steps?: FigStep[];
48
80
  };
49
81
  /** An arrow from one box to another. */
50
82
  export type FigEdge = {
@@ -64,6 +96,8 @@ export type FigEdge = {
64
96
  source?: string;
65
97
  /** The route, queue, topic or table that carries this edge. Checked by `flowfig verify`. */
66
98
  via?: string;
99
+ /** What moves on this edge in <flowfig-explain>: `data`, or a thing. Default: `data`. */
100
+ carries?: 'data' | FigCarry;
67
101
  };
68
102
  /** One packet on one edge. A string is the edge `id`. */
69
103
  export type FigHop = string | {
@@ -88,6 +122,8 @@ export type FigBeat = {
88
122
  edges?: FigHop | FigHop[];
89
123
  /** The line of narration for this beat. */
90
124
  say?: ReactNode;
125
+ /** One line under the say in <flowfig-explain>, 15 words or fewer. */
126
+ note?: ReactNode;
91
127
  /** Fills the content card of each named box until the step ends. */
92
128
  show?: Record<string, FigContent>;
93
129
  /** Ids of boxes to highlight for this beat, without a packet or a content card. */
@@ -126,6 +162,8 @@ export type FigStep = {
126
162
  flow: (FigHop | FigHop[] | FigBeat)[];
127
163
  /** Ids of boxes to highlight for the whole step. */
128
164
  nodes?: string[];
165
+ /** One sentence, 15 words or fewer, on how the story ends in <flowfig-explain>. */
166
+ outcome?: string;
129
167
  };
130
168
  /** Colors for the figure. A key you leave out keeps the built-in color. */
131
169
  export type FigTheme = Partial<Record<'accent' | 'fg' | 'muted' | 'bg' | 'surface' | 'border' | 'font', string>>;
@@ -231,7 +269,66 @@ export declare const outsideLabelRect: (bar: {
231
269
  export declare const labelSpan: (t0: number, end: number, ramp: number) => [number, number];
232
270
  export declare const dateLabelRaised: (x: number, today: number | null | undefined, w: number, todayW: number) => boolean;
233
271
  export declare const STEP_HOLD_MS = 2000;
272
+ export declare const n2: (v: number) => number;
273
+ export declare const beatTimes: (beats: Beat[][], speedMs: number) => {
274
+ beats: {
275
+ t0: number;
276
+ t1: number;
277
+ tEnd: number;
278
+ }[][];
279
+ ends: number[];
280
+ };
234
281
  export declare const nodes: (g: FigGroup) => FigNode[];
282
+ export type FigPort = {
283
+ id: string;
284
+ way: 'in' | 'out' | 'in and out';
285
+ name: string;
286
+ };
287
+ export type FigScene = {
288
+ path: string[];
289
+ name: string;
290
+ line: string;
291
+ props: FlowProps;
292
+ ports: FigPort[];
293
+ parts: number;
294
+ };
295
+ export declare const PORT_GAP = 28;
296
+ export declare const MAX_LEVEL = 3;
297
+ export declare const portLabel: (p: FigPort) => string;
298
+ export declare function scenes(fig: FlowProps): FigScene[];
299
+ export declare const OPEN_MS: {
300
+ fade: number;
301
+ grow: number;
302
+ stagger: number;
303
+ hide: number;
304
+ shrink: number;
305
+ };
306
+ export type Times = {
307
+ beats: {
308
+ t0: number;
309
+ t1: number;
310
+ tEnd: number;
311
+ }[][];
312
+ ends: number[];
313
+ };
314
+ export type TourOpen = {
315
+ path: string[];
316
+ step: number;
317
+ open: [number, number];
318
+ grown: number;
319
+ loop: [number, number][];
320
+ back: [number, number];
321
+ times: Times;
322
+ };
323
+ export type Tour = {
324
+ ms: number;
325
+ loop: [number, number][];
326
+ times: Times;
327
+ opens: TourOpen[];
328
+ };
329
+ export declare function insideStep(s: FigScene, port?: string): number;
330
+ export declare function flowOrder(s: FigScene): string[];
331
+ export declare function tourPlan(fig: FlowProps, speed?: number): Tour;
235
332
  export declare const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
236
333
  export declare const isLanesLayout: (g: FigGroup) => boolean;
237
334
  export declare const validAt: (at: unknown) => at is number;
package/dist/model.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { textWidth, wrap } from './text.js';
2
+ export const LOOKS = ['person', 'shop', 'desk', 'truck', 'phone', 'server', 'cabinet', 'team', 'tower', 'queue', 'gear', 'cloud'];
2
3
  export const toBeat = (b) => {
3
4
  const isBeat = typeof b === 'object' && !Array.isArray(b) && !('edge' in b);
4
5
  const { edges, ...rest } = isBeat ? b : { edges: b };
@@ -66,7 +67,180 @@ export const outsideLabelRect = (bar, label) => ({
66
67
  export const labelSpan = (t0, end, ramp) => [Math.min(t0 + ramp, end), end];
67
68
  export const dateLabelRaised = (x, today, w, todayW) => today != null && x + 3 < today - 3 && x + 3 + w > today - 3 - todayW;
68
69
  export const STEP_HOLD_MS = 2000;
70
+ export const n2 = (v) => Math.round(v * 10) / 10;
71
+ export const beatTimes = (beats, speedMs) => {
72
+ const travel = speedMs / 1000 / BASE_RATE, hold = STEP_HOLD_MS / 1000 / BASE_RATE;
73
+ const ends = [];
74
+ let t = 0;
75
+ const times = beats.map((step) => {
76
+ const out = step.map((b, bi) => {
77
+ const t0 = t, dur = beatMs(b, speedMs) / 1000 / BASE_RATE;
78
+ t += dur + (bi === step.length - 1 ? hold : 0);
79
+ return { t0, t1: t0 + travel, tEnd: t0 + dur };
80
+ });
81
+ ends.push(t);
82
+ return out;
83
+ });
84
+ return { beats: times, ends };
85
+ };
69
86
  export const nodes = (g) => g.children.flatMap((c) => (isGroup(c) ? nodes(c) : [c]));
87
+ // Measured: a 56 px gap makes the Orders level of figures/inside-shop.ts fail small-text.
88
+ export const PORT_GAP = 28;
89
+ export const MAX_LEVEL = 3;
90
+ export const portLabel = (p) => p.way === 'in' ? `in: from ${p.name}` : p.way === 'out' ? `out: to ${p.name}` : `in and out: ${p.name}`;
91
+ export function scenes(fig) {
92
+ const root = { path: [], name: '', line: '', props: fig, ports: [], parts: 0 };
93
+ if (fig.lanes || fig.timeline || fig.rail === 'only')
94
+ return [root];
95
+ const real = new Map();
96
+ const index = (g, level) => nodes(g).forEach((n) => {
97
+ real.set(n.id, n);
98
+ if (n.inside && level < MAX_LEVEL)
99
+ index(n.inside.layout, level + 1);
100
+ });
101
+ index(fig.layout, 1);
102
+ const name = (id) => str(real.get(id)?.label) || id;
103
+ const { theme, speed, rail } = fig;
104
+ const out = [root];
105
+ const open = (parent) => {
106
+ for (const b of nodes(parent.props.layout)) {
107
+ const parts = b.inside ? nodes(b.inside.layout) : [];
108
+ if (!b.inside || !parts.length)
109
+ continue;
110
+ const ids = new Set(parts.map((n) => n.id));
111
+ const edges = b.inside.edges ?? [];
112
+ const ins = [], outs = [];
113
+ for (const e of edges) {
114
+ if (ids.has(e.from) === ids.has(e.to))
115
+ continue;
116
+ if (!ids.has(e.from) && !ins.includes(e.from))
117
+ ins.push(e.from);
118
+ if (!ids.has(e.to) && !outs.includes(e.to))
119
+ outs.push(e.to);
120
+ }
121
+ const ports = [...ins, ...outs.filter((id) => !ins.includes(id))].map((id) => ({
122
+ id,
123
+ way: !ins.includes(id) ? 'out' : outs.includes(id) ? 'in and out' : 'in',
124
+ name: name(id),
125
+ }));
126
+ const box = (p) => {
127
+ const source = real.get(p.id)?.source;
128
+ return { id: p.id, label: portLabel(p), ...(source != null && { source }) };
129
+ };
130
+ const before = ports.filter((p) => p.way !== 'out').map(box), after = ports.filter((p) => p.way === 'out').map(box);
131
+ const l = b.inside.layout;
132
+ const layout = l.auto
133
+ ? { ...l, children: [...before, ...l.children, ...after] }
134
+ : { direction: l.direction === 'column' ? 'column' : 'row', gap: PORT_GAP, children: [...before, l, ...after] };
135
+ const path = [...parent.path, b.id];
136
+ const s = {
137
+ path,
138
+ name: name(b.id),
139
+ line: path.map(name).join(' > '),
140
+ props: { layout, edges, steps: b.inside.steps, theme, speed, rail },
141
+ ports,
142
+ parts: ids.size,
143
+ };
144
+ out.push(s);
145
+ if (path.length + 1 < MAX_LEVEL)
146
+ open(s);
147
+ }
148
+ };
149
+ open(root);
150
+ return out;
151
+ }
152
+ export const OPEN_MS = { fade: 120, grow: 360, stagger: 50, hide: 120, shrink: 300 };
153
+ const hopEnds = (e, back) => (back ? [e.to, e.from] : [e.from, e.to]);
154
+ export function insideStep(s, port) {
155
+ const byId = new Map(s.props.edges.map((e) => [edgeId(e), e]));
156
+ const at = (s.props.steps ?? []).findIndex((st) => {
157
+ const h = st.flow.length ? toBeat(st.flow[0]).hops[0] : undefined;
158
+ const e = h && byId.get(h.edge);
159
+ return e != null && hopEnds(e, h.back)[0] === port;
160
+ });
161
+ return Math.max(at, 0);
162
+ }
163
+ export function flowOrder(s) {
164
+ const cols = laneColumns(s.props);
165
+ const ids = nodes(s.props.layout)
166
+ .map((n) => n.id)
167
+ .sort((a, b) => cols.get(a) - cols.get(b));
168
+ const port = new Set(s.ports.map((p) => p.id));
169
+ return [...ids.filter((id) => port.has(id)), ...ids.filter((id) => !port.has(id))];
170
+ }
171
+ export function tourPlan(fig, speed = fig.speed ?? 900) {
172
+ const all = scenes(fig);
173
+ const byKey = new Map(all.map((s) => [s.path.join('>'), s]));
174
+ const opens = [];
175
+ const done = new Set();
176
+ const cut = (from, to, spans) => {
177
+ const out = [];
178
+ let at = from;
179
+ for (const [a, b] of spans) {
180
+ if (a > at)
181
+ out.push([at, a]);
182
+ at = b;
183
+ }
184
+ if (to > at)
185
+ out.push([at, to]);
186
+ return out;
187
+ };
188
+ const play = (s, steps, at) => {
189
+ const beats = steps.map((st) => st.flow.map(toBeat));
190
+ const base = beatTimes(beats, speed);
191
+ const byId = new Map(s.props.edges.map((e) => [edgeId(e), e]));
192
+ const spans = [];
193
+ let shift = 0;
194
+ const openAt = (id, port, p) => {
195
+ const key = [...s.path, id].join('>');
196
+ const inner = byKey.get(key);
197
+ if (!inner || done.has(key))
198
+ return;
199
+ const L = openBox(inner, port, p);
200
+ spans.push([p, p + L]);
201
+ shift += L;
202
+ };
203
+ const times = { beats: [], ends: [] };
204
+ beats.forEach((row, si) => {
205
+ times.beats.push(row.map((b, bi) => {
206
+ const t = base.beats[si][bi];
207
+ const t0 = at + t.t0 * 1000 + shift, t1 = at + t.t1 * 1000 + shift;
208
+ for (const h of b.hops) {
209
+ const e = byId.get(h.edge);
210
+ if (e)
211
+ openAt(hopEnds(e, h.back)[1], hopEnds(e, h.back)[0], at + t.tEnd * 1000 + shift);
212
+ }
213
+ return { t0: t0 / 1000, t1: t1 / 1000, tEnd: (at + t.tEnd * 1000 + shift) / 1000 };
214
+ }));
215
+ times.ends.push((at + base.ends[si] * 1000 + shift) / 1000);
216
+ });
217
+ const last = at + (base.ends.at(-1) ?? 0) * 1000;
218
+ for (const n of nodes(s.props.layout))
219
+ openAt(n.id, undefined, last + shift);
220
+ if (times.ends.length)
221
+ times.ends[times.ends.length - 1] = (last + shift) / 1000;
222
+ return { times, end: last + shift, spans };
223
+ };
224
+ const openBox = (sc, port, p) => {
225
+ done.add(sc.path.join('>'));
226
+ const grown = p + readMs(sc.name) + OPEN_MS.fade + OPEN_MS.grow;
227
+ const count = nodes(sc.props.layout).length;
228
+ const loopAt = grown + OPEN_MS.stagger * (count - 1) + readMs([sc.line, ...sc.ports.map(portLabel)].join(' '));
229
+ const steps = sc.props.steps ?? [];
230
+ const step = steps.length ? insideStep(sc, port) : -1;
231
+ const entry = { path: sc.path, step, open: [p, loopAt], grown, loop: [], back: [0, 0], times: { beats: [], ends: [] } };
232
+ opens.push(entry);
233
+ const r = play(sc, step === -1 ? [] : [steps[step]], loopAt);
234
+ const up = sc.path.length > 1 ? `back to ${byKey.get(sc.path.slice(0, -1).join('>')).name}` : 'Back';
235
+ const end = r.end + readMs(up) + OPEN_MS.hide + OPEN_MS.shrink + OPEN_MS.fade;
236
+ entry.loop = cut(loopAt, r.end, r.spans);
237
+ entry.back = [r.end, end];
238
+ entry.times = r.times;
239
+ return end - p;
240
+ };
241
+ const root = play(all[0], fig.steps ?? [], 0);
242
+ return { ms: root.end, loop: cut(0, root.end, root.spans), times: root.times, opens };
243
+ }
70
244
  export const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
71
245
  export const isLanesLayout = (g) => g.direction === 'column' && g.children.every((c) => isGroup(c) && c.label != null && c.children.every((k) => !isGroup(k)));
72
246
  export const validAt = (at) => typeof at === 'number' && Number.isInteger(at) && at >= 0;
@@ -507,9 +681,16 @@ export const altText = (fig) => {
507
681
  });
508
682
  }),
509
683
  ]);
684
+ const levels = scenes(fig)
685
+ .slice(1)
686
+ .map((s) => {
687
+ const port = new Set(s.ports.map((p) => p.id));
688
+ const parts = nodes(s.props.layout).filter((n) => !port.has(n.id));
689
+ return `${s.line}: ${parts.map((n) => str(n.label) || n.id).join(', ')}.${s.ports.length ? ` Ports: ${s.ports.map(portLabel).join(', ')}.` : ''}`;
690
+ });
510
691
  return {
511
692
  title: `Flow figure: ${boxes.join(', ')}.${steps.length ? ` Steps: ${steps.map((s) => str(s.label)).join(', ')}.` : ''}`,
512
- desc: [`The figure has these boxes: ${boxes.join(', ')}.`, ...lines].join('\n'),
693
+ desc: [`The figure has these boxes: ${boxes.join(', ')}.`, ...lines, ...levels].join('\n'),
513
694
  };
514
695
  };
515
696
  const keyOf = (v, path) => {
package/dist/scene.d.ts CHANGED
@@ -47,4 +47,6 @@ export type Finding = {
47
47
  severity: 'error' | 'warning';
48
48
  ids: string[];
49
49
  message: string;
50
+ /** The path line of the level, for a finding inside a box. */
51
+ scene?: string;
50
52
  };
package/dist/source.d.ts CHANGED
@@ -10,8 +10,10 @@ export type Link = {
10
10
  source: string;
11
11
  path: string;
12
12
  symbol?: string;
13
+ level?: string;
13
14
  };
14
- /** Every box, edge and hop with its raw `source`, well-formed or not. The one owner list that `links` and `checkSpec` share. */
15
- export declare function owners(fig: FlowProps): [string, string | undefined, string | undefined][];
15
+ type Owner = [string, string | undefined, string | undefined, string];
16
+ export declare function owners(fig: FlowProps): Owner[];
16
17
  /** Every well-formed link in the figure, with the box, edge or hop that carries it. `bad-source` covers the rest. */
17
18
  export declare function links(fig: FlowProps): Link[];
19
+ export {};
package/dist/source.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // No Node import: the React and SVG entries must stay browser-safe.
2
- import { edgeId, nodes, toBeat } from './model.js';
2
+ import { edgeId, nodes, scenes, toBeat } from './model.js';
3
3
  /** `path` or `path#symbol`. No spaces, one `#` at most, both parts non-empty. `null` for a bad form. */
4
4
  export function parseSource(s) {
5
5
  const m = /^([^\s#]+)(?:#([^\s#]+))?$/.exec(s);
@@ -12,18 +12,24 @@ export const headingSlug = (text) => text
12
12
  .toLowerCase()
13
13
  .replace(/[^\p{L}\p{N} -]/gu, '')
14
14
  .replace(/ /g, '-');
15
- /** Every box, edge and hop with its raw `source`, well-formed or not. The one owner list that `links` and `checkSpec` share. */
16
15
  export function owners(fig) {
17
- return [
18
- ...nodes(fig.layout).map((n) => [`box "${n.id}"`, n.source, undefined]),
19
- ...fig.edges.map((e) => [`edge "${edgeId(e)}"`, e.source, e.via]),
20
- ...(fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => [`hop on "${h.edge}"`, h.source, h.via]))),
21
- ];
16
+ return scenes(fig).flatMap((s) => {
17
+ const ports = new Set(s.ports.map((p) => p.id));
18
+ const at = s.line ? ` [${s.line}]` : '';
19
+ const kind = s.line ? 'part' : 'box';
20
+ return [
21
+ ...nodes(s.props.layout)
22
+ .filter((n) => !ports.has(n.id))
23
+ .map((n) => [`${kind} "${n.id}"${at}`, n.source, undefined, s.path.join('>')]),
24
+ ...s.props.edges.map((e) => [`edge "${edgeId(e)}"${at}`, e.source, e.via, s.path.join('>')]),
25
+ ...(s.props.steps ?? []).flatMap((st) => st.flow.flatMap((b) => toBeat(b).hops.map((h) => [`hop on "${h.edge}"${at}`, h.source, h.via, s.path.join('>')]))),
26
+ ];
27
+ });
22
28
  }
23
29
  /** Every well-formed link in the figure, with the box, edge or hop that carries it. `bad-source` covers the rest. */
24
30
  export function links(fig) {
25
- return owners(fig).flatMap(([owner, source]) => {
31
+ return owners(fig).flatMap(([owner, source, , level]) => {
26
32
  const p = source == null ? null : parseSource(source);
27
- return p ? [{ owner, source: source, ...p }] : [];
33
+ return p ? [{ owner, source: source, ...p, ...(level && { level }) }] : [];
28
34
  });
29
35
  }
package/dist/svg.d.ts CHANGED
@@ -23,11 +23,23 @@ export type SvgOptions = {
23
23
  marks?: DiffMarks;
24
24
  /** Links a box to an href, by box id. `flowfig atlas` sets it. */
25
25
  links?: Record<string, string>;
26
+ /** Draws the level of this box as a plain figure: no tour, no script. */
27
+ scene?: string;
28
+ /** Adds the script that opens a box on a click, for a figure with parts. */
29
+ script?: boolean;
30
+ /** A prefix for animation names and path ids. Each page SVG needs its own. */
31
+ scope?: string;
32
+ };
33
+ export type Level = {
34
+ line: string;
35
+ props: FlowProps;
36
+ scene: Scene;
26
37
  };
27
38
  /** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
28
39
  export declare function render(spec: FlowProps, opts?: SvgOptions): {
29
40
  svg: string;
30
41
  scene: Scene;
42
+ levels?: Level[];
31
43
  };
32
44
  /** One self-contained animated SVG string for the figure. Needs no React and no browser. */
33
45
  export declare function toSvg(fig: FlowProps, opts?: SvgOptions): string;