flowfig 0.8.0 → 0.8.2

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/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,8 +2,8 @@ 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
9
  import { coverageLine, unsureLines, verifyReport } from './verify.js';
@@ -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']);
package/dist/model.d.ts CHANGED
@@ -169,6 +169,7 @@ export type Beat = Omit<FigBeat, 'edges'> & {
169
169
  }[];
170
170
  };
171
171
  export declare const toBeat: (b: FigHop | FigHop[] | FigBeat) => Beat;
172
+ export declare const edgeTip: (id: string, source: string | undefined, beats: Beat[]) => string | undefined;
172
173
  export declare const isRows: (c: FigContent) => c is FigRow[];
173
174
  export declare const edgeId: (e: FigEdge) => string;
174
175
  export declare const isGroup: (x: FigNode | FigGroup) => x is FigGroup;
@@ -232,11 +233,17 @@ export declare const isLanesLayout: (g: FigGroup) => boolean;
232
233
  export declare const validAt: (at: unknown) => at is number;
233
234
  /** The time column of each box: first use in the steps, then layout order. `at` wins. */
234
235
  export declare function laneColumns(fig: FlowProps): Map<string, number>;
236
+ export declare const LABEL_LINE = 18;
235
237
  export declare const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
236
238
  export declare const LANE_BLOCK_GAP = 40;
237
239
  export declare const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
238
240
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
239
- 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;
240
247
  export declare const diamondRoom: (w: number, h: number, far: number) => number;
241
248
  /** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
242
249
  export declare function diamondLines(item: FigNode, w: number): {
@@ -343,5 +350,4 @@ export declare const counts: (p: FlowProps) => {
343
350
  steps: number;
344
351
  messages: number;
345
352
  };
346
- /** A row's default gap grows to hold its widest edge label, plus 8 px each side. */
347
353
  export declare function groupGap(g: FigGroup, edges: FigEdge[]): number;
package/dist/model.js CHANGED
@@ -8,6 +8,14 @@ export const toBeat = (b) => {
8
8
  hops: hops.map((h) => (typeof h === 'string' ? { edge: h, back: false } : { back: false, ...h })),
9
9
  };
10
10
  };
11
+ export const edgeTip = (id, source, beats) => {
12
+ const all = new Set(source ? [source] : []);
13
+ for (const b of beats)
14
+ for (const h of b.hops)
15
+ if (h.edge === id && h.source)
16
+ all.add(h.source);
17
+ return all.size ? [...all].join('\n') : undefined;
18
+ };
11
19
  // A React node can be an object, so test for the `text` key.
12
20
  export const isRows = (c) => Array.isArray(c) && c.every((r) => r != null && typeof r === 'object' && 'text' in r);
13
21
  export const edgeId = (e) => e.id ?? `${e.from}->${e.to}`;
@@ -86,18 +94,47 @@ export function laneColumns(fig) {
86
94
  cols.set(n.id, n.at);
87
95
  return cols;
88
96
  }
97
+ export const LABEL_LINE = 18;
89
98
  export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
90
99
  export const LANE_BLOCK_GAP = 40;
91
100
  export const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
