flowfig 0.6.0 → 0.7.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/README.md +2 -2
- package/dist/browser.d.ts +1 -3
- package/dist/browser.js +67 -59
- package/dist/check.d.ts +5 -3
- package/dist/check.js +20 -16
- package/dist/cli.js +32 -46
- package/dist/diff.js +0 -4
- package/dist/draw.d.ts +1 -3
- package/dist/draw.js +3 -7
- package/dist/geometry.d.ts +6 -8
- package/dist/geometry.js +75 -82
- package/dist/gif.d.ts +1 -2
- package/dist/gif.js +115 -42
- package/dist/guide.d.ts +0 -3
- package/dist/guide.js +0 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +20 -78
- package/dist/init.d.ts +3 -13
- package/dist/init.js +13 -18
- package/dist/load.d.ts +1 -3
- package/dist/load.js +2 -5
- package/dist/mcp.d.ts +1 -2
- package/dist/mcp.js +9 -12
- package/dist/model.d.ts +52 -109
- package/dist/model.js +20 -89
- package/dist/open.d.ts +2 -5
- package/dist/open.js +6 -11
- package/dist/page.d.ts +0 -1
- package/dist/page.js +1 -3
- package/dist/picker.d.ts +2 -4
- package/dist/picker.js +11 -14
- package/dist/png.js +9 -10
- package/dist/rail.d.ts +1 -3
- package/dist/rail.js +2 -16
- package/dist/scene.d.ts +8 -8
- package/dist/source.d.ts +0 -1
- package/dist/source.js +2 -2
- package/dist/svg.d.ts +5 -8
- package/dist/svg.js +50 -112
- package/dist/text.d.ts +0 -2
- package/dist/text.js +1 -4
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -4
- package/dist/version.d.ts +1 -2
- package/dist/version.js +2 -2
- package/package.json +4 -2
package/dist/model.js
CHANGED
|
@@ -8,18 +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
|
-
|
|
11
|
+
// A React node can be an object, so test for the `text` key.
|
|
12
12
|
export const isRows = (c) => Array.isArray(c) && c.every((r) => r != null && typeof r === 'object' && 'text' in r);
|
|
13
13
|
export const edgeId = (e) => e.id ?? `${e.from}->${e.to}`;
|
|
14
14
|
export const isGroup = (x) => 'children' in x;
|
|
15
15
|
export const decisions = (g) => g.children.flatMap((c) => (isGroup(c) ? decisions(c) : c.shape === 'decision' ? [c.id] : []));
|
|
16
|
-
/** The width of a content card in px. Both renderers use it, so a figure lays out the same way in each. */
|
|
17
16
|
export const CARD_WIDTH = 166;
|
|
18
|
-
/** The player runs 1.25 times faster than the timings in a figure: figure timings are written to be read slowly. The SVG matches it. */
|
|
19
17
|
export const BASE_RATE = 1.25;
|
|
20
|
-
/** The height of one text line in a content card, and the padding above and below the lines. */
|
|
21
18
|
export const CARD_LINE = 15, CARD_PAD = 6;
|
|
22
|
-
/** The fixed color of each row tone. */
|
|
23
19
|
export const TONES = {
|
|
24
20
|
blue: '#3b82f6',
|
|
25
21
|
purple: '#8b5cf6',
|
|
@@ -28,32 +24,22 @@ export const TONES = {
|
|
|
28
24
|
red: '#ef4444',
|
|
29
25
|
gray: '#8b949e',
|
|
30
26
|
};
|
|
31
|
-
/** A tone as a fill under white text: darker, so the text keeps its contrast. */
|
|
32
27
|
export const toneFill = (c) => `color-mix(in srgb, ${c} 64%, #000)`;
|
|
33
|
-
/** The light tint of a tone over a background: the fill of a toned box. */
|
|
34
28
|
export const toneTint = (c, bg, pct = 8) => `color-mix(in srgb, ${c} ${pct}%, ${bg})`;
|
|
35
|
-
/** The width of an edge-label pill in px: the label text plus its padding. The rail uses it for a payload, so both look the same. */
|
|
36
29
|
export const labelPillW = (label) => textWidth(label, 11, true) + 14;
|
|
37
|
-
/** The width of the `async` tag in px. */
|
|
38
30
|
export const ASYNC_TAG_W = textWidth('ASYNC', 9) + 8;
|
|
39
|
-
/** The stroke width in px of an edge that a packet crosses now, and of an idle edge. */
|
|
40
31
|
export const EDGE_ON = 2, EDGE_OFF = 1.25;
|
|
41
|
-
/** The built-in colors. Both renderers and `flowfig check` read these, so they cannot drift apart. */
|
|
42
32
|
export const LIGHT = { accent: '#0074d9', fg: '#1c1e21', muted: '#606770', bg: '#ffffff', surface: '#f5f7fa', border: '#d0d7de' };
|
|
43
|
-
//
|
|
33
|
+
// White text on the dark accent needs 4.5:1 contrast.
|
|
44
34
|
export const DARK = { accent: '#1f78c8', fg: '#e3e3e3', muted: '#9aa0a6', bg: '#1b1b1d', surface: '#242526', border: '#3a3b3c' };
|
|
45
|
-
/** Text on an accent fill: the data chips and a lit edge label. */
|
|
46
35
|
export const ON_ACCENT = '#ffffff';
|
|
47
|
-
|
|
36
|
+
// A React element has no string form, so only text survives.
|
|
48
37
|
export const str = (n) => (typeof n === 'string' || typeof n === 'number' ? String(n) : '');
|
|
49
|
-
/** The time a reader needs for a caption line: 400 ms plus 240 ms per word. The result is 0 for no text. */
|
|
50
38
|
export const readMs = (say) => {
|
|
51
39
|
const w = str(say).split(/\s+/).filter(Boolean).length;
|
|
52
40
|
return w ? 400 + 240 * w : 0;
|
|
53
41
|
};
|
|
54
|
-
/** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
|
|
55
42
|
export const beatMs = (b, speed) => b.ms ?? speed + readMs(b.say);
|
|
56
|
-
/** The item the playhead rests on at beat `i`: the first dated id in the latest focus up to `i`, or undefined for home. */
|
|
57
43
|
export const playheadItem = (items, beats, i) => {
|
|
58
44
|
for (let k = Math.min(i, beats.length - 1); k >= 0; k--) {
|
|
59
45
|
const id = (beats[k].focus ?? []).find((f) => items.some((x) => x.id === f));
|
|
@@ -62,33 +48,21 @@ export const playheadItem = (items, beats, i) => {
|
|
|
62
48
|
}
|
|
63
49
|
return undefined;
|
|
64
50
|
};
|
|
65
|
-
/** The item the playhead rests on when the loop starts: the playhead item of the first beat of the first step. */
|
|
66
51
|
export const loopStartItem = (items, steps) => playheadItem(items, (steps[0]?.flow ?? []).slice(0, 1).map(toBeat), 0);
|
|
67
|
-
/** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
|
|
68
52
|
export const outsideLabelRect = (bar, label) => ({
|
|
69
53
|
x: bar.x + bar.w + 6,
|
|
70
54
|
y: bar.y,
|
|
71
55
|
w: textWidth(label, 13),
|
|
72
56
|
h: bar.h,
|
|
73
57
|
});
|
|
74
|
-
/**
|
|
75
|
-
* The time span in which the playhead label shows its date. The label waits for the line to arrive: it stays hidden for
|
|
76
|
-
* the ramp (the move), so it never shows a date at a place that is not that date, and it never meets "today" on the way.
|
|
77
|
-
*/
|
|
78
58
|
export const labelSpan = (t0, end, ramp) => [Math.min(t0 + ramp, end), end];
|
|
79
|
-
/** True if a playhead date label at x would overlap the "today" label, so it moves to the top row. Widths are in px. */
|
|
80
59
|
export const dateLabelRaised = (x, today, w, todayW) => today != null && x + 3 < today - 3 && x + 3 + w > today - 3 - todayW;
|
|
81
|
-
/** The hold at the end of each step, before the next step starts. */
|
|
82
60
|
export const STEP_HOLD_MS = 2000;
|
|
83
61
|
export const nodes = (g) => g.children.flatMap((c) => (isGroup(c) ? nodes(c) : [c]));
|
|
84
|
-
/** Lane layout constants, shared by both renderers: the gap between time columns, the padding inside a lane, and the gap between lanes. */
|
|
85
62
|
export const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
|
|
86
|
-
/** True if the layout can draw as lanes: a `column` group whose every child is a labeled group with boxes only. */
|
|
87
63
|
export const isLanesLayout = (g) => g.direction === 'column' && g.children.every((c) => isGroup(c) && c.label != null && c.children.every((k) => !isGroup(k)));
|
|
88
|
-
/** True if `at` is a usable time column: an integer of 0 or more. */
|
|
89
64
|
export const validAt = (at) => typeof at === 'number' && Number.isInteger(at) && at >= 0;
|
|
90
|
-
/** The time column of each box
|
|
91
|
-
* then the boxes no step touches in layout order. `at` overrides. Two boxes may share a column. */
|
|
65
|
+
/** The time column of each box: first use in the steps, then layout order. `at` wins. */
|
|
92
66
|
export function laneColumns(fig) {
|
|
93
67
|
const byId = new Map(fig.edges.map((e) => [edgeId(e), e]));
|
|
94
68
|
const cols = new Map();
|
|
@@ -112,11 +86,8 @@ export function laneColumns(fig) {
|
|
|
112
86
|
cols.set(n.id, n.at);
|
|
113
87
|
return cols;
|
|
114
88
|
}
|
|
115
|
-
/** The side of an SVG frame, and the width limits of a box with no card. The lane split measures with them. */
|
|
116
89
|
export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
|
|
117
|
-
/** The gap between two blocks of wrapped lanes. */
|
|
118
90
|
export const LANE_BLOCK_GAP = 40;
|
|
119
|
-
/** 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. */
|
|
120
91
|
export const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
|
|
121
92
|
/** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
|
|
122
93
|
export function nodeWidth(item, carded) {
|
|
@@ -128,11 +99,8 @@ export function nodeWidth(item, carded) {
|
|
|
128
99
|
const sub = textWidth(str(item.sub), 12) + 32;
|
|
129
100
|
return Math.min(NODE_MAX_W, Math.max(NODE_MIN_W, label, sub));
|
|
130
101
|
}
|
|
131
|
-
/** 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. */
|
|
132
102
|
export const diamondRoom = (w, h, far) => w * Math.max(0, 1 - far / (h / 2)) - 8;
|
|
133
|
-
/** The lines of a diamond with no card. The
|
|
134
|
-
* 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
|
|
135
|
-
* 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. */
|
|
103
|
+
/** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
|
|
136
104
|
export function diamondLines(item, w) {
|
|
137
105
|
const sub = item.sub ? str(item.sub) : '';
|
|
138
106
|
for (let n = sub ? 1 : 0;; n++) {
|
|
@@ -149,8 +117,7 @@ export function diamondLines(item, w) {
|
|
|
149
117
|
};
|
|
150
118
|
}
|
|
151
119
|
}
|
|
152
|
-
/** The lane copies
|
|
153
|
-
* STUB_ROOM and routes again: the pill then fits under or over its box, inside the band. Both renderers call it with their routes. */
|
|
120
|
+
/** The lane copies whose stub end has no clear place. The renderer grows each by STUB_ROOM. */
|
|
154
121
|
export function tightCopies(lanes, ends, edges, routed) {
|
|
155
122
|
const laneOf = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
|
|
156
123
|
const out = new Set();
|
|
@@ -166,30 +133,21 @@ export function tightCopies(lanes, ends, edges, routed) {
|
|
|
166
133
|
}
|
|
167
134
|
return out;
|
|
168
135
|
}
|
|
169
|
-
/** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
|
|
170
136
|
export const laneGutter = (lanes) => Math.max(0, ...lanes.map((l) => textWidth(str(l.label).toUpperCase(), 12))) + FRAME_SIDE * 2;
|
|
171
|
-
/** The block of a time column, from the block starts of a plan. */
|
|
172
137
|
export const laneBlock = (starts, c) => {
|
|
173
138
|
let k = 0;
|
|
174
139
|
while (k < starts.length - 2 && c >= starts[k + 1])
|
|
175
140
|
k++;
|
|
176
141
|
return k;
|
|
177
142
|
};
|
|
178
|
-
/** The pill texts of a cross-block edge: "label → Target" at the source, "from Source" at the target, and "→ Target", a
|
|
179
|
-
* shorter source text for a pill that has no place for the long one. */
|
|
180
143
|
export const stubTexts = (label, from, to) => [
|
|
181
144
|
`${str(label) ? str(label) + ' ' : ''}→ ${str(to)}`,
|
|
182
145
|
`from ${str(from)}`,
|
|
183
146
|
...(str(label) ? [`→ ${str(to)}`] : []),
|
|
184
147
|
];
|
|
185
148
|
/**
|
|
186
|
-
* The wrap of a lanes figure
|
|
187
|
-
*
|
|
188
|
-
* 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.
|
|
189
|
-
* If the wrap cannot make the text readable, the lanes do not wrap: the smallest font is under `minText`, or `floor` (the least
|
|
190
|
-
* width of the rest of the figure, such as the rail) is already too wide.
|
|
191
|
-
* 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
|
|
192
|
-
* 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.
|
|
149
|
+
* The wrap of a lanes figure: it wraps like text when the time columns do not fit `width`.
|
|
150
|
+
* The block count depends on the boxes only, not on pills.
|
|
193
151
|
*/
|
|
194
152
|
export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor = 0 } = {}) {
|
|
195
153
|
const cols = laneColumns(fig);
|
|
@@ -210,7 +168,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
|
|
|
210
168
|
const laneOf = new Map(lanes.filter((l) => l.id != null).map((l) => [l.id, l]));
|
|
211
169
|
const gutter = laneGutter(lanes);
|
|
212
170
|
const plan = (count) => {
|
|
213
|
-
// count blocks share the n columns evenly; the first n % count blocks take one column more.
|
|
214
171
|
const starts = [0];
|
|
215
172
|
for (let k = 0; k < count; k++)
|
|
216
173
|
starts.push(starts[k] + Math.floor(n / count) + (k < n % count ? 1 : 0));
|
|
@@ -219,7 +176,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
|
|
|
219
176
|
const held = [...new Set([...byId.keys()].map(blockOf))].sort((a, b) => a - b);
|
|
220
177
|
const blocks = held.length ? held : [0];
|
|
221
178
|
const wrapped = blocks.length > 1;
|
|
222
|
-
// The blocks that show a lane: a wrapped block shows only the lanes with a box in it.
|
|
223
179
|
const showing = (l) => wrapped ? [...new Set(l.children.map((b) => blockOf(b.id)))].sort((a, b) => a - b) : blocks;
|
|
224
180
|
const stubs = new Map();
|
|
225
181
|
const ends = new Map();
|
|
@@ -229,7 +185,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
|
|
|
229
185
|
const [fl, tl] = [laneOf.get(e.from), laneOf.get(e.to)];
|
|
230
186
|
if (!(byId.has(e.from) || fl) || !(byId.has(e.to) || tl))
|
|
231
187
|
return;
|
|
232
|
-
// 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.
|
|
233
188
|
let miss = false;
|
|
234
189
|
const near = (l, k, after) => {
|
|
235
190
|
const ks = showing(l);
|
|
@@ -260,9 +215,7 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
|
|
|
260
215
|
const texts = stubTexts(e.label, (byId.get(e.from) ?? fl).label, (byId.get(e.to) ?? tl).label);
|
|
261
216
|
stubs.set(id, texts);
|
|
262
217
|
});
|
|
263
|
-
// A
|
|
264
|
-
// 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
|
|
265
|
-
// cover a box, so that gap grows to the label plus 8 px. A label that fits keeps the gap of 0.4.0.
|
|
218
|
+
// A label wider than the gap would cover a box, so the gap grows.
|
|
266
219
|
const label = Array.from({ length: n }, () => 0);
|
|
267
220
|
for (const e of fig.edges) {
|
|
268
221
|
const [a, b] = [cols.get(e.from), cols.get(e.to)];
|
|
@@ -275,8 +228,6 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
|
|
|
275
228
|
const gaps = colW.map((_, c) => (c === starts[colBlock(c) + 1] - 1 ? FRAME_SIDE : Math.max(LANE_GAP, label[c])));
|
|
276
229
|
const per = Math.max(...starts.slice(1).map((e, k) => e - starts[k]));
|
|
277
230
|
const lead = Array.from({ length: count }, () => 0);
|
|
278
|
-
// 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
|
|
279
|
-
// block count never depends on a pill. A pill with no room finds a free place in route() (geometry.ts).
|
|
280
231
|
const want = (text) => STUB + labelPillW(text) + STUB_CLEAR;
|
|
281
232
|
const fit = blocks.every((k) => widthOf(starts[k], starts[k + 1], gaps, 0) <= room);
|
|
282
233
|
const out = new Map(), inn = new Map();
|
|
@@ -305,10 +256,9 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
|
|
|
305
256
|
const need = (out.get(c) ?? 0) + (c < end - 1 ? (inn.get(c + 1) ?? 0) : 0);
|
|
306
257
|
grow(need - gaps[c], () => (gaps[c] = need));
|
|
307
258
|
if (gaps[c] >= need)
|
|
308
|
-
roomOut.add(c).add(-1 - (c + 1));
|
|
259
|
+
roomOut.add(c).add(-1 - (c + 1));
|
|
309
260
|
}
|
|
310
261
|
}
|
|
311
|
-
// 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.
|
|
312
262
|
const laneIndex = (id) => lanes.findIndex((l) => l.id === id || l.children.some((b) => b.id === id));
|
|
313
263
|
const tall = new Set();
|
|
314
264
|
for (const [id] of stubs) {
|
|
@@ -332,11 +282,8 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
|
|
|
332
282
|
return [{ cols, starts, per, blocks, gutter, gaps, lead, ends, stubs, around, tall, lost }, fit];
|
|
333
283
|
};
|
|
334
284
|
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);
|
|
335
|
-
// The wrap only narrows the lanes. If the text stays too small at any width, keep one block, and check reports small-text.
|
|
336
285
|
if (font < minText || floor > limit)
|
|
337
286
|
return plan(1)[0];
|
|
338
|
-
// 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.
|
|
339
|
-
// The fit uses the boxes and the label gaps only, before a pill takes free room.
|
|
340
287
|
for (let count = 1; count <= Math.max(1, Math.floor(n / 2)); count++) {
|
|
341
288
|
const [p, fit] = plan(count);
|
|
342
289
|
if (fit)
|
|
@@ -344,11 +291,7 @@ export function lanePlan(fig, { width = 830, minText = 10, padding = 24, floor =
|
|
|
344
291
|
}
|
|
345
292
|
return plan(1)[0];
|
|
346
293
|
}
|
|
347
|
-
/**
|
|
348
|
-
* 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
|
|
349
|
-
* 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
|
|
350
|
-
* 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`.
|
|
351
|
-
*/
|
|
294
|
+
/** The rect id that an edge end routes to in wrapped lanes. A stub end is a point in the band. */
|
|
352
295
|
export function laneEnd(plan, edge, id, start, band, rects) {
|
|
353
296
|
const k = plan.ends.get(edge)?.[start ? 0 : 1];
|
|
354
297
|
const b = k == null ? undefined : band(id, k);
|
|
@@ -360,10 +303,7 @@ export function laneEnd(plan, edge, id, start, band, rects) {
|
|
|
360
303
|
rects[key] = texts ? { x, y: b.y, w: 0, h: b.h } : b;
|
|
361
304
|
return key;
|
|
362
305
|
}
|
|
363
|
-
/** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
|
|
364
|
-
* the axis strip height and the axis width of the SVG. */
|
|
365
306
|
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;
|
|
366
|
-
/** The days since 1970-01-01 (UTC) of a YYYY-MM-DD date. It is null for any other text and for a date that does not exist. */
|
|
367
307
|
export const dayOf = (iso) => {
|
|
368
308
|
const m = typeof iso === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso) : null;
|
|
369
309
|
if (!m)
|
|
@@ -375,14 +315,12 @@ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', '
|
|
|
375
315
|
const dateOf = (day) => new Date(day * 86400000);
|
|
376
316
|
// Day 0 is a Thursday, so this is 0 for Monday and 6 for Sunday.
|
|
377
317
|
const weekday = (day) => (((day + 3) % 7) + 7) % 7;
|
|
378
|
-
|
|
318
|
+
// ISO 8601: the week with the first Thursday of the year is week 1.
|
|
379
319
|
const isoWeek = (day) => {
|
|
380
320
|
const thu = day - weekday(day) + 3;
|
|
381
321
|
const jan1 = Date.UTC(dateOf(thu).getUTCFullYear(), 0, 1) / 86400000;
|
|
382
322
|
return Math.floor((thu - jan1) / 7) + 1;
|
|
383
323
|
};
|
|
384
|
-
/** The boxes with a real `from`, in layout order, with their track (the index of the top-level child) and their days.
|
|
385
|
-
* A `to` that is not a date, or is before `from`, makes the box a milestone; `checkSpec` reports it. */
|
|
386
324
|
const datedItems = (fig) => {
|
|
387
325
|
const out = [];
|
|
388
326
|
fig.layout.children.forEach((c, track) => nodes(isGroup(c) ? c : { children: [c] }).forEach((n) => {
|
|
@@ -404,7 +342,7 @@ export function timelineLayout(fig, axisWidth) {
|
|
|
404
342
|
const start = first - weekday(first);
|
|
405
343
|
let end = last + (6 - weekday(last));
|
|
406
344
|
const px = (day) => ((day - start) / (end - start + 1)) * axisWidth;
|
|
407
|
-
//
|
|
345
|
+
// After 4 passes the label stays, and `check` reports it.
|
|
408
346
|
for (let pass = 0; pass < 4; pass++) {
|
|
409
347
|
const over = Math.max(0, ...items.map((i) => {
|
|
410
348
|
const x = i.to != null ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
|
|
@@ -426,22 +364,21 @@ export function timelineLayout(fig, axisWidth) {
|
|
|
426
364
|
ticks.push({ x: px(d), label: MONTHS[dateOf(d).getUTCMonth()] });
|
|
427
365
|
const rows = fig.layout.children.map(() => 1);
|
|
428
366
|
const placed = [];
|
|
429
|
-
const
|
|
430
|
-
const
|
|
367
|
+
const rowEnd = fig.layout.children.map(() => []);
|
|
368
|
+
const MIN_ITEM_GAP = 8;
|
|
431
369
|
for (const i of [...items].sort((a, b) => a.from - b.from)) {
|
|
432
370
|
const bar = i.to != null;
|
|
433
371
|
const x = bar ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
|
|
434
372
|
const w = bar ? Math.max(TL_MIN_BAR, px(i.to + 1) - px(i.from)) : TL_DIAMOND;
|
|
435
373
|
const need = textWidth(str(i.label.label), 13);
|
|
436
374
|
const labelInside = bar && need + 16 <= w;
|
|
437
|
-
let row =
|
|
375
|
+
let row = rowEnd[i.track].findIndex((until) => until + MIN_ITEM_GAP <= x);
|
|
438
376
|
if (row < 0)
|
|
439
|
-
row =
|
|
440
|
-
|
|
377
|
+
row = rowEnd[i.track].length;
|
|
378
|
+
rowEnd[i.track][row] = labelInside ? x + w : x + w + 6 + need;
|
|
441
379
|
rows[i.track] = Math.max(rows[i.track], row + 1);
|
|
442
380
|
placed.push({ id: i.id, track: i.track, row, x, w, milestone: !bar, labelInside, date: fmt(i.from) });
|
|
443
381
|
}
|
|
444
|
-
// Keep the layout order in the result.
|
|
445
382
|
const order = new Map(items.map((i, k) => [i.id, k]));
|
|
446
383
|
placed.sort((a, b) => order.get(a.id) - order.get(b.id));
|
|
447
384
|
return {
|
|
@@ -456,7 +393,7 @@ export function timelineLayout(fig, axisWidth) {
|
|
|
456
393
|
};
|
|
457
394
|
}
|
|
458
395
|
const fmt = (d) => `${dateOf(d).getUTCDate()} ${MONTHS[dateOf(d).getUTCMonth()]}`;
|
|
459
|
-
/** One step
|
|
396
|
+
/** One step with one beat for each dated item, in date order. */
|
|
460
397
|
export function timelineBeats(fig) {
|
|
461
398
|
const items = datedItems(fig).sort((a, b) => a.from - b.from);
|
|
462
399
|
const flow = items.map((i, k) => ({
|
|
@@ -467,9 +404,7 @@ export function timelineBeats(fig) {
|
|
|
467
404
|
return [{ label: 'timeline', flow }];
|
|
468
405
|
}
|
|
469
406
|
const labeledGroups = (g) => (g.label ? 1 : 0) + g.children.reduce((n, c) => n + (isGroup(c) ? labeledGroups(c) : 0), 0);
|
|
470
|
-
/** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
|
|
471
407
|
export const counts = (p) => {
|
|
472
|
-
// A timeline with no steps plays one synthetic step.
|
|
473
408
|
const st = p.timeline && isLanesLayout(p.layout) && !p.steps?.length ? timelineBeats(p) : (p.steps ?? []);
|
|
474
409
|
return {
|
|
475
410
|
boxes: nodes(p.layout).length,
|
|
@@ -479,12 +414,8 @@ export const counts = (p) => {
|
|
|
479
414
|
messages: st.reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
|
|
480
415
|
};
|
|
481
416
|
};
|
|
482
|
-
/** Every id inside a child: its own id, and the ids of all boxes and groups below it. */
|
|
483
417
|
const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
|
|
484
|
-
/**
|
|
485
|
-
* A group's gap. A row's default gap grows to hold the widest label of an edge between two of its children, with 8px each side:
|
|
486
|
-
* the label sits at the middle of that gap. A gap the author sets stays as set; `flowfig check` reports it if it is too narrow.
|
|
487
|
-
*/
|
|
418
|
+
/** A row's default gap grows to hold its widest edge label, plus 8 px each side. */
|
|
488
419
|
export function groupGap(g, edges) {
|
|
489
420
|
if (g.gap != null)
|
|
490
421
|
return g.gap;
|
package/dist/open.d.ts
CHANGED
|
@@ -3,13 +3,10 @@ export type Opener = {
|
|
|
3
3
|
args: string[];
|
|
4
4
|
verbatim: boolean;
|
|
5
5
|
};
|
|
6
|
-
/** The command that opens a file in the default program of the system. Pure. */
|
|
7
6
|
export declare function openerFor(platform: NodeJS.Platform, file: string): Opener;
|
|
8
|
-
/** The
|
|
7
|
+
/** The temp page name: each character other than a letter, digit, - or _ becomes -. */
|
|
9
8
|
export declare function pageName(svgPath: string): string;
|
|
10
|
-
/** The line that `open`, a render with `--open` and `draw --open` print. */
|
|
11
9
|
export declare const openedLine: (page: string) => string;
|
|
12
|
-
/** Write the page to a new temp folder
|
|
10
|
+
/** Write the page to a new temp folder, then start the opener. Returns the written paths. */
|
|
13
11
|
export declare function openSvg(svgPath: string, html?: string): Promise<string[]>;
|
|
14
|
-
/** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
|
|
15
12
|
export declare function runOpen(argv: string[]): Promise<number>;
|
package/dist/open.js
CHANGED
|
@@ -1,39 +1,35 @@
|
|
|
1
|
-
// `flowfig open`: show a figure in the default browser. An .svg file often opens in an editor; an .html page opens in a browser.
|
|
2
1
|
import { spawn } from 'node:child_process';
|
|
3
2
|
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
3
|
import { tmpdir } from 'node:os';
|
|
5
4
|
import { basename, join } from 'node:path';
|
|
6
5
|
import { pageHtml } from './page.js';
|
|
7
6
|
const USAGE = 'usage: flowfig open <figure.svg> [--html <path>]';
|
|
8
|
-
/** The command that opens a file in the default program of the system. Pure. */
|
|
9
7
|
export function openerFor(platform, file) {
|
|
10
8
|
if (platform === 'darwin')
|
|
11
9
|
return { cmd: 'open', args: [file], verbatim: false };
|
|
12
|
-
// `start` reads its first quoted argument as a window title
|
|
10
|
+
// `start` reads its first quoted argument as a window title.
|
|
13
11
|
if (platform === 'win32')
|
|
14
12
|
return { cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`], verbatim: true };
|
|
15
13
|
return { cmd: 'xdg-open', args: [file], verbatim: false };
|
|
16
14
|
}
|
|
17
|
-
/** The
|
|
15
|
+
/** The temp page name: each character other than a letter, digit, - or _ becomes -. */
|
|
18
16
|
export function pageName(svgPath) {
|
|
19
|
-
// The safe name
|
|
17
|
+
// The safe name keeps cmd metacharacters such as & out of the command line.
|
|
20
18
|
return (basename(svgPath)
|
|
21
19
|
.replace(/\.svg$/, '')
|
|
22
20
|
.replace(/[^\w-]/g, '-') + '.html');
|
|
23
21
|
}
|
|
24
|
-
/** The line that `open`, a render with `--open` and `draw --open` print. */
|
|
25
22
|
export const openedLine = (page) => `${page} — opened in the default browser`;
|
|
26
|
-
/** Write the page to a new temp folder
|
|
23
|
+
/** Write the page to a new temp folder, then start the opener. Returns the written paths. */
|
|
27
24
|
export async function openSvg(svgPath, html) {
|
|
28
25
|
const page = pageHtml(readFileSync(svgPath, 'utf8'), basename(svgPath));
|
|
29
|
-
// A
|
|
26
|
+
// A fixed shared path lets another user plant a symlink.
|
|
30
27
|
const dir = mkdtempSync(join(tmpdir(), 'flowfig-open-'));
|
|
31
|
-
// The
|
|
28
|
+
// The browser reads the page after the CLI stops, so keep it.
|
|
32
29
|
const temp = join(dir, pageName(svgPath));
|
|
33
30
|
writeFileSync(temp, page);
|
|
34
31
|
if (html)
|
|
35
32
|
writeFileSync(html, page);
|
|
36
|
-
// FLOWFIG_OPENER replaces the system opener, so the tests start no browser.
|
|
37
33
|
const o = process.env.FLOWFIG_OPENER
|
|
38
34
|
? { cmd: process.env.FLOWFIG_OPENER, args: [temp], verbatim: false }
|
|
39
35
|
: openerFor(process.platform, temp);
|
|
@@ -44,7 +40,6 @@ export async function openSvg(svgPath, html) {
|
|
|
44
40
|
});
|
|
45
41
|
return html ? [temp, html] : [temp];
|
|
46
42
|
}
|
|
47
|
-
/** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
|
|
48
43
|
export async function runOpen(argv) {
|
|
49
44
|
const fail = (message, code) => (console.error(message), code);
|
|
50
45
|
const args = [...argv];
|
package/dist/page.d.ts
CHANGED
package/dist/page.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
// The page that holds a figure SVG: `flowfig open` shows it in the default browser, and `flowfig gif` captures it. Pure.
|
|
2
1
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
3
|
-
/** The page: the figure SVG inline, centered, scaled down to the window width. The title is escaped. */
|
|
4
2
|
export function pageHtml(svg, title) {
|
|
5
|
-
//
|
|
3
|
+
// Use element selectors only: figure styles use class names.
|
|
6
4
|
return `<!doctype html>
|
|
7
5
|
<meta charset="utf-8">
|
|
8
6
|
<title>${esc(title)}</title>
|
package/dist/picker.d.ts
CHANGED
|
@@ -23,11 +23,9 @@ export type Picked = {
|
|
|
23
23
|
ids: Set<string>;
|
|
24
24
|
mcp: boolean;
|
|
25
25
|
};
|
|
26
|
-
/**
|
|
26
|
+
/** A terminal narrower than the banner gets the plain version line. */
|
|
27
27
|
export declare function banner({ color, width }: Screen): string[];
|
|
28
|
-
/** The picker screen, one string per line. */
|
|
29
28
|
export declare function screen(s: PickState, o: Screen): string[];
|
|
30
|
-
/** One key press. Returns the next state, or `confirm` or `cancel`. */
|
|
31
29
|
export declare function keyStep(s: PickState, k: Key): PickState | 'confirm' | 'cancel';
|
|
32
|
-
/**
|
|
30
|
+
/** Returns `undefined` on cancel, or `'fallback'` if the terminal has no raw mode. */
|
|
33
31
|
export declare function runPicker(rows: PickRow[], start: Set<string>, mcp: boolean): Promise<Picked | undefined | 'fallback'>;
|
package/dist/picker.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// The arrow-key picker for `flowfig init`. The screen and the keys are pure, so the tests need no TTY.
|
|
2
1
|
import { emitKeypressEvents } from 'node:readline';
|
|
3
2
|
import { VERSION } from './version.js';
|
|
4
3
|
const BANNER = [
|
|
@@ -10,26 +9,22 @@ const BANNER = [
|
|
|
10
9
|
'╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═════╝ ',
|
|
11
10
|
];
|
|
12
11
|
const BANNER_WIDTH = Math.max(...BANNER.map((l) => l.length));
|
|
13
|
-
const NOT = '(not detected)'.length;
|
|
12
|
+
const NOT = '(not detected)'.length;
|
|
14
13
|
const HELP = '↑↓ move · space toggle · a all · enter confirm · esc cancel';
|
|
15
14
|
const SHADOW = '38;2;43;93;143';
|
|
16
15
|
const ansi = (on, code, text) => (on ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
17
|
-
/** Row `i` of `n`: the blue gradient from #4da3ff to #0074d9. */
|
|
18
16
|
const blue = (i, n) => {
|
|
19
17
|
const mix = (a, b) => Math.round(a + ((b - a) * i) / (n - 1));
|
|
20
18
|
return `38;2;${mix(0x4d, 0x00)};${mix(0xa3, 0x74)};${mix(0xff, 0xd9)}`;
|
|
21
19
|
};
|
|
22
|
-
/**
|
|
20
|
+
/** A terminal narrower than the banner gets the plain version line. */
|
|
23
21
|
export function banner({ color, width }) {
|
|
24
22
|
const ver = `flowfig ${VERSION}`;
|
|
25
23
|
if (width < BANNER_WIDTH)
|
|
26
24
|
return [ansi(color, '1;38;2;77;163;255', ver)];
|
|
27
|
-
const art = BANNER.map((l, i) =>
|
|
28
|
-
// each run of one character type gets its own colour: the block letters take the gradient, the shadow takes dim blue
|
|
29
|
-
l.replace(/█+|[╗║╚═╔╝]+/g, (run) => ansi(color, run[0] === '█' ? blue(i, BANNER.length - 1) : SHADOW, run)));
|
|
25
|
+
const art = BANNER.map((l, i) => l.replace(/█+|[╗║╚═╔╝]+/g, (run) => ansi(color, run[0] === '█' ? blue(i, BANNER.length - 1) : SHADOW, run)));
|
|
30
26
|
return [...art, ansi(color, '90', ver)];
|
|
31
27
|
}
|
|
32
|
-
/** Cut the pieces `[text, colour code]` to `width` visible characters, then colour them. */
|
|
33
28
|
function fit(pieces, width, color) {
|
|
34
29
|
let room = width;
|
|
35
30
|
let out = '';
|
|
@@ -42,9 +37,9 @@ function fit(pieces, width, color) {
|
|
|
42
37
|
}
|
|
43
38
|
return out;
|
|
44
39
|
}
|
|
45
|
-
/** The picker screen, one string per line. */
|
|
46
40
|
export function screen(s, o) {
|
|
47
|
-
|
|
41
|
+
// Leave the last column free, so no row wraps.
|
|
42
|
+
const w = Math.max(o.width - 1, 1);
|
|
48
43
|
const pad = Math.max(...s.rows.map((r) => r.name.length), 10);
|
|
49
44
|
const mark = (i) => (i === s.cursor ? '›' : ' ');
|
|
50
45
|
const rows = s.rows.map((r, i) => fit([
|
|
@@ -70,9 +65,8 @@ export function screen(s, o) {
|
|
|
70
65
|
fit([[HELP, '90']], w, o.color),
|
|
71
66
|
];
|
|
72
67
|
}
|
|
73
|
-
/** One key press. Returns the next state, or `confirm` or `cancel`. */
|
|
74
68
|
export function keyStep(s, k) {
|
|
75
|
-
const last = s.rows.length;
|
|
69
|
+
const last = s.rows.length;
|
|
76
70
|
const n = last + 1;
|
|
77
71
|
if (k.name === 'escape' || k.name === 'q' || (k.ctrl && k.name === 'c'))
|
|
78
72
|
return 'cancel';
|
|
@@ -87,14 +81,17 @@ export function keyStep(s, k) {
|
|
|
87
81
|
return { ...s, mcp: !s.mcp };
|
|
88
82
|
const id = s.rows[s.cursor].id;
|
|
89
83
|
const sel = new Set(s.sel);
|
|
90
|
-
sel.has(id)
|
|
84
|
+
if (sel.has(id))
|
|
85
|
+
sel.delete(id);
|
|
86
|
+
else
|
|
87
|
+
sel.add(id);
|
|
91
88
|
return { ...s, sel };
|
|
92
89
|
}
|
|
93
90
|
if (k.name === 'a')
|
|
94
91
|
return { ...s, sel: s.sel.size === last ? new Set() : new Set(s.rows.map((r) => r.id)) };
|
|
95
92
|
return s;
|
|
96
93
|
}
|
|
97
|
-
/**
|
|
94
|
+
/** Returns `undefined` on cancel, or `'fallback'` if the terminal has no raw mode. */
|
|
98
95
|
export function runPicker(rows, start, mcp) {
|
|
99
96
|
const { stdin, stdout } = process;
|
|
100
97
|
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function')
|
package/dist/png.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Reads only what Chrome writes: 8-bit RGB or RGBA, no interlace.
|
|
2
2
|
import { inflateSync } from 'node:zlib';
|
|
3
3
|
const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
4
4
|
export function decodePng(png) {
|
|
@@ -7,7 +7,6 @@ export function decodePng(png) {
|
|
|
7
7
|
const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
|
|
8
8
|
let width = 0, height = 0, bpp = 0;
|
|
9
9
|
const idat = [];
|
|
10
|
-
// The CRC of each chunk is not checked: the PNG comes from the browser over a local pipe.
|
|
11
10
|
for (let at = 8; at + 8 <= png.length;) {
|
|
12
11
|
const len = view.getUint32(at);
|
|
13
12
|
const kind = String.fromCharCode(...png.subarray(at + 4, at + 8));
|
|
@@ -36,11 +35,12 @@ export function decodePng(png) {
|
|
|
36
35
|
if (raw.length < height * (stride + 1))
|
|
37
36
|
throw new Error('not a PNG: the image data is too short');
|
|
38
37
|
const data = new Uint8Array(width * height * 4);
|
|
38
|
+
const data32 = new Uint32Array(data.buffer);
|
|
39
39
|
let prev = new Uint8Array(stride);
|
|
40
40
|
for (let y = 0; y < height; y++) {
|
|
41
41
|
const filter = raw[y * (stride + 1)];
|
|
42
42
|
const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
43
|
-
//
|
|
43
|
+
// A Uint8Array write keeps the low 8 bits, as the PNG standard needs.
|
|
44
44
|
if (filter === 1)
|
|
45
45
|
for (let x = bpp; x < stride; x++)
|
|
46
46
|
row[x] += row[x - bpp];
|
|
@@ -53,17 +53,16 @@ export function decodePng(png) {
|
|
|
53
53
|
else if (filter === 4)
|
|
54
54
|
for (let x = 0; x < stride; x++) {
|
|
55
55
|
const a = x >= bpp ? row[x - bpp] : 0, b = prev[x], c = x >= bpp ? prev[x - bpp] : 0;
|
|
56
|
-
const
|
|
56
|
+
const p = a + b - c, pa = p > a ? p - a : a - p, pb = p > b ? p - b : b - p, pc = p > c ? p - c : c - p;
|
|
57
57
|
row[x] += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
58
58
|
}
|
|
59
59
|
else if (filter !== 0)
|
|
60
60
|
throw new Error(`unsupported PNG: filter ${filter}`);
|
|
61
|
-
|
|
62
|
-
data
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
61
|
+
if (bpp === 4)
|
|
62
|
+
data.set(row, y * stride);
|
|
63
|
+
else
|
|
64
|
+
for (let x = 0, o = y * width; x < stride; x += 3)
|
|
65
|
+
data32[o++] = (row[x + 2] << 16) | (row[x + 1] << 8) | row[x] | 0xff000000;
|
|
67
66
|
prev = row;
|
|
68
67
|
}
|
|
69
68
|
return { width, height, data };
|
package/dist/rail.d.ts
CHANGED
|
@@ -29,7 +29,6 @@ export type RailRow = {
|
|
|
29
29
|
step: number;
|
|
30
30
|
label: string;
|
|
31
31
|
messages: number;
|
|
32
|
-
/** The divider: it starts after the open label or after the folded label, and ends before the counter. */
|
|
33
32
|
line: {
|
|
34
33
|
open: number;
|
|
35
34
|
folded: number;
|
|
@@ -58,7 +57,7 @@ export type RailState = {
|
|
|
58
57
|
};
|
|
59
58
|
export type Rail = {
|
|
60
59
|
width: number;
|
|
61
|
-
/** The y where the first row starts
|
|
60
|
+
/** The y where the first row starts. */
|
|
62
61
|
head: number;
|
|
63
62
|
height: number;
|
|
64
63
|
columns: RailColumn[];
|
|
@@ -72,7 +71,6 @@ export type Rail = {
|
|
|
72
71
|
folds: boolean;
|
|
73
72
|
states: RailState[][];
|
|
74
73
|
};
|
|
75
|
-
/** The folded phase text. Both renderers and the layout use it, so the measured width matches the drawn width. */
|
|
76
74
|
export declare const foldedLabel: (row: {
|
|
77
75
|
label: string;
|
|
78
76
|
messages: number;
|