flowfig 0.10.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/load.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { altText, counts, toBeat } from './model.js';
3
- import { coverageLine, detailFindings, owners, unsureLines, verifyReport } from './verify.js';
2
+ import { altText, counts, scenes, toBeat, tourPlan } from './model.js';
3
+ import { coverageLine, detailFindings, levelLines, owners, unsureLines, verifyReport } from './verify.js';
4
4
  import { toSvg } from './svg.js';
5
5
  const SPEC_OPEN = '<metadata id="figure-spec"><![CDATA[';
6
6
  const SPEC_CLOSE = ']]></metadata>';
@@ -47,12 +47,16 @@ export function sortFindings(findings, strict) {
47
47
  const n = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
48
48
  /** One line per finding, the counts line (unless `withCounts` is false), and the `figure:` line. No color: the CLI adds it. */
49
49
  export function reportLines(props, findings, withCounts = true) {
50
- const out = findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.message}`);
50
+ const out = findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.scene ? `[${f.scene}] ` : ''}${f.message}`);
51
51
  const errors = findings.filter((f) => f.severity === 'error').length;
52
52
  if (withCounts)
53
53
  out.push(`${n(errors, 'error')}, ${n(findings.length - errors, 'warning')}`);
54
54
  const c = counts(props);
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
+ const all = scenes(props);
56
+ const levels = all.length > 1
57
+ ? `; ${1 + Math.max(...all.map((s) => s.path.length))} levels, ${all.length} scenes, ${all.reduce((k, s) => k + s.parts, 0)} parts; tour ${Math.round(tourPlan(props).ms / 1000)} s`
58
+ : '';
59
+ 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')}${levels}`);
56
60
  return out;
57
61
  }
58
62
  const text = (x) => (typeof x === 'string' ? x : '');