92
101
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
93
- export function nodeWidth(item, carded) {
102
+ export function nodeWidth(item, carded, cap = NODE_MAX_W) {
94
103
  if (item.width != null)
95
104
  return item.width;
96
105
  if (carded)
97
106
  return CARD_WIDTH;
98
107
  const label = textWidth(str(item.label), 14) + 32;
99
108
  const sub = textWidth(str(item.sub), 12) + 32;
100
- return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, label, sub));
109
+ return Math.min(item.shape === 'decision' ? NODE_MAX_W : cap, Math.max(NODE_MIN_W, label, sub));
110
+ }
111
+ export function labelLines(item, w) {
112
+ const label = str(item.label);
113
+ if (item.shape === 'decision' || textWidth(label, 14) <= w - 32 + 0.5)
114
+ return [label];
115
+ const lines = wrap(label, w - 32, 14);
116
+ return lines.length === 2 && lines.every((l) => textWidth(l, 14) <= w - 32) ? lines : [label];
117
+ }
118
+ export function itemWidth(item, carded, edges, cap = NODE_MAX_W) {
119
+ if (!isGroup(item))
120
+ return nodeWidth(item, carded.has(item.id), cap) + (item.shape === 'decision' ? 70 : 0);
121
+ const ws = item.children.map((c) => itemWidth(c, carded, edges, cap));
122
+ const inner = item.direction === 'column' ? Math.max(...ws) : ws.reduce((a, b) => a + b, 0) + groupGap(item, edges) * (ws.length - 1);
123
+ return inner + (item.label != null ? FRAME_SIDE * 2 : 0);
124
+ }
125
+ export function fitCap(fig, width, padding = 24) {
126
+ if ((fig.lanes || fig.timeline) && isLanesLayout(fig.layout))
127
+ return NODE_MAX_W;
128
+ const carded = new Set((fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => Object.keys(toBeat(b).show ?? {}))));
129
+ const fits = (n, cap) => {
130
+ const w = nodeWidth(n, carded.has(n.id), cap);
131
+ return Math.max(textWidth(str(n.sub), 12), ...labelLines(n, w).map((l) => textWidth(l, 14))) + 32 <= w + 0.5;
132
+ };
133
+ const keep = nodes(fig.layout).filter((n) => fits(n, NODE_MAX_W));
134
+ let cap = NODE_MAX_W;
135
+ while (cap > 110 && itemWidth(fig.layout, carded, fig.edges, cap) + padding * 2 > width && keep.every((n) => fits(n, cap - 20)))
136
+ cap -= 20;
137
+ return cap;
101
138
  }
102
139
  export const diamondRoom = (w, h, far) => w * Math.max(0, 1 - far / (h / 2)) - 8;
103
140
  /** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
@@ -415,16 +452,22 @@ export const counts = (p) => {
415
452
  };
416
453
  };
417
454
  const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
418
- /** A row's default gap grows to hold its widest edge label, plus 8 px each side. */
419
455
  export function groupGap(g, edges) {
420
- if (g.gap != null)
421
- return g.gap;
422
- if (g.direction === 'column')
423
- return 28;
424
456
  const side = new Map();
425
457
  g.children.forEach((c, i) => idsIn(c).forEach((id) => side.set(id, i)));
426
- const pills = edges
427
- .filter((e) => e.label != null && side.has(e.from) && side.has(e.to) && side.get(e.from) !== side.get(e.to))
428
- .map((e) => labelPillW(str(e.label)) + 16);
429
- return Math.max(56, ...pills);
458
+ const spans = edges.filter((e) => e.label != null && side.has(e.from) && side.has(e.to) && side.get(e.from) !== side.get(e.to) && !(g.gap != null && e.around));
459
+ if (g.gap != null && !spans.length)
460
+ return g.gap;
461
+ const need = Math.max(0, ...spans.map((e) => labelPillW(str(e.label)) + 16));
462
+ if (g.gap != null && g.direction !== 'column')
463
+ return Math.max(g.gap, need - 16);
464
+ let auto;
465
+ if (g.direction === 'column') {
466
+ const ends = spans.map((e) => [side.get(e.from), side.get(e.to)].sort((p, q) => p - q));
467
+ const most = Math.max(0, ...g.children.map((_, k) => ends.filter(([lo, hi]) => lo <= k && k < hi).length));
468
+ auto = most > 1 ? Math.min(most, 3) * 22 + 16 : 28;
469
+ }
470
+ else
471
+ auto = Math.max(56, need);
472
+ return Math.max(g.gap ?? 0, auto);
430
473
  }
package/dist/scene.d.ts CHANGED
@@ -33,6 +33,8 @@ export type Scene = {
33
33
  edges: SceneEdge[];
34
34
  /** The smallest reading text in px, before any scale. */
35
35
  minFont: number;
36
+ /** The drawn map area. An edge label stays inside it. */
37
+ area?: Rect;
36
38
  /** The lane bands of a swimlanes figure. A stub pill stays inside one. */
37
39
  lanes?: {
38
40
  id: string;