flowfig 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/coverage.js CHANGED
@@ -3,12 +3,12 @@ import { readFileSync } from 'node:fs';
3
3
  import { posix, resolve } from 'node:path';
4
4
  import { langOf, readFile } from './code.js';
5
5
  import { loadSpec, specOf } from './load.js';
6
- import { links, verifyReport } from './verify.js';
7
- const MARK = '<metadata id="figure-spec">';
6
+ import { detailFindings, links, SPEC_MARK, verifyReport } from './verify.js';
8
7
  const globRe = (glob) => new RegExp(`^${glob
9
8
  .replace(/^\.\//, '')
10
9
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
11
10
  .replace(/\*\*\/|\*\*|\*|\?/g, (t) => (t === '**/' ? '(?:.*/)?' : t === '**' ? '.*' : t === '*' ? '[^/]*' : '[^/]'))}$`);
11
+ const isTest = (p) => /(^|\/)(__tests__|tests|src\/test)\//.test(p) || /\.(test|spec)\.[^/]+$|(^|\/)test_[^/]*\.py$|_test\.(py|go)$|Tests?\.(java|cs)$/.test(p);
12
12
  const day = (t) => (t === Infinity ? 'now' : new Date(t * 1000).toISOString().slice(0, 10));
13
13
  const count = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
