flowfig 0.4.0 → 0.5.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/model.js CHANGED
@@ -1,4 +1,4 @@
1
- import { textWidth } from './text.js';
1
+ import { textWidth, wrap } from './text.js';
2
2
  export const toBeat = (b) => {
3
3
  const isBeat = typeof b === 'object' && !Array.isArray(b) && !('edge' in b);
4
4
  const { edges, ...rest } = isBeat ? b : { edges: b };
@@ -110,6 +110,254 @@ export function laneColumns(fig) {
110
110
  cols.set(n.id, n.at);
111
111
  return cols;
112
112
  }
113
+ /** The side of an SVG frame, and the width limits of a box with no card. The lane split measures with them. */
114
+ export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
115
+ /** The gap between two blocks of wrapped lanes. */
116
+ export const LANE_BLOCK_GAP = 40;
117
+ /** The length of a stub line between a box and its pill, the clear space past a pill, and the room a lane grows by for a pill. */
118
+ export const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
119
+ /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
120
+ export function nodeWidth(item, carded) {
121
+ if (item.width != null)
122
+ return item.width;
123
+ if (carded)
124
+ return CARD_WIDTH;
125
+ const label = textWidth(str(item.label), 14) + 32;
126
+ const sub = textWidth(str(item.sub), 12) + 32;
127
+ return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, label, sub));
128
+ }
129
+ /** The room a diamond of size w by h gives a text line whose far edge sits `far` px from the center: the width of the outline there, less 8 px. */
130
+ export const diamondRoom = (w, h, far) => w * Math.max(0, 1 - far / (h / 2)) - 8;
131
+ /** The lines of a diamond with no card. The sub wraps to the room the outline gives its last line; the diamond grows taller by 15 px
132
+ * for each line until the lines fit. `dy` is the baseline of each line below the center, as the SVG draws it: the label sits 2 px
133
+ * above the center over a one-line sub, and 5 px below it with no sub. `far` is the distance from the center to the far edge of the text. */
134
+ export function diamondLines(item, w) {
135
+ const sub = item.sub ? str(item.sub) : '';
136
+ for (let n = sub ? 1 : 0;; n++) {
137
+ const h = 62 + 15 * n;
138
+ const dy0 = n ? -2 - 7.5 * (n - 1) : 5;
139
+ const at = (dy, size) => Math.max(Math.abs(dy - 0.75 * size), dy + 0.25 * size);
140
+ const last = n ? at(dy0 + 15 * n, 12) : 0;
141
+ const lines = n ? wrap(sub, diamondRoom(w, h, last), 12) : [];
142
+ if (lines.length <= n || n >= 6)
143
+ return {
144
+ h,
145
+ label: { dy: dy0, far: at(dy0, 14) },
146
+ subs: lines.map((text, k) => ({ text, dy: dy0 + 15 * (k + 1), far: at(dy0 + 15 * (k + 1), 12) })),
147
+ };
148
+ }
149
+ }
150
+ /** The lane copies (`laneIndex@block`) that hold a stub end with no place clear of every edge path. A renderer grows each by
151
+ * STUB_ROOM and routes again: the pill then fits under or over its box, inside the band. Both renderers call it with their routes. */
152
+ export function tightCopies(lanes, ends, edges, routed) {
153
+ const laneOf = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
154
+ const out = new Set();
155
+ for (const r of routed) {
156
+ const e = edges.find((x) => edgeId(x) === r.id);
157
+ const [fb, tb] = ends.get(r.id) ?? [];
158
+ if (!e || !r.stub)
159
+ continue;
160
+ if (r.stub.tight[0] && fb != null)
161
+ out.add(`${laneOf(e.from)}@${fb}`);
162
+ if (r.stub.tight[1] && tb != null)
163
+ out.add(`${laneOf(e.to)}@${tb}`);
164
+ }
165
+ return out;
166
+ }
167
+ /** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
168
+ export const laneGutter = (lanes) => Math.max(0, ...lanes.map((l) => textWidth(str(l.label).toUpperCase(), 12))) + FRAME_SIDE * 2;
169
+ /** The block of a time column, from the block starts of a plan. */
170
+ export const laneBlock = (starts, c) => {
171
+ let k = 0;
172
+ while (k < starts.length - 2 && c >= starts[k + 1])
173
+ k++;
174
+ return k;
175
+ };
176
+ /** The pill texts of a cross-block edge: "label → Target" at the source, "from Source" at the target, and "→ Target", a
177
+ * shorter source text for a pill that has no place for the long one. */
178
+ export const stubTexts = (label, from, to) => [
179
+ `${str(label) ? str(label) + ' ' : ''}→ ${str(to)}`,
180
+ `from ${str(from)}`,
181
+ ...(str(label) ? [`→ ${str(to)}`] : []),
182
+ ];
183
+ /**
184
+ * The wrap of a lanes figure. If all the time columns do not fit `width` with text at `minText` or more, the lanes wrap like a
185
+ * line of text: block 1 holds columns 0..n-1, block 2 holds n..2n-1. n is the largest count that fits; it is 1 at least.
186
+ * The text test is the one `check` runs: the smallest text (11 px, or 10.5 px with a mono card row) times width / figure width.
187
+ * If the wrap cannot make the text readable, the lanes do not wrap: the smallest font is under `minText`, or `floor` (the least
188
+ * width of the rest of the figure, such as the rail) is already too wide.
189
+ * The blocks share the columns as evenly as they can, and a block holds 2 columns at least: if 2 do not fit, the lanes keep
190
+ * one block. An edge between two blocks becomes two stubs with pills. A pill takes no room of its own, so n depends on the boxes.
191
+ */
192
+ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor = 0 } = {}) {
193
+ const cols = laneColumns(fig);
194
+ const n = Math.max(-1, ...cols.values()) + 1;
195
+ const shown = (fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => Object.entries(toBeat(b).show ?? {})));
196
+ const carded = new Set(shown.map(([id]) => id));
197
+ const mono = shown.some(([, c]) => isRows(c) && c.some((r) => r.mono));
198
+ const colW = Array.from({ length: n }, () => 0);
199
+ const byId = new Map(nodes(fig.layout).map((b) => [b.id, b]));
200
+ for (const b of byId.values()) {
201
+ const c = cols.get(b.id);
202
+ colW[c] = Math.max(colW[c], nodeWidth(b, carded.has(b.id)) + (b.shape === 'decision' ? 70 : 0));
203
+ }
204
+ const font = mono ? 10.5 : 11;
205
+ const limit = (width * font) / minText;
206
+ const room = limit - padding * 2;
207
+ const lanes = fig.layout.children;
208
+ const laneOf = new Map(lanes.filter((l) => l.id != null).map((l) => [l.id, l]));
209
+ const gutter = laneGutter(lanes);
210
+ const plan = (count) => {
211
+ // count blocks share the n columns evenly; the first n % count blocks take one column more.
212
+ const starts = [0];
213
+ for (let k = 0; k < count; k++)
214
+ starts.push(starts[k] + Math.floor(n / count) + (k < n % count ? 1 : 0));
215
+ const colBlock = (c) => laneBlock(starts, c);
216
+ const blockOf = (id) => colBlock(cols.get(id));
217
+ const held = [...new Set([...byId.keys()].map(blockOf))].sort((a, b) => a - b);
218
+ const blocks = held.length ? held : [0];
219
+ const wrapped = blocks.length > 1;
220
+ // The blocks that show a lane: a wrapped block shows only the lanes with a box in it.
221
+ const showing = (l) => wrapped ? [...new Set(l.children.map((b) => blockOf(b.id)))].sort((a, b) => a - b) : blocks;
222
+ const stubs = new Map();
223
+ const ends = new Map();
224
+ const lost = [];
225
+ fig.edges.forEach((e) => {
226
+ const id = edgeId(e);
227
+ const [fl, tl] = [laneOf.get(e.from), laneOf.get(e.to)];
228
+ if (!(byId.has(e.from) || fl) || !(byId.has(e.to) || tl))
229
+ return;
230
+ // A lane source takes the last block at or before the target that shows it; a lane target the first at or after the source.
231
+ let miss = false;
232
+ const near = (l, k, after) => {
233
+ const ks = showing(l);
234
+ const hit = after ? ks.find((b) => b >= k) : ks.findLast((b) => b <= k);
235
+ if (hit != null)
236
+ return hit;
237
+ miss = true;
238
+ return after ? ks.at(-1) : ks[0];
239
+ };
240
+ let fb, tb;
241
+ if (!fl)
242
+ fb = blockOf(e.from);
243
+ if (!tl)
244
+ tb = blockOf(e.to);
245
+ if (fl && tl)
246
+ fb = showing(fl)[0];
247
+ if (fl && !tl)
248
+ fb = near(fl, tb, false);
249
+ if (tl)
250
+ tb = near(tl, fb, true);
251
+ if (fb == null || tb == null)
252
+ return void (wrapped && lost.push(id));
253
+ if (miss)
254
+ lost.push(id);
255
+ ends.set(id, [fb, tb]);
256
+ if (fb === tb)
257
+ return;
258
+ const texts = stubTexts(e.label, (byId.get(e.from) ?? fl).label, (byId.get(e.to) ?? tl).label);
259
+ stubs.set(id, texts);
260
+ });
261
+ // A pill takes no room of its own: the route puts it in free space (route() in geometry.ts), so n depends on the boxes only.
262
+ // A label on an edge between two next columns of one block sits in the gap between them. A label too wide for the gap would
263
+ // cover a box, so that gap grows to the label plus 8 px. A label that fits keeps the gap of 0.4.0.
264
+ const label = Array.from({ length: n }, () => 0);
265
+ for (const e of fig.edges) {
266
+ const [a, b] = [cols.get(e.from), cols.get(e.to)];
267
+ if (e.label == null || a == null || b == null || Math.abs(a - b) !== 1 || colBlock(a) !== colBlock(b))
268
+ continue;
269
+ const w = labelPillW(str(e.label));
270
+ if (w - 2 > LANE_GAP)
271
+ label[Math.min(a, b)] = Math.max(label[Math.min(a, b)], w + 8);
272
+ }
273
+ const gaps = colW.map((_, c) => (c === starts[colBlock(c) + 1] - 1 ? FRAME_SIDE : Math.max(LANE_GAP, label[c])));
274
+ const per = Math.max(...starts.slice(1).map((e, k) => e - starts[k]));
275
+ const lead = Array.from({ length: count }, () => 0);
276
+ // A pill takes free room only: a gap next to a stub end grows for its pill while the block still fits the width. So the
277
+ // block count never depends on a pill. A pill with no room finds a free place in route() (geometry.ts).
278
+ const want = (text) => STUB + labelPillW(text) + STUB_CLEAR;
279
+ const fit = blocks.every((k) => widthOf(starts[k], starts[k + 1], gaps, 0) <= room);
280
+ const out = new Map(), inn = new Map();
281
+ const roomOut = new Set(), roomIn = new Set();
282
+ for (const [id, texts] of stubs) {
283
+ const e = fig.edges.find((x) => edgeId(x) === id);
284
+ const [a, b] = [cols.get(e.from), cols.get(e.to)];
285
+ if (a != null)
286
+ out.set(a, Math.max(out.get(a) ?? 0, want(texts[0])));
287
+ if (b != null)
288
+ inn.set(b, Math.max(inn.get(b) ?? 0, want(texts[1])));
289
+ }
290
+ for (const k of blocks) {
291
+ const [c0, end] = [starts[k], starts[k + 1]];
292
+ let free = room - widthOf(c0, end, gaps, 0);
293
+ const grow = (extra, apply) => {
294
+ if (extra > 0 && extra <= free) {
295
+ free -= extra;
296
+ apply();
297
+ }
298
+ };
299
+ grow(inn.get(c0) ?? 0, () => (lead[k] = inn.get(c0)));
300
+ if (!inn.has(c0) || lead[k])
301
+ roomIn.add(c0);
302
+ for (let c = c0; c < end; c++) {
303
+ const need = (out.get(c) ?? 0) + (c < end - 1 ? (inn.get(c + 1) ?? 0) : 0);
304
+ grow(need - gaps[c], () => (gaps[c] = need));
305
+ if (gaps[c] >= need)
306
+ roomOut.add(c).add(-1 - (c + 1)); // -1 - c marks room before column c
307
+ }
308
+ }
309
+ // A stub end with no room beside its box makes its lane taller in that block: the pill then fits under the box, inside the band.
310
+ const laneIndex = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
311
+ const tall = new Set();
312
+ for (const [id] of stubs) {
313
+ const e = fig.edges.find((x) => edgeId(x) === id);
314
+ const [a, b] = [cols.get(e.from), cols.get(e.to)];
315
+ const [fb, tb] = ends.get(id);
316
+ if (a == null || !roomOut.has(a))
317
+ tall.add(`${laneIndex(e.from)}@${fb}`);
318
+ if (b == null || !(b === starts[tb] ? roomIn.has(b) : roomOut.has(-1 - b)))
319
+ tall.add(`${laneIndex(e.to)}@${tb}`);
320
+ }
321
+ const laneOfBox = new Map(lanes.flatMap((l) => l.children.map((b) => [b.id, l])));
322
+ const around = new Set(fig.edges
323
+ .filter((e) => {
324
+ const [a, b, l] = [cols.get(e.from), cols.get(e.to), laneOfBox.get(e.from)];
325
+ if (e.around || a == null || b == null || !l || l !== laneOfBox.get(e.to) || colBlock(a) !== colBlock(b))
326
+ return false;
327
+ return l.children.some((x) => cols.get(x.id) > Math.min(a, b) && cols.get(x.id) < Math.max(a, b));
328
+ })
329
+ .map(edgeId));
330
+ return [{ cols, starts, per, blocks, gutter, gaps, lead, ends, stubs, around, tall, lost }, fit];
331
+ };
332
+ const widthOf = (c, end, gaps, lead) => gutter + lead + colW.slice(c, end).reduce((a, w) => a + w, 0) + gaps.slice(c, end).reduce((a, g) => a + g, 0);
333
+ // The wrap only narrows the lanes. If the text stays too small at any width, keep one block, and check reports small-text.
334
+ if (font < minText || floor > limit)
335
+ return plan(1)[0];
336
+ // A block has 2 columns at least: one column per block reads as a list, not as lanes. If 2 do not fit, keep one block.
337
+ // The fit uses the boxes and the label gaps only, before a pill takes free room.
338
+ for (let count = 1; count <= Math.max(1, Math.floor(n / 2)); count++) {
339
+ const [p, fit] = plan(count);
340
+ if (fit)
341
+ return p;
342
+ }
343
+ return plan(1)[0];
344
+ }
345
+ /**
346
+ * The rect id that an edge end routes to in wrapped lanes. A box keeps its id. A lane has one band per block, so a lane end names
347
+ * the band in the block from the plan (`band` finds it). A stub end at a lane is a point inside that band: the source at the band
348
+ * end, past the room for its pill, and the target at the first column of the block. The function adds the rect it names to `rects`.
349
+ */
350
+ export function laneEnd(plan, edge, id, start, band, rects) {
351
+ const k = plan.ends.get(edge)?.[start ? 0 : 1];
352
+ const b = k == null ? undefined : band(id, k);
353
+ if (plan.blocks.length < 2 || plan.cols.has(id) || !b)
354
+ return id;
355
+ const texts = plan.stubs.get(edge);
356
+ const key = texts ? `${edge}@${start ? 's' : 't'}` : `${id}@${k}`;
357
+ const x = start ? b.x + b.w - (STUB + labelPillW(texts?.[0] ?? '') + STUB_CLEAR) : b.x + plan.gutter + plan.lead[k];
358
+ rects[key] = texts ? { x, y: b.y, w: 0, h: b.h } : b;
359
+ return key;
360
+ }
113
361
  /** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
114
362
  * the axis strip height and the axis width of the SVG. */
