flowfig 0.2.0 → 0.4.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 +80 -6
- package/dist/browser.d.ts +33 -0
- package/dist/browser.js +201 -0
- package/dist/check.js +82 -4
- package/dist/cli.js +180 -5
- package/dist/diff.js +2 -2
- package/dist/draw.d.ts +12 -0
- package/dist/draw.js +195 -0
- package/dist/geometry.d.ts +4 -1
- package/dist/geometry.js +39 -12
- package/dist/gif.d.ts +12 -0
- package/dist/gif.js +245 -0
- package/dist/guide.d.ts +3 -3
- package/dist/guide.js +35 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +280 -35
- package/dist/init.d.ts +5 -0
- package/dist/init.js +58 -22
- package/dist/model.d.ts +107 -3
- package/dist/model.js +185 -7
- package/dist/open.d.ts +15 -0
- package/dist/open.js +82 -0
- package/dist/page.d.ts +2 -0
- package/dist/page.js +16 -0
- package/dist/picker.d.ts +33 -0
- package/dist/picker.js +148 -0
- package/dist/png.d.ts +7 -0
- package/dist/png.js +70 -0
- package/dist/rail.d.ts +2 -1
- package/dist/rail.js +15 -1
- package/dist/scene.d.ts +2 -0
- package/dist/source.d.ts +2 -0
- package/dist/source.js +5 -0
- package/dist/svg.js +313 -40
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/init.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// `flowfig init`: write flowfig instructions for the coding agents of a repo. Pure functions where possible, so the tests need no TTY.
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
6
|
import { AGENT_TEXT, SKILL_DESCRIPTION } from './guide.js';
|
|
7
|
+
import { runPicker } from './picker.js';
|
|
7
8
|
const START = '<!-- flowfig:start -->';
|
|
8
9
|
const END = '<!-- flowfig:end -->';
|
|
9
10
|
export const MCP_ENTRY = { command: 'npx', args: ['flowfig', 'mcp'] };
|
|
@@ -15,6 +16,10 @@ export const AGENTS = [
|
|
|
15
16
|
file: '.claude/skills/figure/SKILL.md',
|
|
16
17
|
kind: 'whole',
|
|
17
18
|
mcp: { file: '.mcp.json', key: 'mcpServers', type: true, global: '.claude.json' },
|
|
19
|
+
also: {
|
|
20
|
+
file: 'CLAUDE.md',
|
|
21
|
+
text: '## Diagrams\nDraw every diagram with the `figure` skill (flowfig). The user can run `/figure <question>`. Do not answer a diagram request with a Mermaid block or ASCII art, unless the user asks for that tool.',
|
|
22
|
+
},
|
|
18
23
|
head: `---\nname: figure\ndescription: ${JSON.stringify(SKILL_DESCRIPTION)}\nuser_invocable: true\n---\n\n`,
|
|
19
24
|
},
|
|
20
25
|
{ id: 'agents', name: 'AGENTS.md', marks: ['AGENTS.md'], file: 'AGENTS.md', kind: 'section', note: 'Codex, Amp, Jules, ...' },
|
|
@@ -87,12 +92,16 @@ export function renderList(selected, detected, dir) {
|
|
|
87
92
|
'Type numbers to toggle (for example "1 3"), Enter to write, q to quit.',
|
|
88
93
|
].join('\n');
|
|
89
94
|
}
|
|
90
|
-
const block = () => `${START}\n${
|
|
95
|
+
const block = (text = AGENT_TEXT) => `${START}\n${text.trimEnd()}\n${END}\n`;
|
|
91
96
|
/** The new file text, or `undefined` if the file belongs to the user and init must leave it. `old` is `undefined` for a missing file. */
|
|
92
97
|
export function place(a, old) {
|
|
93
98
|
const mine = block();
|
|
94
99
|
if (a.kind === 'whole')
|
|
95
100
|
return old === undefined || old.includes(START) ? `${a.head ?? ''}${mine}` : undefined;
|
|
101
|
+
return section(old, mine);
|
|
102
|
+
}
|
|
103
|
+
/** `old` with the marked block `mine` added or replaced. `undefined` if the block is open. */
|
|
104
|
+
function section(old, mine) {
|
|
96
105
|
if (old === undefined)
|
|
97
106
|
return mine;
|
|
98
107
|
const s = old.indexOf(START), e = old.indexOf(END, s);
|
|
@@ -124,12 +133,17 @@ export function registerMcp(a, old) {
|
|
|
124
133
|
json[a.mcp.key] = { ...servers, flowfig: entry };
|
|
125
134
|
return JSON.stringify(json, null, 2) + '\n';
|
|
126
135
|
}
|
|
136
|
+
/** `✓ ` before a finished write. */
|
|
137
|
+
const mark = (status) => (/^(created|updated|unchanged)$/.test(status) ? '✓ ' : '');
|
|
138
|
+
/** The path to show: relative to the repo folder, or `~` for the home folder. */
|
|
139
|
+
const shown = (path, base, global) => (global ? `~/${relative(base, path)}` : relative(base, path));
|
|
127
140
|
const HINT = 'run with -y or --agents <ids>';
|
|
128
141
|
/** Run `flowfig init`. Returns the exit code. */
|
|
129
142
|
export async function runInit(argv) {
|
|
130
143
|
const args = [...argv];
|
|
131
144
|
const flag = (n, ...alias) => [n, ...alias].some((x) => args.includes(x) && args.splice(args.indexOf(x), 1).length > 0);
|
|
132
|
-
const listAgents = flag('--list-agents'), all = flag('--all-agents'), yes = flag('-y', '--yes'), global = flag('--global'), dry = flag('--dry-run')
|
|
145
|
+
const listAgents = flag('--list-agents'), all = flag('--all-agents'), yes = flag('-y', '--yes'), global = flag('--global'), dry = flag('--dry-run');
|
|
146
|
+
let noMcp = flag('--no-mcp');
|
|
133
147
|
let ids;
|
|
134
148
|
const at = args.indexOf('--agents');
|
|
135
149
|
if (at !== -1)
|
|
@@ -165,30 +179,50 @@ export async function runInit(argv) {
|
|
|
165
179
|
return fail(HINT);
|
|
166
180
|
}
|
|
167
181
|
else {
|
|
168
|
-
const
|
|
169
|
-
|
|
182
|
+
const rows = AGENTS.map((a) => ({
|
|
183
|
+
id: a.id,
|
|
184
|
+
name: a.name,
|
|
185
|
+
detected: detected.includes(a.id),
|
|
186
|
+
files: [...new Set([a.file, a.also?.file, a.mcp?.file].filter((f) => !!f))],
|
|
187
|
+
}));
|
|
188
|
+
const got = await runPicker(rows, auto, !noMcp);
|
|
189
|
+
if (got === 'fallback') {
|
|
190
|
+
const picked = await pick(auto, detected, dir);
|
|
191
|
+
if (!picked)
|
|
192
|
+
return 0;
|
|
193
|
+
chosen = picked;
|
|
194
|
+
}
|
|
195
|
+
else if (!got)
|
|
170
196
|
return 0;
|
|
171
|
-
|
|
197
|
+
else {
|
|
198
|
+
chosen = got.ids;
|
|
199
|
+
noMcp = !got.mcp;
|
|
200
|
+
}
|
|
172
201
|
}
|
|
173
202
|
for (const a of AGENTS.filter((x) => chosen.has(x.id))) {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
status
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
status =
|
|
203
|
+
const files = [{ file: a.file, place: (o) => place(a, o) }];
|
|
204
|
+
if (a.also && !global)
|
|
205
|
+
files.push({ file: a.also.file, place: (o) => section(o, block(a.also.text)) });
|
|
206
|
+
for (const f of files) {
|
|
207
|
+
const path = global ? join(homedir(), f.file) : join(dir, f.file);
|
|
208
|
+
const old = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
209
|
+
const next = f.place(old);
|
|
210
|
+
let status;
|
|
211
|
+
if (next === undefined)
|
|
212
|
+
status = old?.includes(START) ? 'skipped (flowfig:start without flowfig:end)' : 'skipped (the file exists and is not from flowfig)';
|
|
213
|
+
else if (next === old)
|
|
214
|
+
status = 'unchanged';
|
|
186
215
|
else {
|
|
187
|
-
|
|
188
|
-
|
|
216
|
+
status = old === undefined ? 'created' : 'updated';
|
|
217
|
+
if (dry)
|
|
218
|
+
status = `would ${status === 'created' ? 'create' : 'update'}`;
|
|
219
|
+
else {
|
|
220
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
221
|
+
writeFileSync(path, next);
|
|
222
|
+
}
|
|
189
223
|
}
|
|
224
|
+
console.log(`${mark(status)}${status.padEnd(9)} ${shown(path, global ? homedir() : dir, global)}`);
|
|
190
225
|
}
|
|
191
|
-
console.log(`${status.padEnd(9)} ${path}`);
|
|
192
226
|
if (noMcp)
|
|
193
227
|
continue;
|
|
194
228
|
if (a.mcpNote) {
|
|
@@ -227,8 +261,10 @@ export async function runInit(argv) {
|
|
|
227
261
|
}
|
|
228
262
|
}
|
|
229
263
|
}
|
|
230
|
-
console.log(`${mcpStatus.padEnd(9)} ${mcpPath}`);
|
|
264
|
+
console.log(`${mark(mcpStatus)}${mcpStatus.padEnd(9)} ${shown(mcpPath, global ? homedir() : dir, global)}`);
|
|
231
265
|
}
|
|
266
|
+
if (!dry)
|
|
267
|
+
console.log(`Next: start a new agent session.${chosen.has('claude') ? ' In Claude Code, run /figure how does login work.' : ''}`);
|
|
232
268
|
return 0;
|
|
233
269
|
}
|
|
234
270
|
function fail(message) {
|
package/dist/model.d.ts
CHANGED
|
@@ -13,6 +13,16 @@ export type FigNode = {
|
|
|
13
13
|
lines?: number;
|
|
14
14
|
/** Width in px. Overrides the width the layout picks. */
|
|
15
15
|
width?: number;
|
|
16
|
+
/** In a `lanes` figure: the time column of the box, 0-based. Default: the box's first appearance in the steps. */
|
|
17
|
+
at?: number;
|
|
18
|
+
/** In a `timeline` figure: the start of the item, or the date of a milestone, as YYYY-MM-DD. */
|
|
19
|
+
from?: string;
|
|
20
|
+
/** In a `timeline` figure: the last day of the item, as YYYY-MM-DD. The day counts. Without it the box is a milestone. */
|
|
21
|
+
to?: string;
|
|
22
|
+
/** A permanent state color for the box, such as a failing part. It gives a 1 px border and a light tint. An arrival with no hop tone uses it too. */
|
|
23
|
+
tone?: FigTone;
|
|
24
|
+
/** A lifecycle mark: `start` draws a filled dot before the box, `end` a ringed dot after it. No layout change. */
|
|
25
|
+
mark?: 'start' | 'end';
|
|
16
26
|
/** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
|
|
17
27
|
source?: string;
|
|
18
28
|
};
|
|
@@ -56,6 +66,8 @@ export type FigHop = string | {
|
|
|
56
66
|
back?: boolean;
|
|
57
67
|
/** A small card that rides along with the packet. */
|
|
58
68
|
data?: ReactNode;
|
|
69
|
+
/** Colors the packet, its data card, the edge and the box it arrives at, for this beat. The trail after the beat stays the accent. Default: the accent. */
|
|
70
|
+
tone?: FigTone;
|
|
59
71
|
/** Marks a message that does not wait for an answer. The rail draws it dashed, with an `async` tag. Default: `false`. */
|
|
60
72
|
async?: boolean;
|
|
61
73
|
/** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
|
|
@@ -71,7 +83,9 @@ export type FigBeat = {
|
|
|
71
83
|
show?: Record<string, FigContent>;
|
|
72
84
|
/** Ids of boxes to highlight for this beat, without a packet or a content card. */
|
|
73
85
|
light?: string[];
|
|
74
|
-
/**
|
|
86
|
+
/** Ids of boxes that take the active look for this beat: a 2 px border, a tint and a glow. The boxes in `light` keep the trail look. */
|
|
87
|
+
focus?: string[];
|
|
88
|
+
/** How long the beat lasts in ms. The packet crosses in `speed`; the rest is a hold. An explicit `ms` wins as written. Default: `speed` plus the time to read `say`. */
|
|
75
89
|
ms?: number;
|
|
76
90
|
};
|
|
77
91
|
/** One line of a content card. */
|
|
@@ -89,8 +103,8 @@ export type FigRow = {
|
|
|
89
103
|
/** Draws the text in a monospace font. Default: `false`. */
|
|
90
104
|
mono?: boolean;
|
|
91
105
|
};
|
|
92
|
-
/** The color of a row tag. Each tone is a fixed color. */
|
|
93
|
-
export type FigTone = 'blue' | 'purple' | 'green' | 'orange' | 'gray';
|
|
106
|
+
/** The color of a row tag, a hop or a box. Each tone is a fixed color. */
|
|
107
|
+
export type FigTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'gray';
|
|
94
108
|
/** What a content card shows: rows, or anything React can render. */
|
|
95
109
|
export type FigContent = FigRow[] | ReactNode;
|
|
96
110
|
/** One story the figure can tell. The player shows one tab for each step. */
|
|
@@ -124,6 +138,12 @@ export type FlowProps = {
|
|
|
124
138
|
check?: boolean;
|
|
125
139
|
/** Draw the steps as a lifeline rail under the map: one row for each message, with its payload. `'only'` draws the rail without the map. Default: `false`. */
|
|
126
140
|
rail?: boolean | 'only';
|
|
141
|
+
/** Draw the layout as swimlanes: a `column` group of labeled groups, one per role, with the boxes in time order left to right. Default: off. */
|
|
142
|
+
lanes?: true;
|
|
143
|
+
/** Draw the layout as a timeline: one labeled group per track, with the boxes placed by their `from` and `to` dates. Default: off. */
|
|
144
|
+
timeline?: true;
|
|
145
|
+
/** In a `timeline` figure: the date of the fixed today marker, as YYYY-MM-DD. With no `today`, the figure has no marker. */
|
|
146
|
+
today?: string;
|
|
127
147
|
};
|
|
128
148
|
/** A figure file's default export: a title, a source note and the props. */
|
|
129
149
|
export type Figure = {
|
|
@@ -137,6 +157,7 @@ export type Beat = Omit<FigBeat, 'edges'> & {
|
|
|
137
157
|
edge: string;
|
|
138
158
|
back: boolean;
|
|
139
159
|
data?: ReactNode;
|
|
160
|
+
tone?: FigTone;
|
|
140
161
|
async?: boolean;
|
|
141
162
|
source?: string;
|
|
142
163
|
}[];
|
|
@@ -155,6 +176,10 @@ export declare const BASE_RATE = 1.25;
|
|
|
155
176
|
export declare const CARD_LINE = 15, CARD_PAD = 6;
|
|
156
177
|
/** The fixed color of each row tone. */
|
|
157
178
|
export declare const TONES: Record<FigTone, string>;
|
|
179
|
+
/** A tone as a fill under white text: darker, so the text keeps its contrast. */
|
|
180
|
+
export declare const toneFill: (c: string) => string;
|
|
181
|
+
/** The light tint of a tone over a background: the fill of a toned box. */
|
|
182
|
+
export declare const toneTint: (c: string, bg: string, pct?: number) => string;
|
|
158
183
|
/** 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. */
|
|
159
184
|
export declare const labelPillW: (label: string) => number;
|
|
160
185
|
/** The width of the `async` tag in px. */
|
|
@@ -182,7 +207,86 @@ export declare const DARK: {
|
|
|
182
207
|
export declare const ON_ACCENT = "#ffffff";
|
|
183
208
|
/** Only text survives outside React: a React element has no string form. */
|
|
184
209
|
export declare const str: (n: unknown) => string;
|
|
210
|
+
/** The time a reader needs for a caption line: 400 ms plus 240 ms per word. The result is 0 for no text. */
|
|
211
|
+
export declare const readMs: (say: unknown) => number;
|
|
212
|
+
/** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
|
|
213
|
+
export declare const beatMs: (b: Beat, speed: number) => number;
|
|
214
|
+
/** The item the playhead rests on at beat `i`: the first dated id in the latest focus up to `i`, or undefined for home. */
|
|
215
|
+
export declare const playheadItem: <T extends {
|
|
216
|
+
id: string;
|
|
217
|
+
}>(items: T[], beats: {
|
|
218
|
+
focus?: string[];
|
|
219
|
+
}[], i: number) => T | undefined;
|
|
220
|
+
/** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
|
|
221
|
+
export declare const outsideLabelRect: (bar: {
|
|
222
|
+
x: number;
|
|
223
|
+
y: number;
|
|
224
|
+
w: number;
|
|
225
|
+
h: number;
|
|
226
|
+
}, label: string) => {
|
|
227
|
+
x: number;
|
|
228
|
+
y: number;
|
|
229
|
+
w: number;
|
|
230
|
+
h: number;
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* The time span in which the playhead label shows its date. The label waits for the line to arrive: it stays hidden for
|
|
234
|
+
* 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.
|
|
235
|
+
*/
|
|
236
|
+
export declare const labelSpan: (t0: number, end: number, ramp: number) => [number, number];
|
|
237
|
+
/** True if a playhead date label at x would overlap the "today" label, so it moves to the top row. Widths are in px. */
|
|
238
|
+
export declare const dateLabelRaised: (x: number, today: number | null | undefined, w: number, todayW: number) => boolean;
|
|
239
|
+
/** The hold at the end of each step, before the next step starts. */
|
|
240
|
+
export declare const STEP_HOLD_MS = 2000;
|
|
185
241
|
export declare const nodes: (g: FigGroup) => FigNode[];
|
|
242
|
+
/** Lane layout constants, shared by both renderers: the gap between time columns, the padding inside a lane, and the gap between lanes. */
|
|
243
|
+
export declare const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
|
|
244
|
+
/** True if the layout can draw as lanes: a `column` group whose every child is a labeled group with boxes only. */
|
|
245
|
+
export declare const isLanesLayout: (g: FigGroup) => boolean;
|
|
246
|
+
/** True if `at` is a usable time column: an integer of 0 or more. */
|
|
247
|
+
export declare const validAt: (at: unknown) => at is number;
|
|
248
|
+
/** The time column of each box in a lanes figure: its first appearance in the steps (from, then to; a back hop to, then from),
|
|
249
|
+
* then the boxes no step touches in layout order. `at` overrides. Two boxes may share a column. */
|
|
250
|
+
export declare function laneColumns(fig: FlowProps): Map<string, number>;
|
|
251
|
+
/** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
|
|
252
|
+
* the axis strip height and the axis width of the SVG. */
|
|
253
|
+
export declare const TL_BAR_H = 28, TL_ROW_GAP = 8, TL_MIN_BAR = 24, TL_DIAMOND = 14, TL_AXIS_H = 28, TL_AXIS_W = 640;
|
|
254
|
+
/** 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. */
|
|
255
|
+
export declare const dayOf: (iso: string) => number | null;
|
|
256
|
+
export type TimelineItem = {
|
|
257
|
+
id: string;
|
|
258
|
+
track: number;
|
|
259
|
+
row: number;
|
|
260
|
+
x: number;
|
|
261
|
+
w: number;
|
|
262
|
+
milestone: boolean;
|
|
263
|
+
labelInside: boolean;
|
|
264
|
+
/** The `from` date in the d MMM form, for the playhead label. */
|
|
265
|
+
date: string;
|
|
266
|
+
};
|
|
267
|
+
export type TimelineLayout = {
|
|
268
|
+
/** The range in days, rounded out to whole weeks: the Monday of the first week and the Sunday of the last week. */
|
|
269
|
+
start: number;
|
|
270
|
+
end: number;
|
|
271
|
+
/** The week ticks (`W41`) or the month ticks (`Oct`). */
|
|
272
|
+
ticks: {
|
|
273
|
+
x: number;
|
|
274
|
+
label: string;
|
|
275
|
+
}[];
|
|
276
|
+
/** Bars and milestones. `x` and `w` are px inside the axis; `row` is the stack row inside the track. */
|
|
277
|
+
items: TimelineItem[];
|
|
278
|
+
/** The row count of each track. */
|
|
279
|
+
rows: number[];
|
|
280
|
+
/** The x of the today marker. It is null when the figure has no valid `today`. */
|
|
281
|
+
today: number | null;
|
|
282
|
+
/** The x and the d MMM date of the last day: the playhead rests there in the step hold. The x is null when the figure has no dated item. */
|
|
283
|
+
last: number | null;
|
|
284
|
+
lastDate: string;
|
|
285
|
+
};
|
|
286
|
+
/** Where each dated item, tick and the today line sit on the axis, `axisWidth` px wide. Both renderers call it. */
|
|
287
|
+
export declare function timelineLayout(fig: FlowProps, axisWidth: number): TimelineLayout;
|
|
288
|
+
/** One step, "timeline", with one beat for each dated item in date order. The beat lights the item and says "label, d MMM to d MMM" (a milestone: "label, d MMM"), then " · sub". */
|
|
289
|
+
export declare function timelineBeats(fig: FlowProps): FigStep[];
|
|
186
290
|
/** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
|
|
187
291
|
export declare const counts: (p: FlowProps) => {
|
|
188
292
|
boxes: number;
|
package/dist/model.js
CHANGED
|
@@ -25,8 +25,13 @@ export const TONES = {
|
|
|
25
25
|
purple: '#8b5cf6',
|
|
26
26
|
green: '#10b981',
|
|
27
27
|
orange: '#f59e0b',
|
|
28
|
+
red: '#ef4444',
|
|
28
29
|
gray: '#8b949e',
|
|
29
30
|
};
|
|
31
|
+
/** A tone as a fill under white text: darker, so the text keeps its contrast. */
|
|
32
|
+
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
|
+
export const toneTint = (c, bg, pct = 8) => `color-mix(in srgb, ${c} ${pct}%, ${bg})`;
|
|
30
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. */
|
|
31
36
|
export const labelPillW = (label) => textWidth(label, 11, true) + 14;
|
|
32
37
|
/** The width of the `async` tag in px. */
|
|
@@ -41,16 +46,189 @@ export const DARK = { accent: '#1f78c8', fg: '#e3e3e3', muted: '#9aa0a6', bg: '#
|
|
|
41
46
|
export const ON_ACCENT = '#ffffff';
|
|
42
47
|
/** Only text survives outside React: a React element has no string form. */
|
|
43
48
|
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
|
+
export const readMs = (say) => {
|
|
51
|
+
const w = str(say).split(/\s+/).filter(Boolean).length;
|
|
52
|
+
return w ? 400 + 240 * w : 0;
|
|
53
|
+
};
|
|
54
|
+
/** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
|
|
55
|
+
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
|
+
export const playheadItem = (items, beats, i) => {
|
|
58
|
+
for (let k = Math.min(i, beats.length - 1); k >= 0; k--) {
|
|
59
|
+
const id = (beats[k].focus ?? []).find((f) => items.some((x) => x.id === f));
|
|
60
|
+
if (id != null)
|
|
61
|
+
return items.find((x) => x.id === id);
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
};
|
|
65
|
+
/** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
|
|
66
|
+
export const outsideLabelRect = (bar, label) => ({
|
|
67
|
+
x: bar.x + bar.w + 6,
|
|
68
|
+
y: bar.y,
|
|
69
|
+
w: textWidth(label, 13),
|
|
70
|
+
h: bar.h,
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* The time span in which the playhead label shows its date. The label waits for the line to arrive: it stays hidden for
|
|
74
|
+
* 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.
|
|
75
|
+
*/
|
|
76
|
+
export const labelSpan = (t0, end, ramp) => [Math.min(t0 + ramp, end), end];
|
|
77
|
+
/** True if a playhead date label at x would overlap the "today" label, so it moves to the top row. Widths are in px. */
|
|
78
|
+
export const dateLabelRaised = (x, today, w, todayW) => today != null && x + 3 < today - 3 && x + 3 + w > today - 3 - todayW;
|
|
79
|
+
/** The hold at the end of each step, before the next step starts. */
|
|
80
|
+
export const STEP_HOLD_MS = 2000;
|
|
44
81
|
export const nodes = (g) => g.children.flatMap((c) => (isGroup(c) ? nodes(c) : [c]));
|
|
82
|
+
/** Lane layout constants, shared by both renderers: the gap between time columns, the padding inside a lane, and the gap between lanes. */
|
|
83
|
+
export const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
|
|
84
|
+
/** True if the layout can draw as lanes: a `column` group whose every child is a labeled group with boxes only. */
|
|
85
|
+
export const isLanesLayout = (g) => g.direction === 'column' && g.children.every((c) => isGroup(c) && c.label != null && c.children.every((k) => !isGroup(k)));
|
|
86
|
+
/** True if `at` is a usable time column: an integer of 0 or more. */
|
|
87
|
+
export const validAt = (at) => typeof at === 'number' && Number.isInteger(at) && at >= 0;
|
|
88
|
+
/** The time column of each box in a lanes figure: its first appearance in the steps (from, then to; a back hop to, then from),
|
|
89
|
+
* then the boxes no step touches in layout order. `at` overrides. Two boxes may share a column. */
|
|
90
|
+
export function laneColumns(fig) {
|
|
91
|
+
const byId = new Map(fig.edges.map((e) => [edgeId(e), e]));
|
|
92
|
+
const cols = new Map();
|
|
93
|
+
const seen = (id) => {
|
|
94
|
+
if (!cols.has(id))
|
|
95
|
+
cols.set(id, cols.size);
|
|
96
|
+
};
|
|
97
|
+
for (const s of fig.steps ?? [])
|
|
98
|
+
for (const b of s.flow.map(toBeat))
|
|
99
|
+
for (const h of b.hops) {
|
|
100
|
+
const e = byId.get(h.edge);
|
|
101
|
+
if (!e)
|
|
102
|
+
continue;
|
|
103
|
+
for (const id of h.back ? [e.to, e.from] : [e.from, e.to])
|
|
104
|
+
seen(id);
|
|
105
|
+
}
|
|
106
|
+
for (const n of nodes(fig.layout))
|
|
107
|
+
seen(n.id);
|
|
108
|
+
for (const n of nodes(fig.layout))
|
|
109
|
+
if (validAt(n.at))
|
|
110
|
+
cols.set(n.id, n.at);
|
|
111
|
+
return cols;
|
|
112
|
+
}
|
|
113
|
+
/** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
|
|
114
|
+
* the axis strip height and the axis width of the SVG. */
|
|
115
|
+
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;
|
|
116
|
+
/** 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. */
|
|
117
|
+
export const dayOf = (iso) => {
|
|
118
|
+
const m = typeof iso === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso) : null;
|
|
119
|
+
if (!m)
|
|
120
|
+
return null;
|
|
121
|
+
const t = Date.UTC(+m[1], +m[2] - 1, +m[3]);
|
|
122
|
+
return new Date(t).toISOString().slice(0, 10) === iso ? t / 86400000 : null;
|
|
123
|
+
};
|
|
124
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
125
|
+
const dateOf = (day) => new Date(day * 86400000);
|
|
126
|
+
// Day 0 is a Thursday, so this is 0 for Monday and 6 for Sunday.
|
|
127
|
+
const weekday = (day) => (((day + 3) % 7) + 7) % 7;
|
|
128
|
+
/** The ISO 8601 week number of a day: the week of the year's first Thursday is week 1. */
|
|
129
|
+
const isoWeek = (day) => {
|
|
130
|
+
const thu = day - weekday(day) + 3;
|
|
131
|
+
const jan1 = Date.UTC(dateOf(thu).getUTCFullYear(), 0, 1) / 86400000;
|
|
132
|
+
return Math.floor((thu - jan1) / 7) + 1;
|
|
133
|
+
};
|
|
134
|
+
/** The boxes with a real `from`, in layout order, with their track (the index of the top-level child) and their days.
|
|
135
|
+
* A `to` that is not a date, or is before `from`, makes the box a milestone; `checkSpec` reports it. */
|
|
136
|
+
const datedItems = (fig) => {
|
|
137
|
+
const out = [];
|
|
138
|
+
fig.layout.children.forEach((c, track) => nodes(isGroup(c) ? c : { children: [c] }).forEach((n) => {
|
|
139
|
+
const from = n.from == null ? null : dayOf(n.from);
|
|
140
|
+
if (from == null)
|
|
141
|
+
return;
|
|
142
|
+
const to = n.to == null ? null : dayOf(n.to);
|
|
143
|
+
out.push({ id: n.id, track, from, to: to != null && to >= from ? to : null, label: n });
|
|
144
|
+
}));
|
|
145
|
+
return out;
|
|
146
|
+
};
|
|
147
|
+
/** Where each dated item, tick and the today line sit on the axis, `axisWidth` px wide. Both renderers call it. */
|
|
148
|
+
export function timelineLayout(fig, axisWidth) {
|
|
149
|
+
const items = datedItems(fig);
|
|
150
|
+
const today = fig.today == null ? null : dayOf(fig.today);
|
|
151
|
+
const days = [...items.flatMap((i) => [i.from, i.to ?? i.from]), ...(today == null ? [] : [today])];
|
|
152
|
+
const first = days.length ? Math.min(...days) : 0;
|
|
153
|
+
const last = days.length ? Math.max(...days) : 0;
|
|
154
|
+
const start = first - weekday(first);
|
|
155
|
+
let end = last + (6 - weekday(last));
|
|
156
|
+
const px = (day) => ((day - start) / (end - start + 1)) * axisWidth;
|
|
157
|
+
// A label right of the last items may pass the axis end. Add whole weeks and lay out again; after 4 passes the label stays and `check` reports it.
|
|
158
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
159
|
+
const over = Math.max(0, ...items.map((i) => {
|
|
160
|
+
const x = i.to != null ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
|
|
161
|
+
const w = i.to != null ? Math.max(TL_MIN_BAR, px(i.to + 1) - px(i.from)) : TL_DIAMOND;
|
|
162
|
+
const need = textWidth(str(i.label.label), 13);
|
|
163
|
+
return i.to != null && need + 16 <= w ? 0 : x + w + 6 + need - axisWidth;
|
|
164
|
+
}));
|
|
165
|
+
if (over <= 0)
|
|
166
|
+
break;
|
|
167
|
+
end += Math.ceil(over / (axisWidth / (end - start + 1)) / 7) * 7;
|
|
168
|
+
}
|
|
169
|
+
const ticks = [];
|
|
170
|
+
if ((end - start + 1) / 7 <= 16)
|
|
171
|
+
for (let d = start; d <= end; d += 7)
|
|
172
|
+
ticks.push({ x: px(d), label: `W${isoWeek(d)}` });
|
|
173
|
+
else
|
|
174
|
+
for (let d = start; d <= end; d++)
|
|
175
|
+
if (dateOf(d).getUTCDate() === 1)
|
|
176
|
+
ticks.push({ x: px(d), label: MONTHS[dateOf(d).getUTCMonth()] });
|
|
177
|
+
const rows = fig.layout.children.map(() => 1);
|
|
178
|
+
const placed = [];
|
|
179
|
+
const busy = fig.layout.children.map(() => []); // per track: the px end of the latest item in each row, with its label
|
|
180
|
+
const GAP = 8; // the least px between two items in a row
|
|
181
|
+
for (const i of [...items].sort((a, b) => a.from - b.from)) {
|
|
182
|
+
const bar = i.to != null;
|
|
183
|
+
const x = bar ? px(i.from) : px(i.from) - TL_DIAMOND / 2;
|
|
184
|
+
const w = bar ? Math.max(TL_MIN_BAR, px(i.to + 1) - px(i.from)) : TL_DIAMOND;
|
|
185
|
+
const need = textWidth(str(i.label.label), 13);
|
|
186
|
+
const labelInside = bar && need + 16 <= w;
|
|
187
|
+
let row = busy[i.track].findIndex((until) => until + GAP <= x);
|
|
188
|
+
if (row < 0)
|
|
189
|
+
row = busy[i.track].length;
|
|
190
|
+
busy[i.track][row] = labelInside ? x + w : x + w + 6 + need;
|
|
191
|
+
rows[i.track] = Math.max(rows[i.track], row + 1);
|
|
192
|
+
placed.push({ id: i.id, track: i.track, row, x, w, milestone: !bar, labelInside, date: fmt(i.from) });
|
|
193
|
+
}
|
|
194
|
+
// Keep the layout order in the result.
|
|
195
|
+
const order = new Map(items.map((i, k) => [i.id, k]));
|
|
196
|
+
placed.sort((a, b) => order.get(a.id) - order.get(b.id));
|
|
197
|
+
return {
|
|
198
|
+
start,
|
|
199
|
+
end,
|
|
200
|
+
ticks,
|
|
201
|
+
items: placed,
|
|
202
|
+
rows,
|
|
203
|
+
today: items.length && today != null ? px(today) : null,
|
|
204
|
+
last: items.length ? px(last) : null,
|
|
205
|
+
lastDate: fmt(last),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const fmt = (d) => `${dateOf(d).getUTCDate()} ${MONTHS[dateOf(d).getUTCMonth()]}`;
|
|
209
|
+
/** One step, "timeline", with one beat for each dated item in date order. The beat lights the item and says "label, d MMM to d MMM" (a milestone: "label, d MMM"), then " · sub". */
|
|
210
|
+
export function timelineBeats(fig) {
|
|
211
|
+
const items = datedItems(fig).sort((a, b) => a.from - b.from);
|
|
212
|
+
const flow = items.map((i, k) => ({
|
|
213
|
+
focus: [i.id],
|
|
214
|
+
light: items.slice(0, k + 1).map((p) => p.id),
|
|
215
|
+
say: `${str(i.label.label)}, ${fmt(i.from)}${i.to != null ? ` to ${fmt(i.to)}` : ''}${i.label.sub != null ? ` · ${str(i.label.sub)}` : ''}`,
|
|
216
|
+
}));
|
|
217
|
+
return [{ label: 'timeline', flow }];
|
|
218
|
+
}
|
|
45
219
|
const labeledGroups = (g) => (g.label ? 1 : 0) + g.children.reduce((n, c) => n + (isGroup(c) ? labeledGroups(c) : 0), 0);
|
|
46
220
|
/** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
|
|
47
|
-
export const counts = (p) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
221
|
+
export const counts = (p) => {
|
|
222
|
+
// A timeline with no steps plays one synthetic step.
|
|
223
|
+
const st = p.timeline && isLanesLayout(p.layout) && !p.steps?.length ? timelineBeats(p) : (p.steps ?? []);
|
|
224
|
+
return {
|
|
225
|
+
boxes: nodes(p.layout).length,
|
|
226
|
+
groups: labeledGroups(p.layout),
|
|
227
|
+
edges: p.edges.length,
|
|
228
|
+
steps: st.length,
|
|
229
|
+
messages: st.reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
|
|
230
|
+
};
|
|
231
|
+
};
|
|
54
232
|
/** Every id inside a child: its own id, and the ids of all boxes and groups below it. */
|
|
55
233
|
const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
|
|
56
234
|
/**
|
package/dist/open.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type Opener = {
|
|
2
|
+
cmd: string;
|
|
3
|
+
args: string[];
|
|
4
|
+
verbatim: boolean;
|
|
5
|
+
};
|
|
6
|
+
/** The command that opens a file in the default program of the system. Pure. */
|
|
7
|
+
export declare function openerFor(platform: NodeJS.Platform, file: string): Opener;
|
|
8
|
+
/** The name of the temp page: the SVG base name, each character other than a letter, a digit, - or _ as -. */
|
|
9
|
+
export declare function pageName(svgPath: string): string;
|
|
10
|
+
/** The line that `open`, a render with `--open` and `draw --open` print. */
|
|
11
|
+
export declare const openedLine: (page: string) => string;
|
|
12
|
+
/** Write the page to a new temp folder (and to `html`, if given), then start the opener. Returns the written paths. */
|
|
13
|
+
export declare function openSvg(svgPath: string, html?: string): Promise<string[]>;
|
|
14
|
+
/** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
|
|
15
|
+
export declare function runOpen(argv: string[]): Promise<number>;
|
package/dist/open.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { pageHtml } from './page.js';
|
|
7
|
+
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
|
+
export function openerFor(platform, file) {
|
|
10
|
+
if (platform === 'darwin')
|
|
11
|
+
return { cmd: 'open', args: [file], verbatim: false };
|
|
12
|
+
// `start` reads its first quoted argument as a window title, so an empty title goes first.
|
|
13
|
+
if (platform === 'win32')
|
|
14
|
+
return { cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`], verbatim: true };
|
|
15
|
+
return { cmd: 'xdg-open', args: [file], verbatim: false };
|
|
16
|
+
}
|
|
17
|
+
/** The name of the temp page: the SVG base name, each character other than a letter, a digit, - or _ as -. */
|
|
18
|
+
export function pageName(svgPath) {
|
|
19
|
+
// The safe name also keeps cmd metacharacters such as & out of the Windows command line.
|
|
20
|
+
return (basename(svgPath)
|
|
21
|
+
.replace(/\.svg$/, '')
|
|
22
|
+
.replace(/[^\w-]/g, '-') + '.html');
|
|
23
|
+
}
|
|
24
|
+
/** The line that `open`, a render with `--open` and `draw --open` print. */
|
|
25
|
+
export const openedLine = (page) => `${page} — opened in the default browser`;
|
|
26
|
+
/** Write the page to a new temp folder (and to `html`, if given), then start the opener. Returns the written paths. */
|
|
27
|
+
export async function openSvg(svgPath, html) {
|
|
28
|
+
const page = pageHtml(readFileSync(svgPath, 'utf8'), basename(svgPath));
|
|
29
|
+
// A fresh private folder (mode 0700): a fixed shared path lets another user plant a symlink there.
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), 'flowfig-open-'));
|
|
31
|
+
// The page stays after the exit: the browser reads it after the CLI stops.
|
|
32
|
+
const temp = join(dir, pageName(svgPath));
|
|
33
|
+
writeFileSync(temp, page);
|
|
34
|
+
if (html)
|
|
35
|
+
writeFileSync(html, page);
|
|
36
|
+
// FLOWFIG_OPENER replaces the system opener, so the tests start no browser.
|
|
37
|
+
const o = process.env.FLOWFIG_OPENER
|
|
38
|
+
? { cmd: process.env.FLOWFIG_OPENER, args: [temp], verbatim: false }
|
|
39
|
+
: openerFor(process.platform, temp);
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
const child = spawn(o.cmd, o.args, { detached: true, windowsHide: true, stdio: 'ignore', windowsVerbatimArguments: o.verbatim });
|
|
42
|
+
child.once('spawn', () => (child.unref(), resolve()));
|
|
43
|
+
child.once('error', (e) => reject(new Error(`${e.message}. Open ${temp} in a browser.`)));
|
|
44
|
+
});
|
|
45
|
+
return html ? [temp, html] : [temp];
|
|
46
|
+
}
|
|
47
|
+
/** `flowfig open`: parse argv, call openSvg, print. Returns the exit code. */
|
|
48
|
+
export async function runOpen(argv) {
|
|
49
|
+
const fail = (message, code) => (console.error(message), code);
|
|
50
|
+
const args = [...argv];
|
|
51
|
+
let html;
|
|
52
|
+
const at = args.indexOf('--html');
|
|
53
|
+
if (at !== -1) {
|
|
54
|
+
html = args.splice(at, 2)[1];
|
|
55
|
+
if (!html || html.startsWith('-'))
|
|
56
|
+
return fail('--html needs a path', 2);
|
|
57
|
+
}
|
|
58
|
+
const bad = args.find((a) => a.startsWith('-'));
|
|
59
|
+
if (bad)
|
|
60
|
+
return fail(`unknown flag ${bad}`, 2);
|
|
61
|
+
const [path] = args;
|
|
62
|
+
if (!path)
|
|
63
|
+
return fail(USAGE, 2);
|
|
64
|
+
if (!path.endsWith('.svg'))
|
|
65
|
+
return fail(`${path}: expected a .svg path`, 2);
|
|
66
|
+
try {
|
|
67
|
+
readFileSync(path);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return fail(`${path}: ${e.message}`, 2);
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const [page, copy] = await openSvg(path, html);
|
|
74
|
+
console.log(openedLine(page));
|
|
75
|
+
if (copy)
|
|
76
|
+
console.log(copy);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
return fail(`open: ${e.message}`, 1);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/page.d.ts
ADDED
package/dist/page.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The page that holds a figure SVG: `flowfig open` shows it in the default browser, and `flowfig gif` captures it. Pure.
|
|
2
|
+
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
|
+
export function pageHtml(svg, title) {
|
|
5
|
+
// Element selectors only: the figure styles use class names, so the two sets of rules do not meet.
|
|
6
|
+
return `<!doctype html>
|
|
7
|
+
<meta charset="utf-8">
|
|
8
|
+
<title>${esc(title)}</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root { color-scheme: light dark }
|
|
11
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: Canvas }
|
|
12
|
+
svg { display: block; max-width: 100%; height: auto }
|
|
13
|
+
</style>
|
|
14
|
+
${svg}
|
|
15
|
+
`;
|
|
16
|
+
}
|