14
14
  function gitTimes(root) {
@@ -42,7 +42,7 @@ export function coverageReport({ root = process.cwd(), figures = '**/*.svg', ent
42
42
  const out = [];
43
43
  for (const figure of all.filter((p) => p.endsWith('.svg') && want.test(p))) {
44
44
  const text = readFileSync(resolve(root, figure), 'utf8');
45
- if (!text.includes(MARK))
45
+ if (!text.includes(SPEC_MARK))
46
46
  continue;
47
47
  let fig;
48
48
  try {
@@ -54,8 +54,10 @@ export function coverageReport({ root = process.cwd(), figures = '**/*.svg', ent
54
54
  }
55
55
  const paths = [...new Set(links(fig).map((l) => posix.normalize(l.path)))];
56
56
  paths.forEach((p) => covered.add(p));
57
+ const lost = detailFindings(fig, root).length;
58
+ const lostText = lost ? `; ${lost} missing-detail` : '';
57
59
  if (!paths.length) {
58
- out.push({ figure, state: 'none', detail: 'no source links' });
60
+ out.push({ figure, state: 'none', detail: `no source links${lostText}` });
59
61
  continue;
60
62
  }
61
63
  const { findings, coverage } = verifyReport(fig, { root, cache });
@@ -72,13 +74,14 @@ export function coverageReport({ root = process.cwd(), figures = '**/*.svg', ent
72
74
  const newer = time && paths.find((p) => time(p) > mine);
73
75
  out.push(newer
74
76
  ? { figure, state: 'stale', detail: `${newer} changed ${day(time(newer))}, figure ${day(mine)}` }
75
- : { figure, state: 'ok', detail: `${coverage.boxesDefined} of ${coverage.boxes} boxes defined` });
77
+ : { figure, state: 'ok', detail: `${coverage.boxesDefined} of ${coverage.boxes} boxes defined${lostText}` });
76
78
  }
77
- const code = all.filter((p) => langOf(p) != null);
79
+ const code = all.filter((p) => langOf(p) != null && !isTest(p));
78
80
  const report = { figures: out, git: time != null };
79
81
  if (entries) {
80
82
  const re = globRe(entries);
81
- const matched = code.filter((p) => re.test(p));
83
+ const named = entries.replace(/^\.\//, '');
84
+ const matched = all.filter((p) => langOf(p) != null && re.test(p) && (p === named || !isTest(p)));
82
85
  report.entries = { glob: entries, total: matched.length, uncovered: matched.filter((p) => !covered.has(p)) };
83
86
  }
84
87
  else {
package/dist/diff.d.ts CHANGED
@@ -7,3 +7,13 @@ export type Change = {
7
7
  };
8
8
  export declare function diff(before: FlowProps, after: FlowProps): Change[];
9
9
  export declare function formatDiff(changes: Change[], as: 'text' | 'md'): string;
10
+ export type DiffMark = 'added' | 'removed' | 'changed';
11
+ export type DiffMarks = {
12
+ boxes: Record<string, DiffMark>;
13
+ edges: Record<string, DiffMark>;
14
+ steps: number;
15
+ };
16
+ export declare function mergeFigures(before: FlowProps, after: FlowProps): {
17
+ figure: FlowProps;
18
+ marks: DiffMarks;
19
+ };
package/dist/diff.js CHANGED
@@ -1,4 +1,4 @@
1
- import { edgeId, isGroup, str, toBeat } from './model.js';
1
+ import { edgeId, isGroup, laneColumns, nodes, str, toBeat } from './model.js';
2
2
  const show = (v) => (v == null ? '(none)' : `"${str(v)}"`);
3
3
  const fields = (a, b, names) => names.filter((k) => str(a[k]) !== str(b[k])).map((k) => `${String(k)} ${show(a[k])} -> ${show(b[k])}`);
4
4
  function compare(kind, before, after, fieldsOf) {
@@ -29,13 +29,13 @@ const messages = (fig) => {
29
29
  })));
30
30
  return new Map(ids.map((id) => [id, id]));
31
31
  };
32
+ const boxes = (f) => {
33
+ const m = new Map();
34
+ const walk = (g, parent) => g.children.forEach((c) => (isGroup(c) ? walk(c, c.label != null ? str(c.label) : c.id) : m.set(c.id, { ...c, group: parent })));
35
+ walk(f.layout);
36
+ return m;
37
+ };
32
38
  export function diff(before, after) {
33
- const boxes = (f) => {
34
- const m = new Map();
35
- const walk = (g, parent) => g.children.forEach((c) => (isGroup(c) ? walk(c, c.label != null ? str(c.label) : c.id) : m.set(c.id, { ...c, group: parent })));
36
- walk(f.layout);
37
- return m;
38
- };
39
39
  const edges = (f) => new Map(f.edges.map((e) => [edgeId(e), e]));
40
40
  const steps = (f) => new Map((f.steps ?? []).map((s) => [str(s.label), s]));
41
41
  const out = [
@@ -60,3 +60,133 @@ export function formatDiff(changes, as) {
60
60
  })
61
61
  .join('\n');
62
62
  }
63
+ const key = (g) => g.id ?? (g.label != null ? str(g.label) : undefined);
64
+ const cloneGroup = (g) => ({ ...g, children: g.children.map((c) => (isGroup(c) ? cloneGroup(c) : c)) });
65
+ const ids = (g) => (isGroup(g) ? nodes(g).map((n) => n.id) : [g.id]);
66
+ function find(root, hit) {
67
+ for (const [i, c] of root.children.entries()) {
68
+ if (hit(c))
69
+ return { parent: root, i };
70
+ const inner = isGroup(c) ? find(c, hit) : undefined;
71
+ if (inner)
72
+ return inner;
73
+ }
74
+ }
75
+ export function mergeFigures(before, after) {
76
+ const was = boxes(before);
77
+ const now = boxes(after);
78
+ const gone = (c) => ids(c).every((id) => !now.has(id));
79
+ const layout = cloneGroup(after.layout);
80
+ const placed = new Set();
81
+ const locate = (c) => find(layout, (x) => x === c || (isGroup(c) ? isGroup(x) && key(c) != null && key(x) === key(c) : !isGroup(x) && x.id === c.id));
82
+ const put = (item, frames, sibs, at) => {
83
+ const sides = [
84
+ ...sibs
85
+ .slice(0, at)
86
+ .reverse()
87
+ .map((s) => [s, 1]),
88
+ ...sibs.slice(at + 1).map((s) => [s, 0]),
89
+ ];
90
+ const next = after.layout.auto
91
+ ? undefined
92
+ : sides
93
+ .filter(([s]) => placed.has(s) || !gone(s))
94
+ .map(([s, off]) => ({ spot: locate(s), off }))
95
+ .find((x) => x.spot);
96
+ if (next)
97
+ next.spot.parent.children.splice(next.spot.i + next.off, 0, item);
98
+ else {
99
+ const frame = frames.map(locate).find(Boolean);
100
+ (frame ? frame.parent.children[frame.i] : layout).children.push(item);
101
+ }
102
+ placed.add(item);
103
+ };
104
+ const walk = (g, frames) => g.children.forEach((c, i, sibs) => {
105
+ if (isGroup(c) && !(c.label != null && gone(c) && !locate(c)))
106
+ walk(c, [c, ...frames]);
107
+ else if (gone(c))
108
+ put(c, frames, sibs, i);
109
+ });
110
+ walk(before.layout, [before.layout]);
111
+ const marks = { boxes: {}, edges: {}, steps: diff(before, after).filter((c) => c.kind !== 'box' && c.kind !== 'edge').length };
112
+ for (const [id, n] of now) {
113
+ const old = was.get(id);
114
+ if (!old)
115
+ marks.boxes[id] = 'added';
116
+ else if (fields(old, n, ['label', 'sub', 'shape', 'source', 'group']).length)
117
+ marks.boxes[id] = 'changed';
118
+ }
119
+ for (const id of was.keys())
120
+ if (!now.has(id))
121
+ marks.boxes[id] = 'removed';
122
+ const colAt = new Map();
123
+ if (after.lanes) {
124
+ const colsAfter = laneColumns(after);
125
+ const colsBefore = laneColumns(before);
126
+ const kept = [...now.keys()].filter((id) => was.has(id));
127
+ const slots = new Map();
128
+ for (const id of was.keys()) {
129
+ if (now.has(id))
130
+ continue;
131
+ const old = colsBefore.get(id);
132
+ const prev = kept
133
+ .filter((k) => colsBefore.get(k) < old)
134
+ .sort((a, b) => colsBefore.get(b) - colsBefore.get(a) || colsAfter.get(b) - colsAfter.get(a));
135
+ slots.set(id, { p: prev.length ? colsAfter.get(prev[0]) : -1, old });
136
+ }
137
+ const groups = [...new Map([...slots.values()].map((v) => [`${v.p}:${v.old}`, v])).values()].sort((a, b) => a.p - b.p || a.old - b.old);
138
+ for (const id of now.keys())
139
+ colAt.set(id, colsAfter.get(id) + groups.filter((g) => g.p < colsAfter.get(id)).length);
140
+ for (const [id, v] of slots) {
141
+ const i = groups.findIndex((g) => g.p === v.p && g.old === v.old);
142
+ colAt.set(id, v.p + 1 + groups.filter((g) => g.p < v.p).length + groups.slice(0, i).filter((g) => g.p === v.p).length);
143
+ }
144
+ }
145
+ const paint = (g) => ({
146
+ ...g,
147
+ children: g.children.map((c) => {
148
+ if (isGroup(c))
149
+ return paint(c);
150
+ const n = { ...c };
151
+ delete n.tone;
152
+ if (after.lanes)
153
+ n.at = colAt.get(c.id);
154
+ const old = was.get(c.id);
155
+ if (marks.boxes[c.id] === 'changed' && old) {
156
+ if (str(old.label) !== str(c.label))
157
+ n.sub = `was "${str(old.label)}"`;
158
+ else if (old.group !== now.get(c.id).group)
159
+ n.sub = old.group != null ? `was in "${old.group}"` : 'moved';
160
+ }
161
+ return n;
162
+ }),
163
+ });
164
+ const oldEdges = new Map(before.edges.map((e) => [edgeId(e), e]));
165
+ const newEdges = new Map(after.edges.map((e) => [edgeId(e), e]));
166
+ const same = (a, b) => b != null && a.from === b.from && a.to === b.to;
167
+ const edges = after.edges.map((e) => {
168
+ const id = edgeId(e);
169
+ const old = oldEdges.get(id);
170
+ if (!same(e, old))
171
+ marks.edges[id] = 'added';
172
+ else if (fields(old, e, ['label', 'source']).length) {
173
+ marks.edges[id] = 'changed';
174
+ if (str(old.label) !== str(e.label) && old.label != null)
175
+ return { ...e, label: e.label != null ? `${str(e.label)} (was ${str(old.label)})` : `(was ${str(old.label)})` };
176
+ }
177
+ return e;
178
+ });
179
+ for (const [id, e] of oldEdges) {
180
+ if (same(e, newEdges.get(id)))
181
+ continue;
182
+ const mid = newEdges.has(id) ? `${id} (old)` : id;
183
+ marks.edges[mid] = 'removed';
184
+ const gone = { ...e, id: mid };
185
+ delete gone.around;
186
+ edges.push(gone);
187
+ }
188
+ const figure = { ...after, layout: paint(layout), edges };
189
+ delete figure.steps;
190
+ delete figure.rail;
191
+ return { figure, marks };
192
+ }
@@ -30,6 +30,7 @@ export type Routed = {
30
30
  room?: number;
31
31
  /** The px an elbow detour goes outside the boxes, for each side. */
32
32
  bleed?: Partial<Record<Around, number>>;
33
+ ends?: [string, string];
33
34
  };
34
35
  /** A rect that a route keeps clear of. `box` marks a box. */
35
36
  export type Avoid = Rect & {
@@ -37,6 +38,7 @@ export type Avoid = Rect & {
37
38
  };
38
39
  export declare const avoidOf: (boxes: Rect[], extra: Rect[], lanes: boolean) => Avoid[];
39
40
  type Around = 'above' | 'below' | 'left' | 'right';
41
+ export declare const samples: (curve: [Pt, Pt, Pt, Pt]) => Pt[];
40
42
  export declare const hit: (curve: [Pt, Pt, Pt, Pt], boxes: Rect[], m: number) => Rect[];
41
43
  /** Routes edges between measured boxes as curved SVG paths. `elbow` draws right-angle paths; `stub` splits a cross-lane edge in two. */
42
44
  export declare function route(edges: {
@@ -46,6 +48,7 @@ export declare function route(edges: {
46
48
  around?: Around;
47
49
  sides?: [Side, Side];
48
50
  elbow?: boolean;
51
+ drop?: boolean;
49
52
  stub?: number[];
50
53
  bands?: [Rect | undefined, Rect | undefined];
51
54
  labelW?: number;
@@ -53,6 +56,7 @@ export declare function route(edges: {
53
56
  bands: Rect[];
54
57
  boxes: Rect[];
55
58
  }, pad?: number): Routed[];
59
+ export declare const markSide: (id: string, mark: 'start' | 'end', routed: Routed[]) => Side;
56
60
  export declare const arcRoom: (routed: Routed[], way: Around) => number;
57
61
  export declare function labelAlong(r: Routed, w: number, boxes: Rect[], placed: Rect[], fig?: Rect, lines?: Pt[]): Pt;
58
62
  /** True if segment a-b crosses c-d. The ends of a-b are trimmed by 1.5 px. */
package/dist/geometry.js CHANGED
@@ -52,9 +52,18 @@ const spanOf = (a, b, around) => {
52
52
  return [Math.min(p, q), Math.max(p, q)];
53
53
  };
54
54
  const SIDES = { above: ['t', 't'], below: ['b', 'b'], left: ['l', 'l'], right: ['r', 'r'] };
55
+ export const samples = (curve) => {
56
+ const hull = curve.slice(1).reduce((s, q, k) => s + Math.hypot(q.x - curve[k].x, q.y - curve[k].y), 0);
57
+ const n = Math.max(32, Math.ceil(hull / 4));
58
+ return Array.from({ length: n + 1 }, (_, i) => bezier(curve, i / n));
59
+ };
55
60
  export const hit = (curve, boxes, m) => {
56
- const pts = Array.from({ length: 33 }, (_, i) => bezier(curve, i / 32));
57
- return boxes.filter((r) => pts.some((q) => q.x > r.x - m && q.x < r.x + r.w + m && q.y > r.y - m && q.y < r.y + r.h + m));
61
+ const xs = curve.map((q) => q.x), ys = curve.map((q) => q.y);
62
+ const near = boxes.filter((r) => r.x + r.w + m > Math.min(...xs) && r.x - m < Math.max(...xs) && r.y + r.h + m > Math.min(...ys) && r.y - m < Math.max(...ys));
63
+ if (!near.length)
64
+ return [];
65
+ const pts = samples(curve);
66
+ return near.filter((r) => pts.some((q) => q.x > r.x - m && q.x < r.x + r.w + m && q.y > r.y - m && q.y < r.y + r.h + m));
58
67
  };
59
68
  /** Routes edges between measured boxes as curved SVG paths. `elbow` draws right-angle paths; `stub` splits a cross-lane edge in two. */
60
69
  export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, pad = 24) {
@@ -68,7 +77,7 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
68
77
  const others = avoid.filter((r) => r.box && !same(r, a) && !same(r, b));
69
78
  const plain = stacked ? (a.y < b.y ? ['b', 't'] : ['t', 'b']) : a.x < b.x ? ['r', 'l'] : ['l', 'r'];
70
79
  const past = hit(bend(sideMid(a, plain[0]), sideMid(b, plain[1]), plain[0]), others, -2);
71
- const misses = (way, at) => [0.2, 0.5, 0.8].filter((f) => hit(arcLine(sideAt(a, SIDES[way][0], f), sideAt(b, SIDES[way][1], f), way, at), others, -2).length > 0)
80
+ const misses = (way, at) => [0.2, 0.5, 0.8].filter((f) => hit(arcLine(sideAt(a, SIDES[way][0], f), sideAt(b, SIDES[way][1], f), way, at), others, -6).length > 0)
72
81
  .length;
73
82
  const blocked = (way, at) => misses(way, at) === 3;
74
83
  let around = e.around;
@@ -81,7 +90,10 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
81
90
  if (blocked(around, arc))
82
91
  around = undefined;
83
92
  }
84
- if (!around && !e.sides && !e.stub && !e.elbow && past.length) {
93
+ const down = e.drop && !hit(bend(sideMid(a, 'b'), sideMid(b, 't'), 'b'), others, -2).length;
94
+ if (e.drop && !around)
95
+ elbow = !down;
96
+ else if (!around && !e.sides && !e.stub && !e.elbow && past.length) {
85
97
  const ways = stacked ? ['left', 'right'] : ['above', 'below'];
86
98
  const tries = [];
87
99
  for (const share of [false, true])
@@ -107,7 +119,7 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
107
119
  const [lo, hi] = spanOf(a, b, around);
108
120
  arcs.push({ way: around, lo, hi, at: arc });
109
121
  }
110
- const [sa, sb] = e.sides ?? (e.stub ? ['r', 'l'] : around ? SIDES[around] : plain);
122
+ const [sa, sb] = e.sides ?? (e.stub ? ['r', 'l'] : down ? ['b', 't'] : around ? SIDES[around] : plain);
111
123
  picks.push({ ...e, plain, a, b, sa, sb, around, arc, elbow });
112
124
  }
113
125
  let anchor = new Map();
@@ -152,10 +164,10 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
152
164
  if (!p.around || !s || !e)
153
165
  continue;
154
166
  const others = avoid.filter((r) => r.box && !same(r, p.a) && !same(r, p.b));
155
- if (!hit(arcLine(s, e, p.around, p.arc), others, -2).length)
167
+ if (!hit(arcLine(s, e, p.around, p.arc), others, -6).length)
156
168
  continue;
157
169
  const [pa, pb] = p.plain;
158
- const straight = hit(bend(sideMid(p.a, pa), sideMid(p.b, pb), pa), others, -2).length > 0;
170
+ const straight = hit(bend(sideMid(p.a, pa), sideMid(p.b, pb), pa), others, -6).length > 0;
159
171
  [p.sa, p.sb, p.around, p.arc, p.elbow, moved] = [pa, pb, undefined, undefined, p.elbow || straight, true];
160
172
  }
161
173
  if (moved)
@@ -183,7 +195,15 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
183
195
  const outside = (way, { x, y }) => (way === 'above' ? y0 - y : way === 'below' ? y - y1 : way === 'left' ? x0 - x : x - x1);
184
196
  const roomOf = (way, at, curve, labelW) => Math.max(ARC - 6, lanes || !labelW ? outside(way, { x: at, y: at }) - 6 : outside(way, bezier(curve, 0.5)) + (upright(way) ? labelW / 2 : 9));
185
197
  const one = (p) => {
186
- const s = anchor.get(p.id + ':s'), e = anchor.get(p.id + ':e');
198
+ const turn = !p.stub && p.elbow && p.sa === 'l';
199
+ const [s, e] = [anchor.get(p.id + (turn ? ':e' : ':s')), anchor.get(p.id + (turn ? ':s' : ':e'))];
200
+ const flip = (r) => {
201
+ if (!turn)
202
+ return r;
203
+ const pts = [...r.elbow].reverse();
204
+ const d = pts.slice(1).reduce((d, q, k) => d + (q.y === pts[k].y ? ` H ${q.x}` : ` V ${q.y}`), `M ${pts[0].x} ${pts[0].y}`);
205
+ return { ...r, d, curve: [...r.curve].reverse(), elbow: pts };
206
+ };
187
207
  if (p.stub) {
188
208
  const [ow, iw, sw] = p.stub;
189
209
  const [sb, tb] = p.bands ?? [];
@@ -336,7 +356,7 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
336
356
  }
337
357
  if (p.elbow) {
338
358
  if (e.x - s.x < 16 && s.y === e.y) {
339
- return { id: p.id, d: `M ${s.x} ${s.y} H ${e.x}`, mid: { x: (s.x + e.x) / 2, y: s.y }, curve: [s, s, e, e], elbow: [s, e] };
359
+ return flip({ id: p.id, d: `M ${s.x} ${s.y} H ${e.x}`, mid: { x: (s.x + e.x) / 2, y: s.y }, curve: [s, s, e, e], elbow: [s, e] });
340
360
  }
341
361
  const own = (r) => [p.a, p.b].some((q) => same(q, r));
342
362
  const all = avoid.filter((r) => !own(r));
@@ -347,14 +367,14 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
347
367
  const mx = s.x + 8, ex = e.x - 8, mid = (s.y + e.y) / 2;
348
368
  const ys = Array.from({ length: Math.floor((y1 - y0 + 24) / 4) + 1 }, (_, i) => y0 - 12 + i * 4).sort((u, w) => Math.abs(u - mid) - Math.abs(w - mid));
349
369
  const my = ys.find((y) => vClear(mx, s.y, y) && hClear(y, ex, mx, all) && vClear(ex, y, e.y)) ?? mid;
350
- return {
370
+ return flip({
351
371
  id: p.id,
352
372
  d: `M ${s.x} ${s.y} H ${mx} V ${my} H ${ex} V ${e.y} H ${e.x}`,
353
373
  mid: { x: (mx + ex) / 2, y: my },
354
374
  curve: [s, { x: mx, y: my }, { x: ex, y: my }, e],
355
375
  elbow: [s, { x: mx, y: s.y }, { x: mx, y: my }, { x: ex, y: my }, { x: ex, y: e.y }, e],
356
376
  bleed: { above: room(y0 - my), below: room(my - y1) },
357
- };
377
+ });
358
378
  }
359
379
  const mid = (s.x + e.x) / 2;
360
380
  const gap = Array.from({ length: Math.floor((e.x - s.x - 16) / 4) + 1 }, (_, i) => s.x + 8 + i * 4).sort((u, w) => Math.abs(u - mid) - Math.abs(w - mid));
@@ -383,7 +403,7 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
383
403
  }
384
404
  if (best) {
385
405
  const { x, y, ex } = best;
386
- return {
406
+ return flip({
387
407
  id: p.id,
388
408
  d: `M ${s.x} ${s.y} H ${x} V ${y} H ${ex} V ${e.y} H ${e.x}`,
389
409
  mid: { x: (x + ex) / 2, y },
@@ -395,18 +415,18 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
395
415
  above: room(y0 - y),
396
416
  below: room(y - y1),
397
417
  },
398
- };
418
+ });
399
419
  }
400
420
  }
401
421
  const x = mx ?? mid;
402
422
  const c1 = { x, y: s.y }, c2 = { x, y: e.y };
403
- return {
423
+ return flip({
404
424
  id: p.id,
405
425
  d: `M ${s.x} ${s.y} H ${x} V ${e.y} H ${e.x}`,
406
426
  mid: { x, y: (s.y + e.y) / 2 },
407
427
  curve: [s, c1, c2, e],
408
428
  elbow: [s, c1, c2, e],
409
- };
429
+ });
410
430
  }
411
431
  if (p.around) {
412
432
  const curve = arcLine(s, e, p.around, p.arc);
@@ -456,15 +476,23 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
456
476
  pills.push(...use.out);
457
477
  let k = 0;
458
478
  for (const [p, r] of done) {
459
- if (p.labelW)
479
+ if (p.labelW) {
460
480
  r.mid = use.mids[k++];
481
+ const [l, rt] = [x0 - r.mid.x + p.labelW / 2, r.mid.x + p.labelW / 2 - x1];
482
+ r.bleed = { ...r.bleed, left: Math.max(r.bleed?.left ?? 0, l, 0), right: Math.max(r.bleed?.right ?? 0, rt, 0) };
483
+ }
461
484
  track(Array.from({ length: 33 }, (_, i) => bezier(r.curve, i / 32)));
462
485
  }
463
486
  for (const p of picks)
464
487
  if (p.stub)
465
488
  done.set(p, one(p));
466
- return picks.map((p) => done.get(p));
489
+ return picks.map((p) => ({ ...done.get(p), ends: [`${p.from}:${p.sa}`, `${p.to}:${p.sb}`] }));
467
490
  }
491
+ export const markSide = (id, mark, routed) => {
492
+ const used = new Set(routed.flatMap((r) => r.ends ?? []));
493
+ const order = mark === 'start' ? ['l', 't', 'b', 'r'] : ['r', 'b', 't', 'l'];
494
+ return order.find((s) => !used.has(`${id}:${s}`)) ?? order[0];
495
+ };
468
496
  export const arcRoom = (routed, way) => Math.max(0, ...routed.map((r) => (r.around === way ? r.room : (r.bleed?.[way] ?? 0))));
469
497
  // The player pads its root by 4 px on a side with no detour.
470
498
  const PLAYER_PAD = 4;
package/dist/index.d.ts CHANGED
@@ -5,4 +5,4 @@ export { LIGHT, DARK } from './model.ts';
5
5
  * The interactive player for one figure. Give it a `FlowProps` spec.
6
6
  * Use `toSvg` from `flowfig/svg` for a static animated SVG.
7
7
  */
8
- export declare function Flow({ layout, edges, steps: stepsIn, theme, speed, autoplay, check, rail: withRail, lanes, timeline, today, }: FlowProps): import("react").JSX.Element;
8
+ export declare function Flow(props: FlowProps): import("react").JSX.Element;