flowfig 0.1.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/LICENSE +22 -0
- package/README.md +428 -0
- package/dist/check.d.ts +14 -0
- package/dist/check.js +155 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +133 -0
- package/dist/geometry.d.ts +24 -0
- package/dist/geometry.js +80 -0
- package/dist/guide.d.ts +6 -0
- package/dist/guide.js +280 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +638 -0
- package/dist/init.d.ts +25 -0
- package/dist/init.js +171 -0
- package/dist/model.d.ts +191 -0
- package/dist/model.js +71 -0
- package/dist/rail.d.ts +82 -0
- package/dist/rail.js +162 -0
- package/dist/scene.d.ts +35 -0
- package/dist/scene.js +1 -0
- package/dist/svg.d.ts +28 -0
- package/dist/svg.js +563 -0
- package/dist/text.d.ts +2 -0
- package/dist/text.js +13 -0
- package/package.json +80 -0
package/dist/rail.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { edgeId, isGroup, labelPillW, str, toBeat } from './model.js';
|
|
2
|
+
import { textWidth } from './text.js';
|
|
3
|
+
// band: the band-label line; cols: the column-label row; bandInset: a band's inset from its outer column edges, so two adjacent
|
|
4
|
+
// bands keep 16 px between them; clear: the space between a phase label or counter and a line; inset: the space a parallel band
|
|
5
|
+
// leaves around its arrows and pills.
|
|
6
|
+
export const RAIL = {
|
|
7
|
+
gap: 24,
|
|
8
|
+
band: 20,
|
|
9
|
+
cols: 28,
|
|
10
|
+
phase: 30,
|
|
11
|
+
row: 34,
|
|
12
|
+
minCol: 120,
|
|
13
|
+
pad: 24,
|
|
14
|
+
bandInset: 8,
|
|
15
|
+
clear: 12,
|
|
16
|
+
inset: 6,
|
|
17
|
+
maxOpen: 10,
|
|
18
|
+
};
|
|
19
|
+
/** The folded phase text. Both renderers and the layout use it, so the measured width matches the drawn width. */
|
|
20
|
+
export const foldedLabel = (row) => `▸ ${row.label} · ${row.messages} messages`;
|
|
21
|
+
/** Rail columns follow this order, so a reader meets the boxes in the same order on the rail as on the map. */
|
|
22
|
+
const order = (item) => isGroup(item)
|
|
23
|
+
? [...(item.id ? [{ id: item.id, label: str(item.label) }] : []), ...item.children.flatMap(order)]
|
|
24
|
+
: [{ id: item.id, label: str(item.label) }];
|
|
25
|
+
export function railState(rail, step) {
|
|
26
|
+
return rail.states[Math.min(step, rail.states.length - 1)] ?? rail.states[0];
|
|
27
|
+
}
|
|
28
|
+
export function layoutRail(fig, mapWidth) {
|
|
29
|
+
const steps = fig.steps ?? [];
|
|
30
|
+
const edges = new Map(fig.edges.map((e) => [edgeId(e), e]));
|
|
31
|
+
const layoutIds = new Set(order(fig.layout).map((o) => o.id));
|
|
32
|
+
const perStep = steps.map((s, si) => s.flow.map(toBeat).map((b, bi) => b.hops
|
|
33
|
+
.filter((h) => {
|
|
34
|
+
if (!edges.has(h.edge))
|
|
35
|
+
return false;
|
|
36
|
+
const e = edges.get(h.edge);
|
|
37
|
+
return layoutIds.has(e.from) && layoutIds.has(e.to);
|
|
38
|
+
})
|
|
39
|
+
.map((h) => ({
|
|
40
|
+
step: si,
|
|
41
|
+
beat: bi,
|
|
42
|
+
edge: h.edge,
|
|
43
|
+
back: h.back,
|
|
44
|
+
text: str(h.data) || str(edges.get(h.edge).label),
|
|
45
|
+
async: h.async === true,
|
|
46
|
+
}))));
|
|
47
|
+
const msgs = perStep.flat(2);
|
|
48
|
+
if (!msgs.length)
|
|
49
|
+
return null;
|
|
50
|
+
const used = new Set(msgs.flatMap((m) => [edges.get(m.edge).from, edges.get(m.edge).to]));
|
|
51
|
+
const cols = order(fig.layout).filter((c) => used.has(c.id));
|
|
52
|
+
const index = new Map(cols.map((c, i) => [c.id, i]));
|
|
53
|
+
// Every column gets the same width: at least RAIL.minCol, wide enough for each label and pill, and together as wide as the map.
|
|
54
|
+
// A column label also needs room for the band inset on each side.
|
|
55
|
+
let need = RAIL.minCol;
|
|
56
|
+
for (const c of cols)
|
|
57
|
+
need = Math.max(need, textWidth(c.label, 12) + 16 + RAIL.bandInset * 2);
|
|
58
|
+
for (const m of msgs)
|
|
59
|
+
if (m.text)
|
|
60
|
+
need = Math.max(need, labelPillW(m.text) + 16);
|
|
61
|
+
// The phase labels start at RAIL.pad and the counters end at width - RAIL.pad. No band border comes within RAIL.clear of them.
|
|
62
|
+
const folds = msgs.length > RAIL.maxOpen;
|
|
63
|
+
const labelW = (text) => textWidth(text, 13, true);
|
|
64
|
+
const counterW = (() => {
|
|
65
|
+
const text = `${msgs.length} of ${msgs.length}`;
|
|
66
|
+
return textWidth(text, 11) + 0.04 * 11 * text.length; // the frame style adds .04em letter spacing
|
|
67
|
+
})();
|
|
68
|
+
const widest = Math.max(...perStep.map((b, si) => {
|
|
69
|
+
const label = str(steps[si].label);
|
|
70
|
+
return labelW(folds ? foldedLabel({ label, messages: b.flat().length }) : label);
|
|
71
|
+
}));
|
|
72
|
+
// Bands: each labeled group around the columns it holds, RAIL.bandInset inside their outer edges; a nested group sits 4 px inside
|
|
73
|
+
// its parent and one label line (RAIL.band) below it. The height comes later.
|
|
74
|
+
const place = (left, right, colW) => {
|
|
75
|
+
const width = left + colW * cols.length + right;
|
|
76
|
+
const columns = cols.map((c, i) => ({ id: c.id, label: c.label, x: left + colW * (i + 0.5), w: colW }));
|
|
77
|
+
const bands = [];
|
|
78
|
+
const walk = (g, depth) => {
|
|
79
|
+
const ids = new Set(order(g).map((o) => o.id));
|
|
80
|
+
const inside = columns.filter((c) => ids.has(c.id));
|
|
81
|
+
if (g.label != null && inside.length) {
|
|
82
|
+
const l = Math.min(...inside.map((c) => c.x - c.w / 2)) + RAIL.bandInset + depth * 4;
|
|
83
|
+
const r = Math.max(...inside.map((c) => c.x + c.w / 2)) - RAIL.bandInset - depth * 4;
|
|
84
|
+
bands.push({ id: g.id ?? str(g.label), label: str(g.label), rect: { x: l, y: depth * RAIL.band, w: r - l, h: 0 } });
|
|
85
|
+
}
|
|
86
|
+
for (const c of g.children)
|
|
87
|
+
if (isGroup(c))
|
|
88
|
+
walk(c, depth + (g.label != null ? 1 : 0));
|
|
89
|
+
};
|
|
90
|
+
walk(fig.layout, 0);
|
|
91
|
+
const borders = bands.flatMap((b) => [b.rect.x, b.rect.x + b.rect.w]);
|
|
92
|
+
// How far the columns must move in from each side, so every band border clears the label and the counter.
|
|
93
|
+
const dl = Math.max(0, RAIL.pad + widest + RAIL.clear - Math.min(Infinity, ...borders));
|
|
94
|
+
const dr = Math.max(0, Math.max(-Infinity, ...borders) - (width - RAIL.pad - counterW - RAIL.clear));
|
|
95
|
+
return { width, columns, bands, dl, dr };
|
|
96
|
+
};
|
|
97
|
+
const spread = (left, right) => Math.max(need, (mapWidth - left - right) / cols.length);
|
|
98
|
+
let [left, right] = [RAIL.pad, RAIL.pad];
|
|
99
|
+
let colW = spread(left, right);
|
|
100
|
+
let placed = place(left, right, colW);
|
|
101
|
+
for (let k = 0; placed.dl > 0.01 || placed.dr > 0.01; k++) {
|
|
102
|
+
left += placed.dl;
|
|
103
|
+
right += placed.dr;
|
|
104
|
+
// Narrower columns keep the rail as wide as the map. After a few tries the columns keep their width, and the rail grows.
|
|
105
|
+
if (k < 8)
|
|
106
|
+
colW = spread(left, right);
|
|
107
|
+
placed = place(left, right, colW);
|
|
108
|
+
}
|
|
109
|
+
const { width, columns, bands } = placed;
|
|
110
|
+
const head = (bands.length ? Math.max(...bands.map((b) => b.rect.y)) + RAIL.band : 0) + RAIL.cols;
|
|
111
|
+
// Rows: a phase header per step, then one row per message.
|
|
112
|
+
const rows = [];
|
|
113
|
+
const groups = [];
|
|
114
|
+
let n = 0;
|
|
115
|
+
perStep.forEach((stepBeats, si) => {
|
|
116
|
+
const label = str(steps[si].label), messages = stepBeats.flat().length;
|
|
117
|
+
const after = (text) => RAIL.pad + labelW(text) + RAIL.clear;
|
|
118
|
+
const line = { open: after(label), folded: after(foldedLabel({ label, messages })), end: width - RAIL.pad - counterW - RAIL.clear };
|
|
119
|
+
rows.push({ kind: 'phase', step: si, label, messages, line });
|
|
120
|
+
for (const beatMsgs of stepBeats) {
|
|
121
|
+
const group = beatMsgs.length > 1 ? groups.push({ step: si, rows: [] }) - 1 : undefined;
|
|
122
|
+
for (const m of beatMsgs) {
|
|
123
|
+
const e = edges.get(m.edge);
|
|
124
|
+
const [a, b] = m.back ? [e.to, e.from] : [e.from, e.to];
|
|
125
|
+
const from = index.get(a), to = index.get(b);
|
|
126
|
+
const mid = (columns[from].x + columns[to].x) / 2;
|
|
127
|
+
const pill = m.text ? { x: mid - labelPillW(m.text) / 2, w: labelPillW(m.text) } : undefined;
|
|
128
|
+
if (group != null)
|
|
129
|
+
groups[group].rows.push(rows.length);
|
|
130
|
+
rows.push({ kind: 'message', step: si, beat: m.beat, n: ++n, edge: m.edge, from, to, text: m.text, async: m.async, pill, group });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
// Above maxOpen messages, only the playing step shows its rows; the others shrink to their phase header.
|
|
135
|
+
const states = steps.map((_, open) => {
|
|
136
|
+
let y = head;
|
|
137
|
+
return rows.map((row) => {
|
|
138
|
+
const shown = !folds || row.kind === 'phase' || row.step === open;
|
|
139
|
+
const s = { y, shown };
|
|
140
|
+
if (shown)
|
|
141
|
+
y += row.kind === 'phase' ? RAIL.phase : RAIL.row;
|
|
142
|
+
return s;
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
const bottom = (s) => s.reduce((m, st, i) => (st.shown ? Math.max(m, st.y + (rows[i].kind === 'phase' ? RAIL.phase : RAIL.row)) : m), 0);
|
|
146
|
+
const height = Math.max(...states.map(bottom)) + 8;
|
|
147
|
+
for (const b of bands)
|
|
148
|
+
b.rect.h = height - b.rect.y - (b.rect.y / RAIL.band) * 4; // the bottom edge keeps the 4 px nesting inset
|
|
149
|
+
return { width, head, height, columns, bands, rows, groups, total: n, folds, states };
|
|
150
|
+
}
|
|
151
|
+
/** A parallel band around its rows, relative to the y of its first row: RAIL.inset around every arrow and pill it holds. */
|
|
152
|
+
export function groupBox(rail, g) {
|
|
153
|
+
const xs = g.rows.flatMap((k) => {
|
|
154
|
+
const r = rail.rows[k];
|
|
155
|
+
if (r.kind !== 'message')
|
|
156
|
+
return [];
|
|
157
|
+
return [rail.columns[r.from].x, rail.columns[r.to].x, ...(r.pill ? [r.pill.x, r.pill.x + r.pill.w] : [])];
|
|
158
|
+
});
|
|
159
|
+
const x = Math.min(...xs) - RAIL.inset;
|
|
160
|
+
const top = (RAIL.row - 18) / 2 - RAIL.inset; // 18: the pill height
|
|
161
|
+
return { x, y: top, w: Math.max(...xs) + RAIL.inset - x, h: g.rows.length * RAIL.row - top * 2 };
|
|
162
|
+
}
|
package/dist/scene.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Pt, Rect } from './geometry.ts';
|
|
2
|
+
/** One line of text in a box. `need` is the measured width; without it, the check estimates the width. */
|
|
3
|
+
export type TextRun = {
|
|
4
|
+
text: string;
|
|
5
|
+
fontSize: number;
|
|
6
|
+
room: number;
|
|
7
|
+
need?: number;
|
|
8
|
+
mono?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type SceneBox = {
|
|
11
|
+
id: string;
|
|
12
|
+
rect: Rect;
|
|
13
|
+
texts: TextRun[];
|
|
14
|
+
};
|
|
15
|
+
export type SceneEdge = {
|
|
16
|
+
id: string;
|
|
17
|
+
from: string;
|
|
18
|
+
to: string;
|
|
19
|
+
curve: [Pt, Pt, Pt, Pt];
|
|
20
|
+
label?: Rect;
|
|
21
|
+
};
|
|
22
|
+
/** `minFont` is the smallest reading text in px, before any scale. Group frames are not boxes. */
|
|
23
|
+
export type Scene = {
|
|
24
|
+
width: number;
|
|
25
|
+
boxes: SceneBox[];
|
|
26
|
+
edges: SceneEdge[];
|
|
27
|
+
minFont: number;
|
|
28
|
+
};
|
|
29
|
+
/** 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. */
|
|
30
|
+
export type Finding = {
|
|
31
|
+
rule: string;
|
|
32
|
+
severity: 'error' | 'warning';
|
|
33
|
+
ids: string[];
|
|
34
|
+
message: string;
|
|
35
|
+
};
|
package/dist/scene.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/svg.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type CheckOptions } from './check.ts';
|
|
2
|
+
import type { Finding, Scene } from './scene.ts';
|
|
3
|
+
export type { CheckOptions } from './check.ts';
|
|
4
|
+
export type { Finding, Scene } from './scene.ts';
|
|
5
|
+
export type * from './model.ts';
|
|
6
|
+
import { type FigTheme, type FlowProps } from './model.ts';
|
|
7
|
+
export { LIGHT, DARK } from './model.ts';
|
|
8
|
+
/** Options for `toSvg`, `render` and `check`. */
|
|
9
|
+
export type SvgOptions = {
|
|
10
|
+
/** Milliseconds a packet takes to cross one edge, in place of `FlowProps.speed`. Default: `FlowProps.speed`, else 900. */
|
|
11
|
+
speed?: number;
|
|
12
|
+
/** Space around the figure in px. Default: 24. */
|
|
13
|
+
padding?: number;
|
|
14
|
+
/** Colors, in place of `FlowProps.theme`. Default: `FlowProps.theme`. */
|
|
15
|
+
theme?: FigTheme;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The SVG string, and the scene it drew. The scene lets `flowfig check` test the same layout the SVG uses.
|
|
19
|
+
* The scene is for tools, and its shape may change. Use `toSvg` if you only need the SVG.
|
|
20
|
+
*/
|
|
21
|
+
export declare function render(fig: FlowProps, opts?: SvgOptions): {
|
|
22
|
+
svg: string;
|
|
23
|
+
scene: Scene;
|
|
24
|
+
};
|
|
25
|
+
/** One self-contained animated SVG string for the figure. Needs no React and no browser. */
|
|
26
|
+
export declare function toSvg(fig: FlowProps, opts?: SvgOptions): string;
|
|
27
|
+
/** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. A `Finding` has a `rule` name, a `severity`, the ids it names and a message. */
|
|
28
|
+
export declare function check(fig: FlowProps, opts?: SvgOptions & CheckOptions): Finding[];
|