115
363
  export const TL_BAR_H = 28, TL_ROW_GAP = 8, TL_MIN_BAR = 24, TL_DIAMOND = 14, TL_AXIS_H = 28, TL_AXIS_W = 640;
package/dist/scene.d.ts CHANGED
@@ -14,21 +14,28 @@ export type SceneBox = {
14
14
  };
15
15
  /** `step` marks a rail row: rows of two steps never show at the same time, so their labels cannot overlap. */
16
16
  /** `behind` marks an edge drawn under the boxes (a timeline dependency): `check` skips its crossing test. */
17
+ /** `pts` are the corners of an edge drawn as straight runs (a cross-block lanes edge): `check` tests them in place of `curve`. */
17
18
  export type SceneEdge = {
18
19
  id: string;
19
20
  from: string;
20
21
  to: string;
21
22
  curve: [Pt, Pt, Pt, Pt];
23
+ pts?: Pt[];
22
24
  label?: Rect;
23
25
  step?: number;
24
26
  behind?: true;
25
27
  };
26
28
  /** `minFont` is the smallest reading text in px, before any scale. Group frames are not boxes. */
29
+ /** `lanes` are the lane bands of a swimlanes figure, one per lane and block, named by the lane label. A stub pill stays inside one. */
27
30
  export type Scene = {
28
31
  width: number;
29
32
  boxes: SceneBox[];
30
33
  edges: SceneEdge[];
31
34
  minFont: number;
35
+ lanes?: {
36
+ id: string;
37
+ rect: Rect;
38
+ }[];
32
39
  };
33
40
  /** One fault that `flowfig check` found. `rule` names the check, for example `unknown-id`, `text-overflow`, `edge-crosses-box`, `label-overlap`, `small-text`, `low-contrast`. `ids` are the boxes or edges it names. */
34
41
  export type Finding = {
package/dist/svg.d.ts CHANGED
@@ -15,6 +15,9 @@ export type SvgOptions = {
15
15
  padding?: number;
16
16
  /** Colors, in place of `FlowProps.theme`. Default: `FlowProps.theme`. */
17
17
  theme?: FigTheme;
18
+ /** Lanes: the page width and the smallest text that decide when the time columns wrap into blocks. Default: 830 and 10, as in `check`. */
19
+ width?: number;
20
+ minText?: number;
18
21
  };
19
22
  /**
20
23
  * The SVG string, and the scene it drew. The scene lets `flowfig check` test the same layout the SVG uses.