@@ -75,6 +79,7 @@ export function verifyLines(props, figure, root = process.cwd()) {
75
79
  return [
76
80
  ...findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${figure}: ${f.message}`),
77
81
  coverageLine(figure, coverage),
82
+ ...levelLines(coverage),
78
83
  ...unsureLines(coverage),
79
84
  ];
80
85
  }
@@ -0,0 +1,15 @@
1
+ export declare const TOKENS: {
2
+ light: Record<string, string>;
3
+ dark: Record<string, string>;
4
+ };
5
+ export declare const HUD: {
6
+ wide: number;
7
+ roomy: number;
8
+ panel: number;
9
+ slim: number;
10
+ pad: number;
11
+ chips: number;
12
+ sheet: number;
13
+ short: number;
14
+ tall: number;
15
+ };
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,14 +269,73 @@ 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;
238
335
  /** The time column of each box: first use in the steps, then layout order. `at` wins. */
239
336
  export declare function laneColumns(fig: FlowProps): Map<string, number>;
240
337
  export declare const LABEL_LINE = 18;
241
- export declare const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
338
+ export declare const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190, DIAMOND_MAX_W = 170;
242
339
  export declare const LANE_BLOCK_GAP = 40;
243
340
  export declare const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
244
341
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
@@ -249,13 +346,13 @@ export declare function itemWidth(item: FigNode | FigGroup, carded: {
249
346
  }, edges: FigEdge[], cap?: number): number;
250
347
  export declare function fitCap(fig: FlowProps, width: number, padding?: number): number;
251
348
  export declare const diamondRoom: (w: number, h: number, far: number) => number;
252
- /** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
253
349
  export declare function diamondLines(item: FigNode, w: number): {
254
350
  h: number;
255
351
  label: {
352
+ text: string;
256
353
  dy: number;
257
354
  far: number;
258
- };
355
+ }[];
259
356
  subs: {
260
357
  text: string;
261
358
  dy: number;
@@ -361,5 +458,10 @@ export declare const altText: (fig: FlowProps) => {
361
458
  desc: string;
362
459
  };
363
460
  export declare const specKey: (...parts: unknown[]) => string;
461
+ export declare const textFloor: (box: number) => 9 | 11;
364
462
  export declare const fitScale: (wide: number, box: number, map: number) => number;
463
+ export declare const shownFont: (size: number, place: 'map' | 'rail' | 'plain', scale: {
464
+ map: number;
465
+ rail: number;
466
+ }) => number;
365
467
  export declare const nextWide: (wide: number, box: number, map: number) => 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;
@@ -95,7 +269,7 @@ export function laneColumns(fig) {
95
269
  return cols;
96
270
  }
97
271
  export const LABEL_LINE = 18;
98
- export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
272
+ export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190, DIAMOND_MAX_W = 170;
99
273
  export const LANE_BLOCK_GAP = 40;
100
274
  export const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
101
275
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
@@ -106,14 +280,16 @@ export function nodeWidth(item, carded, cap = NODE_MAX_W) {
106
280
  return CARD_WIDTH;
107
281
  const label = textWidth(str(item.label), 14) + 32;
108
282
  const sub = textWidth(str(item.sub), 12) + 32;
109
- return Math.min(item.shape === 'decision' ? NODE_MAX_W : cap, Math.max(NODE_MIN_W, label, sub));
283
+ return Math.min(item.shape === 'decision' ? DIAMOND_MAX_W : cap, Math.max(NODE_MIN_W, label, sub));
110
284
  }
111
285
  export function labelLines(item, w) {
112
286
  const label = str(item.label);
113
- if (item.shape === 'decision' || textWidth(label, 14) <= w - 32 + 0.5)
287
+ if (item.shape === 'decision')
288
+ return wrap(label, w - 102 + 0.5, 14);
289
+ if (textWidth(label, 14) <= w - 32 + 0.5)
114
290
  return [label];
115
291
  const lines = wrap(label, w - 32, 14);
116
- return lines.length === 2 && lines.every((l) => textWidth(l, 14) <= w - 32) ? lines : [label];
292
+ return lines.every((l) => textWidth(l, 14) <= w - 32) ? lines : [label];
117
293
  }
118
294
  export function itemWidth(item, carded, edges, cap = NODE_MAX_W) {
119
295
  if (!isGroup(item))
@@ -137,21 +313,28 @@ export function fitCap(fig, width, padding = 24) {
137
313
  return cap;
138
314
  }
139
315
  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
316
  export function diamondLines(item, w) {
142
317
  const sub = item.sub ? str(item.sub) : '';
143
- for (let n = sub ? 1 : 0;; n++) {
144
- const h = 62 + 15 * n;
145
- const dy0 = n ? -2 - 7.5 * (n - 1) : 5;
146
- const at = (dy, size) => Math.max(Math.abs(dy - 0.75 * size), dy + 0.25 * size);
147
- const last = n ? at(dy0 + 15 * n, 12) : 0;
318
+ const label = labelLines(item, w);
319
+ const k = label.length - 1;
320
+ const at = (dy, size) => Math.max(Math.abs(dy - 0.75 * size), dy + 0.25 * size);
321
+ for (let n = sub ? 1 : 0, h = 0;;) {
322
+ h = Math.max(h, 62 + 15 * n + LABEL_LINE * k);
323
+ const dy0 = (n ? 5.5 : 5) - 7.5 * n - (LABEL_LINE / 2) * k;
324
+ const last = n ? at(dy0 + LABEL_LINE * k + 15 * n, 12) : 0;
148
325
  const lines = n ? wrap(sub, diamondRoom(w, h, last), 12) : [];
149
- if (lines.length <= n || n >= 6)
150
- return {
151
- h,
152
- label: { dy: dy0, far: at(dy0, 14) },
153
- subs: lines.map((text, k) => ({ text, dy: dy0 + 15 * (k + 1), far: at(dy0 + 15 * (k + 1), 12) })),
154
- };
326
+ const labels = label.map((text, i) => ({ text, dy: dy0 + LABEL_LINE * i, far: at(dy0 + LABEL_LINE * i, 14) }));
327
+ const subs = lines.map((text, j) => ({
328
+ text,
329
+ dy: dy0 + LABEL_LINE * k + 15 * (j + 1),
330
+ far: at(dy0 + LABEL_LINE * k + 15 * (j + 1), 12),
331
+ }));
332
+ if (lines.length > n && n < 6)
333
+ n++;
334
+ else if (h < w && labels.some((l) => textWidth(l.text, 14) > diamondRoom(w, h, l.far)))
335
+ h += 4;
336
+ else
337
+ return { h, label: labels, subs };
155
338
  }
156
339
  }
157
340
  /** The lane copies whose stub end has no clear place. The renderer grows each by STUB_ROOM. */
@@ -498,9 +681,16 @@ export const altText = (fig) => {
498
681
  });
499
682
  }),
500
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
+ });
501
691
  return {
502
692
  title: `Flow figure: ${boxes.join(', ')}.${steps.length ? ` Steps: ${steps.map((s) => str(s.label)).join(', ')}.` : ''}`,
503
- desc: [`The figure has these boxes: ${boxes.join(', ')}.`, ...lines].join('\n'),
693
+ desc: [`The figure has these boxes: ${boxes.join(', ')}.`, ...lines, ...levels].join('\n'),
504
694
  };
505
695
  };
506
696
  const keyOf = (v, path) => {
@@ -523,7 +713,17 @@ const keyOf = (v, path) => {
523
713
  return Object.fromEntries(Object.entries(src).flatMap(([k, x]) => (k === 'ref' || k.startsWith('_') ? [] : [[k, keyOf(x, at)]])));
524
714
  };
525
715
  export const specKey = (...parts) => JSON.stringify(keyOf(parts, []));
526
- export const fitScale = (wide, box, map) => Math.max(wide ? 1 : 0.5, Math.min(1, box / map));
527
- const PHONE = 480;
528
- const folds = (box, map) => box < map / 2 || (box < PHONE && box < map);
529
- export const nextWide = (wide, box, map) => (folds(box, wide || map) ? wide || map : 0);
716
+ // Edge labels are 11 px, the smallest reader text at scale 1.
717
+ const READER_PX = 11;
718
+ const DESKTOP_PX = 9;
719
+ const DESKTOP_BOX = 600;
720
+ export const textFloor = (box) => (box < DESKTOP_BOX ? READER_PX : DESKTOP_PX);
721
+ export const fitScale = (wide, box, map) => Math.max(wide ? 1 : textFloor(box) / READER_PX, Math.min(1, box / map));
722
+ export const shownFont = (size, place, scale) => size * (place === 'map' ? scale.map : place === 'rail' ? scale.rail : 1);
723
+ const folds = (box, row) => box * READER_PX < row * textFloor(box);
724
+ export const nextWide = (wide, box, map) => {
725
+ const row = Math.abs(wide) || map;
726
+ if (!folds(box, row))
727
+ return 0;
728
+ return wide > 0 && map >= wide ? -wide : wide || map;
729
+ };
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 {};