visualproof 0.1.0 → 0.3.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 +40 -26
- package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
- package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
- package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
- package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/metrics.json +1 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
- package/dist/server/agent.js +125 -92
- package/dist/server/agentdocs.js +1 -1
- package/dist/server/artcmd.js +335 -0
- package/dist/server/history.js +39 -0
- package/dist/server/historyapi.js +2 -2
- package/dist/server/hover.js +3 -3
- package/dist/server/layoutthread.js +1 -1
- package/dist/server/live.js +6 -0
- package/dist/server/main.js +151 -11
- package/dist/server/mcptools.js +6 -4
- package/dist/server/pastdoc.js +1 -1
- package/dist/server/pipeline.js +18 -9
- package/dist/server/readonlygate.js +2 -2
- package/dist/server/review.js +4 -1
- package/dist/server/reviewapi.js +35 -1
- package/dist/server/snapshot.js +13 -3
- package/dist/server/usage.js +2 -2
- package/dist/server/views.js +25 -1
- package/dist/shared/artfonts.js +81 -0
- package/dist/shared/artrecipe.js +316 -0
- package/dist/shared/collab.js +99 -1
- package/dist/shared/declfind.js +95 -0
- package/dist/shared/doc/order.js +17 -0
- package/dist/shared/doc/outline.js +45 -0
- package/dist/shared/keys.js +1 -1
- package/dist/shared/lostfound.js +1 -0
- package/dist/shared/moves.js +2 -1
- package/dist/shared/pastgraph.js +9 -1
- package/dist/shared/query.js +21 -13
- package/dist/shared/review.js +62 -5
- package/dist/shared/sharesnap.js +2 -1
- package/dist/shared/titles.js +2 -0
- package/dist/shared/viewgraph.js +18 -0
- package/dist/web/bundle/app.js +7709 -3222
- package/dist/web/bundle/edges.js +4 -6
- package/dist/web/index.html +24 -6
- package/dist/web/src/agentmark.js +14 -0
- package/dist/web/src/artwork.js +1000 -0
- package/dist/web/src/bundles.js +151 -0
- package/dist/web/src/canvas.js +3694 -0
- package/dist/web/src/drag.js +61 -0
- package/dist/web/src/edgequeue.js +204 -0
- package/dist/web/src/gl/gl.js +123 -0
- package/dist/web/src/gl/renderer.js +451 -0
- package/dist/web/src/halo.js +95 -0
- package/dist/web/src/highlight.js +108 -0
- package/dist/web/src/overview.js +393 -0
- package/dist/web/src/reviewqueue.js +250 -0
- package/dist/web/src/scene.js +17 -0
- package/dist/web/src/selection.js +272 -0
- package/dist/web/src/status.js +80 -0
- package/dist/web/src/visitors.js +318 -0
- package/dist/web/src/visits.js +174 -0
- package/dist/web/style.css +255 -12
- package/docs/agent-api/README.md +1 -1
- package/docs/agent-api/export.md +41 -2
- package/docs/agent-api/following.md +2 -2
- package/docs/agent-api/history.md +2 -1
- package/docs/agent-api/mcp.md +1 -1
- package/docs/agent-api/reading.md +2 -1
- package/docs/agent-api/views.md +3 -2
- package/package.json +3 -1
- package/test/toolchains.json +2 -2
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
// Artwork (ExportSpec decision 19, the owner's decisions of 2026-10-13 and 2026-10-14): the board as
|
|
2
|
+
// a picture, PNG or SVG, at a size of the reader's choosing. There is no picture drawn apart from the
|
|
3
|
+
// board: while the artwork mode is on, the board itself is drawn with the artwork's settings — the
|
|
4
|
+
// colouring, the theme, the background, the names, the edges, the notes, the targets, the selection —
|
|
5
|
+
// and a frame fixed in the middle of the screen (web/src/artframe.ts) says what of it the picture
|
|
6
|
+
// holds. Everything done on the board works under the frame.
|
|
7
|
+
//
|
|
8
|
+
// The files are made from the board's own drawing. PNG: the board's renderer (web/src/gl/renderer.ts)
|
|
9
|
+
// draws the frame again at the size chosen, a tile at a time, and the board's names and marks are
|
|
10
|
+
// written over it as the screen has them (GraphCanvas.paintArtBand): the frame's view, more pixels.
|
|
11
|
+
// SVG: what the renderer draws, read from its layers as shapes (GraphCanvas.artShapes), and what the
|
|
12
|
+
// board writes over it — its names, stars, rings — recorded as it is written (SvgContext), within the
|
|
13
|
+
// frame. Nothing is asked of the server, so a read-only page and a snapshot can make one too.
|
|
14
|
+
import { ART_MAX_SIDE, ART_NAMES_BYTES, ART_RULED_BYTES, ART_STYLES, ART_TARGETS_BYTES, cleanFootSize, cleanNames, cleanTitle, cleanTitleSize, FOOT_SIZE, isArtNameId, TITLE_SIZE } from "../../shared/collab.js";
|
|
15
|
+
import { ART_PRESETS } from "../../shared/artrecipe.js";
|
|
16
|
+
import { ART_DISPLAY, ART_SANS, ART_SERIF, spacedWidth, writeNames } from "./halo.js";
|
|
17
|
+
/** The sizes offered, each in either orientation (`oriented`): given landscape; a recipe names
|
|
18
|
+
* them by `key` (shared/artrecipe.ts). */
|
|
19
|
+
export const PRESETS = ART_PRESETS;
|
|
20
|
+
/** The preset a size is, in either orientation; -1 for none. */
|
|
21
|
+
export function presetOf(w, h) {
|
|
22
|
+
return PRESETS.findIndex((p) => (p.w === w && p.h === h) || (p.w === h && p.h === w));
|
|
23
|
+
}
|
|
24
|
+
/** A size turned to an orientation: its longer side across (landscape) or down (portrait). */
|
|
25
|
+
export function oriented(w, h, portrait) {
|
|
26
|
+
const [long, short] = w >= h ? [w, h] : [h, w];
|
|
27
|
+
return portrait ? { w: short, h: long } : { w: long, h: short };
|
|
28
|
+
}
|
|
29
|
+
export const MAX_SIDE = ART_MAX_SIDE;
|
|
30
|
+
const DEFAULTS = {
|
|
31
|
+
w: 3840,
|
|
32
|
+
h: 2160,
|
|
33
|
+
scheme: "kind",
|
|
34
|
+
theme: "light",
|
|
35
|
+
background: "theme",
|
|
36
|
+
labels: "board",
|
|
37
|
+
namesFrom: "board",
|
|
38
|
+
hiddenNames: [],
|
|
39
|
+
shownNames: [],
|
|
40
|
+
ruledNames: [],
|
|
41
|
+
edges: "all",
|
|
42
|
+
notes: true,
|
|
43
|
+
targets: "marked",
|
|
44
|
+
shownTargets: [],
|
|
45
|
+
keys: true,
|
|
46
|
+
selection: true,
|
|
47
|
+
source: false,
|
|
48
|
+
style: "board",
|
|
49
|
+
title: null,
|
|
50
|
+
subtitle: null,
|
|
51
|
+
titleSize: TITLE_SIZE.first,
|
|
52
|
+
footSize: FOOT_SIZE.first,
|
|
53
|
+
border: false,
|
|
54
|
+
};
|
|
55
|
+
const one = (v, of, d) => (of.includes(v) ? v : d);
|
|
56
|
+
const side = (v, d) => (typeof v === "number" && Number.isFinite(v) ? Math.max(16, Math.min(MAX_SIDE, Math.round(v))) : d);
|
|
57
|
+
/** Settings as a browser kept them, each checked, over the defaults. */
|
|
58
|
+
export function cleanSettings(s, now) {
|
|
59
|
+
const o = (s ?? {});
|
|
60
|
+
const targets = one(o.targets, ["marked", "plain", "highlight", "chosen"], DEFAULTS.targets);
|
|
61
|
+
return {
|
|
62
|
+
w: side(o.w, DEFAULTS.w),
|
|
63
|
+
h: side(o.h, DEFAULTS.h),
|
|
64
|
+
scheme: one(o.scheme, ["kind", "status"], now.scheme),
|
|
65
|
+
theme: one(o.theme, ["light", "dark"], now.theme),
|
|
66
|
+
background: one(o.background, ["theme", "transparent"], DEFAULTS.background),
|
|
67
|
+
labels: one(o.labels, ["board", "clusters", "none", "chosen"], DEFAULTS.labels),
|
|
68
|
+
namesFrom: o.namesFrom === "none" ? "none" : "board",
|
|
69
|
+
hiddenNames: cleanNames(o.hiddenNames, ART_NAMES_BYTES, isArtNameId),
|
|
70
|
+
shownNames: cleanNames(o.shownNames, ART_NAMES_BYTES, isArtNameId),
|
|
71
|
+
ruledNames: cleanNames(o.ruledNames, ART_RULED_BYTES, isArtNameId),
|
|
72
|
+
edges: one(o.edges, ["all", "selection", "none"], DEFAULTS.edges),
|
|
73
|
+
notes: typeof o.notes === "boolean" ? o.notes : DEFAULTS.notes,
|
|
74
|
+
targets,
|
|
75
|
+
shownTargets: cleanNames(o.shownTargets, ART_TARGETS_BYTES),
|
|
76
|
+
// kept before the key marks had a switch of their own: as the targets drew them
|
|
77
|
+
keys: typeof o.keys === "boolean" ? o.keys : targets !== "plain",
|
|
78
|
+
selection: typeof o.selection === "boolean" ? o.selection : DEFAULTS.selection,
|
|
79
|
+
source: typeof o.source === "boolean" ? o.source : DEFAULTS.source,
|
|
80
|
+
style: one(o.style, ART_STYLES, DEFAULTS.style),
|
|
81
|
+
title: cleanTitle(o.title),
|
|
82
|
+
subtitle: cleanTitle(o.subtitle),
|
|
83
|
+
titleSize: cleanTitleSize(o.titleSize),
|
|
84
|
+
footSize: cleanFootSize(o.footSize),
|
|
85
|
+
border: typeof o.border === "boolean" ? o.border : DEFAULTS.border,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** the most rows the list of names shows at once: the rest are narrowed to by its filter */
|
|
89
|
+
export const NAME_ROWS = 2000;
|
|
90
|
+
/** The rows of the list of names whose name or full name holds `filter`, whatever the case, in the
|
|
91
|
+
* order given (the clusters' first), at most `cap` of them; and how many more hold it. */
|
|
92
|
+
export function nameRows(all, filter, cap = NAME_ROWS) {
|
|
93
|
+
const f = filter.trim().toLowerCase();
|
|
94
|
+
const rows = [];
|
|
95
|
+
let more = 0;
|
|
96
|
+
for (const r of all) {
|
|
97
|
+
if (f && !r.label.toLowerCase().includes(f) && !r.full.toLowerCase().includes(f))
|
|
98
|
+
continue;
|
|
99
|
+
if (rows.length < cap)
|
|
100
|
+
rows.push(r);
|
|
101
|
+
else
|
|
102
|
+
more++;
|
|
103
|
+
}
|
|
104
|
+
return { rows, more };
|
|
105
|
+
}
|
|
106
|
+
/** `list` with `add` after it, as many of them as fit in `budget` bytes of what presence carries
|
|
107
|
+
* (shared/collab.ts, cleanNames), in their order; and how many of them did not fit. */
|
|
108
|
+
export function withChosen(list, add, budget) {
|
|
109
|
+
const out = cleanNames([...list, ...add], budget);
|
|
110
|
+
const had = new Set(list), kept = new Set(out);
|
|
111
|
+
return { list: out, left: new Set(add.filter((x) => !had.has(x) && !kept.has(x))).size };
|
|
112
|
+
}
|
|
113
|
+
/** The picture's background, the theme's: read while the artwork mode is on, it is its theme's. */
|
|
114
|
+
export function themeBackground(v = browserVar) {
|
|
115
|
+
return v("--canvas");
|
|
116
|
+
}
|
|
117
|
+
/** A custom property of the page as the browser has it now (the theme on show). */
|
|
118
|
+
const browserVar = (name) => getComputedStyle(document.body).getPropertyValue(name).trim();
|
|
119
|
+
/** The page's custom properties as its style sheet sets them, in the light theme or the dark one,
|
|
120
|
+
* read without a browser (the artwork command, ExportSpec decision 21): `:root`, and
|
|
121
|
+
* `:root[data-theme="dark"]` over it; `var()` followed. */
|
|
122
|
+
export function cssTheme(css, dark) {
|
|
123
|
+
const text = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
124
|
+
const props = new Map();
|
|
125
|
+
const take = (selector) => {
|
|
126
|
+
for (const m of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
127
|
+
if (m[1].trim() !== selector)
|
|
128
|
+
continue;
|
|
129
|
+
for (const d of m[2].split(";")) {
|
|
130
|
+
const i = d.indexOf(":");
|
|
131
|
+
const k = d.slice(0, i).trim();
|
|
132
|
+
if (i > 0 && k.startsWith("--"))
|
|
133
|
+
props.set(k, d.slice(i + 1).trim());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
take(":root");
|
|
138
|
+
if (dark)
|
|
139
|
+
take(':root[data-theme="dark"]');
|
|
140
|
+
const get = (name, depth = 0) => {
|
|
141
|
+
const raw = props.get(name) ?? "";
|
|
142
|
+
if (depth > 8)
|
|
143
|
+
return raw;
|
|
144
|
+
return raw.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)/g, (_, k, dflt) => (props.has(k) ? get(k, depth + 1) : (dflt ?? "").trim()));
|
|
145
|
+
};
|
|
146
|
+
return get;
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------- the frame, as the page places it
|
|
149
|
+
/** The room the artwork mode keeps (web/src/artframe.ts): the panel's width at the right, the margin
|
|
150
|
+
* at the frame's sides, the gaps above and below it, the room for the title's strip above it and
|
|
151
|
+
* the source's below it, and the page's bar at the top. */
|
|
152
|
+
export const FRAME_ROOM = { panel: 300, margin: 32, gap: 12, head: 60, tail: 34, bar: 60 };
|
|
153
|
+
/** Where the frame is on a screen of `screen.w` × `screen.h`: in the middle of the room the panel
|
|
154
|
+
* leaves, of the picture's shape, as large as that room allows (artframe place; the artwork command
|
|
155
|
+
* places it so, on the screen it is given: ExportSpec decision 21). */
|
|
156
|
+
export function frameRect(screen, size) {
|
|
157
|
+
const R = FRAME_ROOM;
|
|
158
|
+
// a screen too small for the panel and the strips leaves the frame a room of 40 pixels at least;
|
|
159
|
+
// the frame keeps the picture's shape whatever the room
|
|
160
|
+
const roomW = Math.max(40, screen.w - R.panel - 3 * R.margin), roomH = Math.max(40, screen.h - R.bar - R.head - R.tail - 2 * R.gap);
|
|
161
|
+
const scale = Math.min(roomW / size.w, roomH / size.h);
|
|
162
|
+
const w = size.w * scale, h = size.h * scale;
|
|
163
|
+
return { x: R.margin + (roomW - w) / 2, y: R.bar + R.gap + R.head + (roomH - h) / 2, w, h };
|
|
164
|
+
}
|
|
165
|
+
/** How much of the frame's top its title takes: the board is fitted below it as the mode opens. */
|
|
166
|
+
export function titleRoom(h, s, own) {
|
|
167
|
+
const titled = !!(s.title ?? own.title) || !!(s.subtitle ?? own.subtitle);
|
|
168
|
+
return titled ? Math.min(h * 0.4, ((s.titleSize * 1.7) / 100) * h + Math.max(14, h * 0.035)) : 0;
|
|
169
|
+
}
|
|
170
|
+
/** What an artwork writes over the board within `rect` (the page's and the command's alike): its
|
|
171
|
+
* border, its title and its foot, in the theme's colours as `v` reads them; `own`, the project's
|
|
172
|
+
* title and line where the reader gave none; `source`, the foot's source line or none. */
|
|
173
|
+
export function writeOver(ctx, s, rect, own, source, v = browserVar) {
|
|
174
|
+
const halo = s.style === "cells" ? "#0f1520" : s.style === "stars" ? "#090d1a" : v("--canvas");
|
|
175
|
+
if (s.border)
|
|
176
|
+
drawBorder(ctx, rect, s.style === "cells" ? "rgba(200, 212, 236, 0.55)" : s.style === "stars" ? "rgba(241, 230, 200, 0.55)" : v("--text-2"));
|
|
177
|
+
drawTitle(ctx, rect, s.title ?? own.title, s.subtitle ?? own.subtitle, s.titleSize, s.style, { ink: v("--text"), quiet: v("--text-2"), halo });
|
|
178
|
+
const quiet = s.style === "board" ? v("--text-2") : "rgba(200, 208, 224, 0.7)";
|
|
179
|
+
drawFoot(ctx, rect, s.source ? source : null, { text: quiet, halo }, ART_SANS, s.footSize);
|
|
180
|
+
}
|
|
181
|
+
/** A rounded rectangle's path. */
|
|
182
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
183
|
+
r = Math.min(r, w / 2, h / 2);
|
|
184
|
+
ctx.beginPath();
|
|
185
|
+
ctx.moveTo(x + r, y);
|
|
186
|
+
ctx.lineTo(x + w - r, y);
|
|
187
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
188
|
+
ctx.lineTo(x + w, y + h - r);
|
|
189
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
190
|
+
ctx.lineTo(x + r, y + h);
|
|
191
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
192
|
+
ctx.lineTo(x, y + r);
|
|
193
|
+
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
194
|
+
ctx.closePath();
|
|
195
|
+
}
|
|
196
|
+
/** `text` broken into lines `width` wide in the context's font, as the card breaks it: at its line
|
|
197
|
+
* breaks, between words, and inside a word too long for a line. */
|
|
198
|
+
export function wrapText(text, width, measure) {
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const para of text.split("\n")) {
|
|
201
|
+
let line = "";
|
|
202
|
+
for (const word of para.split(/(?<=\s)/)) {
|
|
203
|
+
if (measure(line + word) <= width || !line)
|
|
204
|
+
line += word;
|
|
205
|
+
else {
|
|
206
|
+
out.push(line.trimEnd());
|
|
207
|
+
line = word;
|
|
208
|
+
}
|
|
209
|
+
// a word longer than the line: broken where it reaches the edge
|
|
210
|
+
while (measure(line.trimEnd()) > width && line.length > 1) {
|
|
211
|
+
let k = line.length - 1;
|
|
212
|
+
while (k > 1 && measure(line.slice(0, k)) > width)
|
|
213
|
+
k--;
|
|
214
|
+
out.push(line.slice(0, k));
|
|
215
|
+
line = line.slice(k);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
out.push(line.trimEnd());
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/** The notes as the board shows them, written with `ctx` in the screen's pixels — over the board in a
|
|
223
|
+
* PNG, recorded into an SVG: the leader to a declaration and its diamond, the leader to an open card,
|
|
224
|
+
* the mark (its icon, its dot over it as far as it has turned into one) and the card with its text.
|
|
225
|
+
* `glyph` draws an icon's glyph, which is SVG: a PNG has it as a picture, an SVG as it is. */
|
|
226
|
+
export function drawNotes(ctx, notes, font, glyph) {
|
|
227
|
+
for (const n of notes) {
|
|
228
|
+
ctx.save();
|
|
229
|
+
if (n.decl) {
|
|
230
|
+
const d = n.decl, [x0, y0, x1, y1] = d.line;
|
|
231
|
+
ctx.globalAlpha = n.t;
|
|
232
|
+
ctx.strokeStyle = d.colour;
|
|
233
|
+
ctx.lineWidth = d.width;
|
|
234
|
+
ctx.setLineDash([3 * d.width, 3 * d.width]);
|
|
235
|
+
ctx.beginPath();
|
|
236
|
+
ctx.moveTo(x0, y0);
|
|
237
|
+
ctx.lineTo(x1, y1);
|
|
238
|
+
ctx.stroke();
|
|
239
|
+
ctx.setLineDash([]);
|
|
240
|
+
const q = d.markPx / Math.SQRT2, [mx, my] = d.mark;
|
|
241
|
+
ctx.beginPath();
|
|
242
|
+
ctx.moveTo(mx, my - q);
|
|
243
|
+
ctx.lineTo(mx + q, my);
|
|
244
|
+
ctx.lineTo(mx, my + q);
|
|
245
|
+
ctx.lineTo(mx - q, my);
|
|
246
|
+
ctx.closePath();
|
|
247
|
+
ctx.fillStyle = d.colour;
|
|
248
|
+
ctx.fill();
|
|
249
|
+
ctx.strokeStyle = n.surface;
|
|
250
|
+
ctx.lineWidth = 1;
|
|
251
|
+
ctx.stroke();
|
|
252
|
+
ctx.globalAlpha = 1;
|
|
253
|
+
}
|
|
254
|
+
if (n.lead && n.card) {
|
|
255
|
+
const [x0, y0, x1, y1] = n.lead.line;
|
|
256
|
+
ctx.strokeStyle = n.lead.colour;
|
|
257
|
+
ctx.lineWidth = 1.2;
|
|
258
|
+
if (n.lead.dashed)
|
|
259
|
+
ctx.setLineDash([4, 3]);
|
|
260
|
+
ctx.beginPath();
|
|
261
|
+
ctx.moveTo(x0, y0);
|
|
262
|
+
ctx.lineTo(x1, y1);
|
|
263
|
+
ctx.stroke();
|
|
264
|
+
ctx.setLineDash([]);
|
|
265
|
+
}
|
|
266
|
+
// the mark: the icon, and the dot covering it as the zoom turns it into one
|
|
267
|
+
if (n.t > 0) {
|
|
268
|
+
ctx.beginPath();
|
|
269
|
+
ctx.arc(n.x, n.y, Math.max(0.1, n.r - n.ringW / 2), 0, 2 * Math.PI);
|
|
270
|
+
ctx.fillStyle = n.fill;
|
|
271
|
+
ctx.fill();
|
|
272
|
+
ctx.strokeStyle = n.ring;
|
|
273
|
+
ctx.lineWidth = n.ringW;
|
|
274
|
+
if (n.dashed)
|
|
275
|
+
ctx.setLineDash([3 * n.ringW, 2 * n.ringW]);
|
|
276
|
+
ctx.stroke();
|
|
277
|
+
ctx.setLineDash([]);
|
|
278
|
+
glyph(n);
|
|
279
|
+
}
|
|
280
|
+
if (n.t < 1) {
|
|
281
|
+
const s = n.r / 12;
|
|
282
|
+
ctx.globalAlpha = 1 - n.t;
|
|
283
|
+
ctx.beginPath();
|
|
284
|
+
ctx.arc(n.x, n.y, n.r + 1.5 * s, 0, 2 * Math.PI);
|
|
285
|
+
ctx.fillStyle = n.dot;
|
|
286
|
+
ctx.fill();
|
|
287
|
+
ctx.strokeStyle = n.surface;
|
|
288
|
+
ctx.lineWidth = 1;
|
|
289
|
+
ctx.stroke();
|
|
290
|
+
ctx.globalAlpha = 1;
|
|
291
|
+
}
|
|
292
|
+
// the card: its accent down its left, its edge, its text and who wrote it
|
|
293
|
+
const c = n.card;
|
|
294
|
+
if (c) {
|
|
295
|
+
ctx.fillStyle = c.accent;
|
|
296
|
+
roundRect(ctx, c.x, c.y, c.w, c.h, 8);
|
|
297
|
+
ctx.fill();
|
|
298
|
+
ctx.strokeStyle = c.edge;
|
|
299
|
+
ctx.lineWidth = 1;
|
|
300
|
+
ctx.stroke();
|
|
301
|
+
ctx.fillStyle = c.bg;
|
|
302
|
+
roundRect(ctx, c.x + 2, c.y, c.w - 2, c.h, 8);
|
|
303
|
+
ctx.fill();
|
|
304
|
+
ctx.textAlign = "left";
|
|
305
|
+
ctx.textBaseline = "alphabetic";
|
|
306
|
+
ctx.font = `12.5px ${font}`;
|
|
307
|
+
ctx.fillStyle = c.textColour;
|
|
308
|
+
const lh = 12.5 * 1.45, bottom = c.y + c.h - 6 - (c.by ? 20 : 0);
|
|
309
|
+
let y = c.y + 7 + 12.8;
|
|
310
|
+
for (const line of wrapText(c.text, c.w - 22, (t) => ctx.measureText(t).width)) {
|
|
311
|
+
if (y > bottom + 1)
|
|
312
|
+
break;
|
|
313
|
+
ctx.fillText(line, c.x + 12, y);
|
|
314
|
+
y += lh;
|
|
315
|
+
}
|
|
316
|
+
if (c.by) {
|
|
317
|
+
ctx.font = `11px ${font}`;
|
|
318
|
+
ctx.fillStyle = c.byColour;
|
|
319
|
+
ctx.fillText(c.by, c.x + 12, c.y + c.h - 9);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
ctx.restore();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// ---------------------------------------------------------------- the foot
|
|
326
|
+
/** VisualProof's wordmark: its gradient's colours, left to right (the page's `--grad`) */
|
|
327
|
+
export const BRAND = ["#5e5ce6", "#bf5af2", "#ff375f"];
|
|
328
|
+
/** The foot's line for a project's git source and commit: "github.com/A/B · main @ 1a2b3c4", and
|
|
329
|
+
* "(modified)" where a file of it differed from the commit. */
|
|
330
|
+
export function sourceLine(git) {
|
|
331
|
+
const at = `${git.branch ? `${git.branch} @ ` : ""}${git.commit.slice(0, 7)}${git.dirty ? " (modified)" : ""}`;
|
|
332
|
+
return git.source ? `${git.source} · ${at}` : `commit ${at}`;
|
|
333
|
+
}
|
|
334
|
+
const FOOT_PRE = "Generated by ";
|
|
335
|
+
/** the foot's faces at `px` pixels: its words, and VisualProof's wordmark */
|
|
336
|
+
const footFonts = (px, font) => [`500 ${px}px ${font}`, `700 ${px * 1.1}px ${font}`];
|
|
337
|
+
/** the least room between the foot's two sides, and its room from the edges, in its sizes */
|
|
338
|
+
const FOOT_GAP = 1.6;
|
|
339
|
+
/** The largest foot (drawFoot's `size`) a frame `w` wide and `h` high holds on one line, with the
|
|
340
|
+
* source's line `source` where it is given: its two sides never one above the other (the owner's
|
|
341
|
+
* decision of 2026-10-15). What a foot's size slider reaches. */
|
|
342
|
+
export function footMost(measure, w, h, source, font) {
|
|
343
|
+
const [plain, mark] = footFonts(100, font);
|
|
344
|
+
const across = (measure(FOOT_PRE, plain) + measure("VisualProof", mark) + (source ? measure(source, plain) : 0)) / 100;
|
|
345
|
+
return (w / (across + (source ? FOOT_GAP : 0) + 2) / h) * 100;
|
|
346
|
+
}
|
|
347
|
+
/** An artwork's foot (the owner's decision of 2026-10-15), written with `ctx` in the screen's pixels
|
|
348
|
+
* inside the frame `rect`, on halos of `halo`: "Generated by VisualProof" at the bottom left,
|
|
349
|
+
* VisualProof in its wordmark; at the bottom right, where it is given, the source's line, on the same
|
|
350
|
+
* line. Its size is `size` hundredths of the frame's height, its room from the edges as much; no
|
|
351
|
+
* larger than the frame holds so (footMost). */
|
|
352
|
+
export function drawFoot(ctx, rect, source, colours, font, size) {
|
|
353
|
+
const measure = (text, f) => {
|
|
354
|
+
ctx.font = f;
|
|
355
|
+
return ctx.measureText(text).width;
|
|
356
|
+
};
|
|
357
|
+
ctx.save();
|
|
358
|
+
const px = (Math.min(size, footMost(measure, rect.w, rect.h, source, font)) / 100) * rect.h;
|
|
359
|
+
const [plain, mark] = footFonts(px, font);
|
|
360
|
+
const pw = measure(FOOT_PRE, plain), bw = measure("VisualProof", mark);
|
|
361
|
+
const y = rect.y + rect.h - px, x = rect.x + px, right = rect.x + rect.w - px;
|
|
362
|
+
const grad = ctx.createLinearGradient(x + pw, 0, x + pw + bw, 0);
|
|
363
|
+
BRAND.forEach((c, k) => grad.addColorStop(k / (BRAND.length - 1), c));
|
|
364
|
+
writeNames(ctx, [
|
|
365
|
+
{ text: FOOT_PRE, x, y, font: plain, align: "left", fill: colours.text },
|
|
366
|
+
{ text: "VisualProof", x: x + pw, y, font: mark, align: "left", fill: grad },
|
|
367
|
+
...(source ? [{ text: source, x: right, y, font: plain, align: "right", fill: colours.text }] : []),
|
|
368
|
+
], colours.halo);
|
|
369
|
+
ctx.restore();
|
|
370
|
+
}
|
|
371
|
+
// ---------------------------------------------------------------- the ground and the title
|
|
372
|
+
/** The ground of an artwork's style behind the board, within the frame `rect` (the screen's pixels),
|
|
373
|
+
* written with `ctx` (the owner's decisions of 2026-10-15): the cells' a dark field lit towards its
|
|
374
|
+
* middle; the starfield's a night sky, darker at its edges, with a dust of faint stars that keeps
|
|
375
|
+
* its place in the frame. None for the board's style, whose ground is the theme's. */
|
|
376
|
+
export function drawBackdrop(ctx, style, rect) {
|
|
377
|
+
if (style === "board")
|
|
378
|
+
return;
|
|
379
|
+
const { x, y, w, h } = rect;
|
|
380
|
+
ctx.save();
|
|
381
|
+
const stars = style === "stars";
|
|
382
|
+
const g = stars ? ctx.createRadialGradient(x + w * 0.5, y + h * 0.4, 0, x + w * 0.5, y + h * 0.4, w * 0.8) : ctx.createRadialGradient(x + w * 0.55, y + h * 0.45, 0, x + w * 0.55, y + h * 0.45, w * 0.7);
|
|
383
|
+
g.addColorStop(0, stars ? "#101833" : "#121a28");
|
|
384
|
+
g.addColorStop(1, stars ? "#04060c" : "#090c12");
|
|
385
|
+
ctx.fillStyle = g;
|
|
386
|
+
ctx.fillRect(x, y, w, h);
|
|
387
|
+
if (stars) {
|
|
388
|
+
// the dust: the same faint stars in the same places of every frame of this shape
|
|
389
|
+
let seed = 0x9e3779b9;
|
|
390
|
+
const rnd = () => {
|
|
391
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
392
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
393
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
394
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
395
|
+
};
|
|
396
|
+
const n = Math.round(Math.min(1500, (w * h) / 700));
|
|
397
|
+
for (let k = 0; k < n; k++) {
|
|
398
|
+
const [px, py, r, a] = [x + rnd() * w, y + rnd() * h, 0.25 + rnd() * rnd() * 0.9, 0.12 + rnd() * 0.45];
|
|
399
|
+
ctx.beginPath();
|
|
400
|
+
ctx.arc(px, py, r, 0, 2 * Math.PI);
|
|
401
|
+
ctx.fillStyle = rnd() < 0.2 ? `rgba(255, 226, 190, ${a.toFixed(3)})` : `rgba(214, 226, 255, ${a.toFixed(3)})`;
|
|
402
|
+
ctx.fill();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
ctx.restore();
|
|
406
|
+
}
|
|
407
|
+
/** An artwork's title and the line under it at the top left of the frame `rect` (the screen's
|
|
408
|
+
* pixels), written with `ctx` on halos of `halo` (the owner's decisions of 2026-10-15): the title in
|
|
409
|
+
* spaced capitals of Jost in every style, `size` hundredths of the frame's height its letters' em,
|
|
410
|
+
* as its top-level clusters' names are; the line under it in the style's own face, about a third of
|
|
411
|
+
* it. The styles differ in colour and weight: the board's in the theme's ink, the cells' in a pale
|
|
412
|
+
* light, the starfield's in a pale gold, its line in serif italics. `ink` and `quiet`: the theme's
|
|
413
|
+
* strong and quiet text, for the board's style. */
|
|
414
|
+
export function drawTitle(ctx, rect, title, subtitle, size, style, colours) {
|
|
415
|
+
if (!title && !subtitle)
|
|
416
|
+
return;
|
|
417
|
+
let t = (size / 100) * rect.h * 0.8, sub = Math.max(7, (size / 100) * rect.h * 0.36);
|
|
418
|
+
const margin = Math.max(14, rect.w * 0.035), x = rect.x + margin, top = rect.y + Math.max(14, rect.h * 0.035);
|
|
419
|
+
const caps = title.toUpperCase();
|
|
420
|
+
const titleFont = (px) => `${style === "stars" ? 400 : 500} ${px}px ${ART_DISPLAY}`;
|
|
421
|
+
const lineFont = (px) => (style === "stars" ? `italic 400 ${px * 1.05}px ${ART_SERIF}` : `400 ${px}px ${ART_SANS}`);
|
|
422
|
+
ctx.save();
|
|
423
|
+
// what does not fit across the frame within its margins is drawn smaller until it does (the owner's
|
|
424
|
+
// decision of 2026-10-15): the title with its line under it, the line alone where it is the longer
|
|
425
|
+
const room = rect.w - 2 * margin;
|
|
426
|
+
const across = (font, text, spacing) => {
|
|
427
|
+
ctx.font = font;
|
|
428
|
+
return spacedWidth(ctx, text, spacing);
|
|
429
|
+
};
|
|
430
|
+
if (title) {
|
|
431
|
+
const w = across(titleFont(t), caps, 0.18 * t);
|
|
432
|
+
if (w > room)
|
|
433
|
+
[t, sub] = [(t * room) / w, (sub * room) / w];
|
|
434
|
+
}
|
|
435
|
+
if (subtitle) {
|
|
436
|
+
const w = across(lineFont(sub), subtitle, 0);
|
|
437
|
+
if (w > room)
|
|
438
|
+
sub = (sub * room) / w;
|
|
439
|
+
}
|
|
440
|
+
// the capitals' top at the margin, the line under them
|
|
441
|
+
const ty = top + t * 0.72, sy = title ? ty + sub * 1.9 : top + sub;
|
|
442
|
+
const items = [];
|
|
443
|
+
const capitals = (fill) => ({ text: caps, x, y: ty, font: titleFont(t), align: "left", fill, spacing: 0.18 * t });
|
|
444
|
+
const line = (fill) => ({ text: subtitle, x, y: sy, font: lineFont(sub), align: "left", fill });
|
|
445
|
+
if (style === "cells") {
|
|
446
|
+
const fill = ctx.createLinearGradient(x, 0, x + t * 9, 0);
|
|
447
|
+
fill.addColorStop(0, "#dfe7ff");
|
|
448
|
+
fill.addColorStop(1, "#9fb6ff");
|
|
449
|
+
if (title)
|
|
450
|
+
items.push(capitals(fill));
|
|
451
|
+
if (subtitle)
|
|
452
|
+
items.push(line("rgba(200, 208, 224, 0.78)"));
|
|
453
|
+
}
|
|
454
|
+
else if (style === "stars") {
|
|
455
|
+
if (title)
|
|
456
|
+
items.push(capitals("#f1e6c8"));
|
|
457
|
+
if (subtitle)
|
|
458
|
+
items.push(line("rgba(200, 212, 236, 0.8)"));
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
if (title)
|
|
462
|
+
items.push(capitals(colours.ink));
|
|
463
|
+
if (subtitle)
|
|
464
|
+
items.push(line(colours.quiet));
|
|
465
|
+
}
|
|
466
|
+
writeNames(ctx, items, colours.halo);
|
|
467
|
+
ctx.restore();
|
|
468
|
+
}
|
|
469
|
+
/** A thin line round the whole of the frame `rect` (the screen's pixels), a little within its edge,
|
|
470
|
+
* in `colour` (the owner's decision of 2026-10-15): its inset and its width a part of the frame's
|
|
471
|
+
* shorter side, so that the picture's are the frame's, more pixels. */
|
|
472
|
+
export function drawBorder(ctx, rect, colour) {
|
|
473
|
+
const inset = borderInset(rect), width = Math.max(0.6, Math.min(rect.w, rect.h) * 0.0012);
|
|
474
|
+
ctx.save();
|
|
475
|
+
ctx.strokeStyle = colour;
|
|
476
|
+
ctx.lineWidth = width;
|
|
477
|
+
ctx.beginPath();
|
|
478
|
+
ctx.rect(rect.x + inset, rect.y + inset, rect.w - 2 * inset, rect.h - 2 * inset);
|
|
479
|
+
ctx.stroke();
|
|
480
|
+
ctx.restore();
|
|
481
|
+
}
|
|
482
|
+
/** How far within the frame `rect` its border runs: what lies outside it is the ground alone. */
|
|
483
|
+
export function borderInset(rect) {
|
|
484
|
+
return Math.max(3, Math.min(rect.w, rect.h) * 0.006);
|
|
485
|
+
}
|
|
486
|
+
/** The band between the frame `rect` and its border, as a path to fill or clip by the even-odd rule:
|
|
487
|
+
* what an artwork with a border keeps to its ground (the owner's decision of 2026-10-15). */
|
|
488
|
+
export function borderBand(ctx, rect) {
|
|
489
|
+
const i = borderInset(rect);
|
|
490
|
+
ctx.beginPath();
|
|
491
|
+
ctx.rect(rect.x, rect.y, rect.w, rect.h);
|
|
492
|
+
ctx.rect(rect.x + i, rect.y + i, rect.w - 2 * i, rect.h - 2 * i);
|
|
493
|
+
}
|
|
494
|
+
/** The title an artwork has where the reader gave none: the project's name; and its line: how many
|
|
495
|
+
* declarations the project holds and how many targets its review has. */
|
|
496
|
+
export function titleOf(project, declarations, targets) {
|
|
497
|
+
const n = (k, one) => `${k.toLocaleString("en-US")} ${one}${k === 1 ? "" : "s"}`;
|
|
498
|
+
return { title: project, subtitle: `${n(declarations, "declaration")} · ${n(targets, "target")}` };
|
|
499
|
+
}
|
|
500
|
+
/** An icon's glyph as a picture of its own (`<svg>`, `px` across) in `colour`. */
|
|
501
|
+
export function glyphSvg(glyph, colour, px) {
|
|
502
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="${px}" height="${px}" color="${esc(colour)}">${glyph}</svg>`;
|
|
503
|
+
}
|
|
504
|
+
/** A bundled edge's points: the shader's clamped uniform cubic B-spline through its control points,
|
|
505
|
+
* straightened by `beta` towards the chord, sampled (web/src/gl/renderer.ts, BUNDLE_VS). */
|
|
506
|
+
export function bundlePoints(ctrl, beta, segs = 32) {
|
|
507
|
+
const n = ctrl.length / 2;
|
|
508
|
+
const ax = ctrl[0], ay = ctrl[1], bx = ctrl[2 * n - 2], by = ctrl[2 * n - 1];
|
|
509
|
+
const den = Math.max(n - 1, 1);
|
|
510
|
+
const Q = (j) => {
|
|
511
|
+
const t = j / den;
|
|
512
|
+
return [ax + (bx - ax) * t + (ctrl[2 * j] - (ax + (bx - ax) * t)) * beta, ay + (by - ay) * t + (ctrl[2 * j + 1] - (ay + (by - ay) * t)) * beta];
|
|
513
|
+
};
|
|
514
|
+
const out = [];
|
|
515
|
+
for (let k = 0; k <= segs; k++) {
|
|
516
|
+
const x = (k / segs) * (n + 1);
|
|
517
|
+
const s = Math.min(Math.floor(x), n);
|
|
518
|
+
const f = x - s;
|
|
519
|
+
const j = (d) => Math.min(Math.max(s + d, 0), n - 1);
|
|
520
|
+
const [q0, q1, q2, q3] = [Q(j(-2)), Q(j(-1)), Q(j(0)), Q(j(1))];
|
|
521
|
+
const f2 = f * f, f3 = f2 * f, g = 1 - f;
|
|
522
|
+
const w0 = (g * g * g) / 6, w1 = (4 - 6 * f2 + 3 * f3) / 6, w2 = (1 + 3 * f + 3 * f2 - 3 * f3) / 6, w3 = f3 / 6;
|
|
523
|
+
out.push(q0[0] * w0 + q1[0] * w1 + q2[0] * w2 + q3[0] * w3, q0[1] * w0 + q1[1] * w1 + q2[1] * w2 + q3[1] * w3);
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
// ---------------------------------------------------------------- SVG
|
|
528
|
+
const n2 = (v) => +v.toFixed(2);
|
|
529
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
530
|
+
/** A CSS colour as SVG writes it everywhere: `rgb(…)` and an opacity apart. The forms the page's
|
|
531
|
+
* style gives (hex, rgb(), rgba()) are read; any other is written as it is, opaque. */
|
|
532
|
+
export function svgColour(css, alpha = 1) {
|
|
533
|
+
const s = css.trim();
|
|
534
|
+
let r, g, b, a = 1;
|
|
535
|
+
const hex = /^#([0-9a-f]{3,8})$/i.exec(s);
|
|
536
|
+
const fn = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(s);
|
|
537
|
+
if (hex && [3, 4, 6, 8].includes(hex[1].length)) {
|
|
538
|
+
const h = hex[1].length <= 4 ? [...hex[1]].map((c) => c + c).join("") : hex[1];
|
|
539
|
+
[r, g, b] = [0, 2, 4].map((k) => parseInt(h.slice(k, k + 2), 16));
|
|
540
|
+
if (h.length === 8)
|
|
541
|
+
a = parseInt(h.slice(6, 8), 16) / 255;
|
|
542
|
+
}
|
|
543
|
+
else if (fn) {
|
|
544
|
+
[r, g, b] = [fn[1], fn[2], fn[3]].map(Number);
|
|
545
|
+
if (fn[4] !== undefined)
|
|
546
|
+
a = fn[4].endsWith("%") ? parseFloat(fn[4]) / 100 : Number(fn[4]);
|
|
547
|
+
}
|
|
548
|
+
else
|
|
549
|
+
return { rgb: s, a: alpha };
|
|
550
|
+
return { rgb: `rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)})`, a: +(a * alpha).toFixed(3) };
|
|
551
|
+
}
|
|
552
|
+
/** A colour of the renderer's (0 to 1) as SVG writes it. */
|
|
553
|
+
function glColour(c) {
|
|
554
|
+
return { rgb: `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`, a: +c[3].toFixed(3) };
|
|
555
|
+
}
|
|
556
|
+
/** the most pieces an edge of two colours is written in, by an SVG (SvgContext.shape) */
|
|
557
|
+
const BUNDLE_PIECES = 12;
|
|
558
|
+
const paint = (attr, c) => ` ${attr}="${c.rgb}"${c.a < 1 ? ` ${attr}-opacity="${c.a}"` : ""}`;
|
|
559
|
+
/** A gradient handed out by SvgContext.createLinearGradient or createRadialGradient: its stops,
|
|
560
|
+
* written into the picture's definitions where it is used; `r`, a radial one's circles' radii. */
|
|
561
|
+
class SvgGradient {
|
|
562
|
+
stops = [];
|
|
563
|
+
id = "";
|
|
564
|
+
x0;
|
|
565
|
+
y0;
|
|
566
|
+
x1;
|
|
567
|
+
y1;
|
|
568
|
+
r;
|
|
569
|
+
constructor(x0, y0, x1, y1, r = null) {
|
|
570
|
+
[this.x0, this.y0, this.x1, this.y1, this.r] = [x0, y0, x1, y1, r];
|
|
571
|
+
}
|
|
572
|
+
addColorStop(offset, colour) {
|
|
573
|
+
this.stops.push([offset, colour]);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/** What a canvas's 2D context draws, recorded as SVG: as much of the context as the board uses to
|
|
577
|
+
* write its names and marks over its renderer's drawing (GraphCanvas.drawMarks), and the notes. A
|
|
578
|
+
* path is written when it is filled or stroked, a text when it is written; what is cleared is
|
|
579
|
+
* nothing. `measure` gives a text's width in a CSS font. */
|
|
580
|
+
export class SvgContext {
|
|
581
|
+
body = [];
|
|
582
|
+
defs = [];
|
|
583
|
+
st = { fillStyle: "#000", strokeStyle: "#000", lineWidth: 1, font: "10px sans-serif", textAlign: "start", textBaseline: "alphabetic", globalAlpha: 1, lineCap: "butt", lineJoin: "miter", dash: [], m: [1, 0, 0, 1, 0, 0], letterSpacing: "0px" };
|
|
584
|
+
stack = [];
|
|
585
|
+
d = [];
|
|
586
|
+
open = false;
|
|
587
|
+
ids = 0;
|
|
588
|
+
/** gradients written once for every circle of their look and colour */
|
|
589
|
+
shared = new Map();
|
|
590
|
+
measure;
|
|
591
|
+
family;
|
|
592
|
+
constructor(measure, family) {
|
|
593
|
+
this.measure = measure;
|
|
594
|
+
this.family = family;
|
|
595
|
+
}
|
|
596
|
+
get fillStyle() { return this.st.fillStyle; }
|
|
597
|
+
set fillStyle(v) { this.st.fillStyle = v; }
|
|
598
|
+
get strokeStyle() { return this.st.strokeStyle; }
|
|
599
|
+
set strokeStyle(v) { this.st.strokeStyle = v; }
|
|
600
|
+
get lineWidth() { return this.st.lineWidth; }
|
|
601
|
+
set lineWidth(v) { this.st.lineWidth = v; }
|
|
602
|
+
get font() { return this.st.font; }
|
|
603
|
+
set font(v) { this.st.font = v; }
|
|
604
|
+
get textAlign() { return this.st.textAlign; }
|
|
605
|
+
set textAlign(v) { this.st.textAlign = v; }
|
|
606
|
+
get textBaseline() { return this.st.textBaseline; }
|
|
607
|
+
set textBaseline(v) { this.st.textBaseline = v; }
|
|
608
|
+
get globalAlpha() { return this.st.globalAlpha; }
|
|
609
|
+
set globalAlpha(v) { this.st.globalAlpha = v; }
|
|
610
|
+
get lineCap() { return this.st.lineCap; }
|
|
611
|
+
set lineCap(v) { this.st.lineCap = v; }
|
|
612
|
+
get lineJoin() { return this.st.lineJoin; }
|
|
613
|
+
set lineJoin(v) { this.st.lineJoin = v; }
|
|
614
|
+
get letterSpacing() { return this.st.letterSpacing; }
|
|
615
|
+
set letterSpacing(v) { this.st.letterSpacing = v; }
|
|
616
|
+
save() {
|
|
617
|
+
this.stack.push({ ...this.st, dash: [...this.st.dash], m: [...this.st.m] });
|
|
618
|
+
}
|
|
619
|
+
restore() {
|
|
620
|
+
this.st = this.stack.pop() ?? this.st;
|
|
621
|
+
}
|
|
622
|
+
setTransform(a, b, c, d, e, f) {
|
|
623
|
+
this.st.m = [a, b, c, d, e, f];
|
|
624
|
+
}
|
|
625
|
+
translate(x, y) {
|
|
626
|
+
const [a, b, c, d, e, f] = this.st.m;
|
|
627
|
+
this.st.m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f];
|
|
628
|
+
}
|
|
629
|
+
rotate(t) {
|
|
630
|
+
const [a, b, c, d, e, f] = this.st.m;
|
|
631
|
+
const [cs, sn] = [Math.cos(t), Math.sin(t)];
|
|
632
|
+
this.st.m = [a * cs + c * sn, b * cs + d * sn, c * cs - a * sn, d * cs - b * sn, e, f];
|
|
633
|
+
}
|
|
634
|
+
clearRect() { }
|
|
635
|
+
setLineDash(segments) {
|
|
636
|
+
this.st.dash = [...segments];
|
|
637
|
+
}
|
|
638
|
+
getLineDash() {
|
|
639
|
+
return [...this.st.dash];
|
|
640
|
+
}
|
|
641
|
+
measureText(text) {
|
|
642
|
+
const spacing = parseFloat(this.st.letterSpacing) || 0;
|
|
643
|
+
return { width: this.measure(text, this.st.font) + spacing * [...text].length };
|
|
644
|
+
}
|
|
645
|
+
createLinearGradient(x0, y0, x1, y1) {
|
|
646
|
+
const [a, b] = this.at(x0, y0), [c, d] = this.at(x1, y1);
|
|
647
|
+
return new SvgGradient(a, b, c, d);
|
|
648
|
+
}
|
|
649
|
+
/** A radial gradient: from a circle at (x0, y0) of radius r0 to one at (x1, y1) of radius r1. */
|
|
650
|
+
createRadialGradient(x0, y0, r0, x1, y1, r1) {
|
|
651
|
+
const [a, b] = this.at(x0, y0), [c, d] = this.at(x1, y1), k = this.scale();
|
|
652
|
+
return new SvgGradient(a, b, c, d, [r0 * k, r1 * k]);
|
|
653
|
+
}
|
|
654
|
+
fillRect(x, y, w, h) {
|
|
655
|
+
this.beginPath();
|
|
656
|
+
this.rect(x, y, w, h);
|
|
657
|
+
this.fill();
|
|
658
|
+
}
|
|
659
|
+
// the path
|
|
660
|
+
beginPath() {
|
|
661
|
+
this.d = [];
|
|
662
|
+
this.open = false;
|
|
663
|
+
}
|
|
664
|
+
moveTo(x, y) {
|
|
665
|
+
this.d.push(`M${this.pt(x, y)}`);
|
|
666
|
+
this.open = true;
|
|
667
|
+
}
|
|
668
|
+
lineTo(x, y) {
|
|
669
|
+
this.d.push(`${this.open ? "L" : "M"}${this.pt(x, y)}`);
|
|
670
|
+
this.open = true;
|
|
671
|
+
}
|
|
672
|
+
quadraticCurveTo(cx, cy, x, y) {
|
|
673
|
+
this.d.push(`Q${this.pt(cx, cy)} ${this.pt(x, y)}`);
|
|
674
|
+
}
|
|
675
|
+
bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
|
|
676
|
+
this.d.push(`C${this.pt(c1x, c1y)} ${this.pt(c2x, c2y)} ${this.pt(x, y)}`);
|
|
677
|
+
}
|
|
678
|
+
closePath() {
|
|
679
|
+
this.d.push("Z");
|
|
680
|
+
}
|
|
681
|
+
rect(x, y, w, h) {
|
|
682
|
+
this.moveTo(x, y);
|
|
683
|
+
this.lineTo(x + w, y);
|
|
684
|
+
this.lineTo(x + w, y + h);
|
|
685
|
+
this.lineTo(x, y + h);
|
|
686
|
+
this.closePath();
|
|
687
|
+
}
|
|
688
|
+
/** An arc as the canvas draws it: a line from where the path is to its start, then the arc; a
|
|
689
|
+
* whole turn or more is a circle, in two halves. */
|
|
690
|
+
arc(x, y, r, a0, a1, ccw = false) {
|
|
691
|
+
const T = 2 * Math.PI;
|
|
692
|
+
const p = (a) => this.pt(x + r * Math.cos(a), y + r * Math.sin(a));
|
|
693
|
+
const R = n2(r * this.scale());
|
|
694
|
+
this.d.push(`${this.open ? "L" : "M"}${p(a0)}`);
|
|
695
|
+
this.open = true;
|
|
696
|
+
const span = ccw ? a0 - a1 : a1 - a0;
|
|
697
|
+
const sweep = ccw ? 0 : 1;
|
|
698
|
+
if (span >= T) {
|
|
699
|
+
const mid = ccw ? a0 - Math.PI : a0 + Math.PI;
|
|
700
|
+
this.d.push(`A${R} ${R} 0 1 ${sweep} ${p(mid)}A${R} ${R} 0 1 ${sweep} ${p(a0)}`);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const delta = ((span % T) + T) % T;
|
|
704
|
+
this.d.push(`A${R} ${R} 0 ${delta > Math.PI ? 1 : 0} ${sweep} ${p(ccw ? a0 - delta : a0 + delta)}`);
|
|
705
|
+
}
|
|
706
|
+
fill() {
|
|
707
|
+
if (this.d.length)
|
|
708
|
+
this.body.push(`<path d="${this.d.join("")}"${this.style("fill")}/>`);
|
|
709
|
+
}
|
|
710
|
+
stroke() {
|
|
711
|
+
if (this.d.length)
|
|
712
|
+
this.body.push(`<path d="${this.d.join("")}" fill="none"${this.style("stroke")}${this.lines()}/>`);
|
|
713
|
+
}
|
|
714
|
+
fillText(text, x, y) {
|
|
715
|
+
this.body.push(`<text ${this.textAt(x, y)}${this.style("fill")}>${esc(text)}</text>`);
|
|
716
|
+
}
|
|
717
|
+
strokeText(text, x, y) {
|
|
718
|
+
this.body.push(`<text ${this.textAt(x, y)} fill="none"${this.style("stroke")}${this.lines()}>${esc(text)}</text>`);
|
|
719
|
+
}
|
|
720
|
+
/** A rectangle (the context's pixels) to clip to, by its id: an artwork's inside its border. */
|
|
721
|
+
clipRect(id, x, y, w, h) {
|
|
722
|
+
const [x0, y0] = this.at(x, y), k = this.scale();
|
|
723
|
+
this.defs.push(`<clipPath id="${id}"><rect x="${n2(x0)}" y="${n2(y0)}" width="${n2(w * k)}" height="${n2(h * k)}"/></clipPath>`);
|
|
724
|
+
}
|
|
725
|
+
/** SVG's own markup, written as it is: a note's glyph. */
|
|
726
|
+
raw(markup) {
|
|
727
|
+
this.body.push(markup);
|
|
728
|
+
}
|
|
729
|
+
/** A shape the board's renderer draws (GraphCanvas.artShapes), as SVG draws it. */
|
|
730
|
+
shape(s) {
|
|
731
|
+
if (s.kind === "circle" && (s.look || s.halo))
|
|
732
|
+
return this.lit(s);
|
|
733
|
+
if (s.kind === "circle") {
|
|
734
|
+
const fill = s.fill[3] > 0.002 ? glColour(s.fill) : null;
|
|
735
|
+
const ring = s.stroke ? `${paint("stroke", glColour(s.stroke))} stroke-width="${n2(s.ring)}"${s.dashed ? ` stroke-dasharray="5 4"` : ""}` : "";
|
|
736
|
+
const c = `cx="${n2(s.x)}" cy="${n2(s.y)}"`;
|
|
737
|
+
if (fill && ring && Math.abs(s.ringR - s.r) < 0.01)
|
|
738
|
+
this.body.push(`<circle ${c} r="${n2(s.r)}"${paint("fill", fill)}${ring}/>`);
|
|
739
|
+
else {
|
|
740
|
+
if (fill)
|
|
741
|
+
this.body.push(`<circle ${c} r="${n2(s.r)}"${paint("fill", fill)}/>`);
|
|
742
|
+
if (ring)
|
|
743
|
+
this.body.push(`<circle ${c} r="${n2(s.ringR)}" fill="none"${ring}/>`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
else if (s.kind === "line") {
|
|
747
|
+
this.body.push(`<line x1="${n2(s.x0)}" y1="${n2(s.y0)}" x2="${n2(s.x1)}" y2="${n2(s.y1)}"${paint("stroke", glColour(s.colour))} stroke-width="${n2(s.width)}"/>`);
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
// the shader's spline, as finely as it draws it
|
|
751
|
+
const pts = bundlePoints(s.ctrl, s.beta, BUNDLE_PIECES * 4);
|
|
752
|
+
const n = pts.length / 2 - 1;
|
|
753
|
+
const path = (i0, i1, c) => `<path d="M${n2(pts[2 * i0])},${n2(pts[2 * i0 + 1])}${Array.from({ length: i1 - i0 }, (_, k) => `L${n2(pts[2 * (i0 + k + 1)])},${n2(pts[2 * (i0 + k + 1) + 1])}`).join("")}" fill="none"${paint("stroke", c)} stroke-width="${n2(s.width)}"/>`;
|
|
754
|
+
const a = glColour(s.c0), b = glColour(s.c1);
|
|
755
|
+
if (a.rgb === b.rgb && a.a === b.a)
|
|
756
|
+
this.body.push(path(0, n, a));
|
|
757
|
+
else {
|
|
758
|
+
// coloured along its own path from the end that uses to the end used, as the renderer mixes
|
|
759
|
+
// it (mix(c0, c1, t)), in pieces, the more the farther apart its colours: a gradient of SVG
|
|
760
|
+
// runs straight between two points, which leaves most of a bent edge one end's colour, the
|
|
761
|
+
// picture brighter than the PNG (the owner's preference of 2026-10-15: the PNG's look)
|
|
762
|
+
const apart = Math.max(...s.c0.map((v, k) => Math.abs(v - s.c1[k])));
|
|
763
|
+
const pieces = Math.min(BUNDLE_PIECES, Math.max(2, Math.ceil(apart * BUNDLE_PIECES)));
|
|
764
|
+
for (let p = 0; p < pieces; p++) {
|
|
765
|
+
const i0 = Math.floor((p * n) / pieces), i1 = Math.floor(((p + 1) * n) / pieces);
|
|
766
|
+
const t = (i0 + i1) / (2 * n);
|
|
767
|
+
this.body.push(path(i0, i1, glColour(s.c0.map((v, k) => v + (s.c1[k] - v) * t))));
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/** A circle of an artwork's style, as the renderer draws it: a glow fading from its centre; a
|
|
773
|
+
* fill fading from a light at its top left; its ring a halo fading to both sides. Gradients a glow
|
|
774
|
+
* or a fill of one colour share are written once. */
|
|
775
|
+
lit(s) {
|
|
776
|
+
const c = `cx="${n2(s.x)}" cy="${n2(s.y)}"`;
|
|
777
|
+
if (s.halo && s.stroke) {
|
|
778
|
+
const k = glColour(s.stroke), R = s.ringR + s.ring / 2;
|
|
779
|
+
const at = (d) => +Math.max(0, Math.min(1, d / R)).toFixed(4);
|
|
780
|
+
const id = `h${this.ids++}`;
|
|
781
|
+
const stops = [[0, 0], [at(s.ringR - s.ring / 2), 0], [at(s.ringR - s.ring / 4), 0.25], [at(s.ringR), 1], [at(s.ringR + s.ring / 4), 0.25], [1, 0]];
|
|
782
|
+
this.defs.push(`<radialGradient id="${id}">${stops.map(([o, a]) => `<stop offset="${o}" stop-color="${k.rgb}" stop-opacity="${+(a * k.a).toFixed(3)}"/>`).join("")}</radialGradient>`);
|
|
783
|
+
this.body.push(`<circle ${c} r="${n2(R)}" fill="url(#${id})"/>`);
|
|
784
|
+
}
|
|
785
|
+
if (s.fill[3] > 0.002) {
|
|
786
|
+
const k = glColour(s.fill);
|
|
787
|
+
const key = `${s.look}|${k.rgb}|${k.a}`;
|
|
788
|
+
let id = this.shared.get(key);
|
|
789
|
+
if (!id) {
|
|
790
|
+
id = `l${this.ids++}`;
|
|
791
|
+
this.shared.set(key, id);
|
|
792
|
+
this.defs.push(s.look === "glow"
|
|
793
|
+
? `<radialGradient id="${id}">${[0, 0.25, 0.5, 0.75, 1].map((o) => `<stop offset="${o}" stop-color="${k.rgb}" stop-opacity="${+((1 - o) ** 2 * k.a).toFixed(3)}"/>`).join("")}</radialGradient>`
|
|
794
|
+
: `<radialGradient id="${id}" cx="0.375" cy="0.35" r="0.65"><stop offset="0" stop-color="${k.rgb}" stop-opacity="${k.a}"/><stop offset="1" stop-color="${k.rgb}" stop-opacity="${+(k.a * 0.25).toFixed(3)}"/></radialGradient>`);
|
|
795
|
+
}
|
|
796
|
+
this.body.push(`<circle ${c} r="${n2(s.r)}" fill="url(#${id})"/>`);
|
|
797
|
+
}
|
|
798
|
+
if (s.stroke && !s.halo)
|
|
799
|
+
this.body.push(`<circle ${c} r="${n2(s.ringR)}" fill="none"${paint("stroke", glColour(s.stroke))} stroke-width="${n2(s.ring)}"${s.dashed ? ` stroke-dasharray="5 4"` : ""}/>`);
|
|
800
|
+
}
|
|
801
|
+
scale() {
|
|
802
|
+
const [a, b, c, d] = this.st.m;
|
|
803
|
+
return Math.sqrt(Math.abs(a * d - b * c));
|
|
804
|
+
}
|
|
805
|
+
at(x, y) {
|
|
806
|
+
const [a, b, c, d, e, f] = this.st.m;
|
|
807
|
+
return [a * x + c * y + e, b * x + d * y + f];
|
|
808
|
+
}
|
|
809
|
+
pt(x, y) {
|
|
810
|
+
const [px, py] = this.at(x, y);
|
|
811
|
+
return `${n2(px)},${n2(py)}`;
|
|
812
|
+
}
|
|
813
|
+
/** A fill's or a stroke's paint: a colour (and the context's alpha), or a gradient written once. */
|
|
814
|
+
style(attr) {
|
|
815
|
+
const v = attr === "fill" ? this.st.fillStyle : this.st.strokeStyle;
|
|
816
|
+
const alpha = this.st.globalAlpha;
|
|
817
|
+
if (typeof v === "string")
|
|
818
|
+
return paint(attr, svgColour(v, alpha));
|
|
819
|
+
if (!v.id) {
|
|
820
|
+
v.id = `g${this.ids++}`;
|
|
821
|
+
const stops = v.stops.map(([o, c]) => {
|
|
822
|
+
const k = svgColour(c);
|
|
823
|
+
return `<stop offset="${o}" stop-color="${k.rgb}" stop-opacity="${k.a}"/>`;
|
|
824
|
+
});
|
|
825
|
+
this.defs.push(v.r
|
|
826
|
+
? `<radialGradient id="${v.id}" gradientUnits="userSpaceOnUse" fx="${n2(v.x0)}" fy="${n2(v.y0)}" fr="${n2(v.r[0])}" cx="${n2(v.x1)}" cy="${n2(v.y1)}" r="${n2(v.r[1])}">${stops.join("")}</radialGradient>`
|
|
827
|
+
: `<linearGradient id="${v.id}" gradientUnits="userSpaceOnUse" x1="${n2(v.x0)}" y1="${n2(v.y0)}" x2="${n2(v.x1)}" y2="${n2(v.y1)}">${stops.join("")}</linearGradient>`);
|
|
828
|
+
}
|
|
829
|
+
return ` ${attr}="url(#${v.id})"${alpha < 1 ? ` ${attr}-opacity="${+alpha.toFixed(3)}"` : ""}`;
|
|
830
|
+
}
|
|
831
|
+
lines() {
|
|
832
|
+
const s = this.st, k = this.scale();
|
|
833
|
+
return ` stroke-width="${n2(s.lineWidth * k)}"${s.lineCap !== "butt" ? ` stroke-linecap="${s.lineCap}"` : ""}${s.lineJoin !== "miter" ? ` stroke-linejoin="${s.lineJoin}"` : ""}${s.dash.length ? ` stroke-dasharray="${s.dash.map((x) => n2(x * k)).join(" ")}"` : ""}`;
|
|
834
|
+
}
|
|
835
|
+
/** Where a text is written and in what font, as SVG's attributes. */
|
|
836
|
+
textAt(x, y) {
|
|
837
|
+
const [px, py] = this.at(x, y);
|
|
838
|
+
const f = /^\s*((?:(?:italic|oblique|normal|bold|bolder|lighter|\d{3})\s+)*)([\d.]+)px\s+(.+?)\s*$/i.exec(this.st.font);
|
|
839
|
+
const words = (f?.[1] ?? "").trim().toLowerCase().split(/\s+/);
|
|
840
|
+
const size = f ? Number(f[2]) * this.scale() : 10;
|
|
841
|
+
const family = f?.[3] ?? "sans-serif";
|
|
842
|
+
const weight = words.find((w) => /^\d{3}$|^bold/.test(w));
|
|
843
|
+
const italic = words.some((w) => w === "italic" || w === "oblique");
|
|
844
|
+
const align = this.st.textAlign;
|
|
845
|
+
const anchor = align === "center" ? "middle" : align === "right" || align === "end" ? "end" : null;
|
|
846
|
+
const base = this.st.textBaseline;
|
|
847
|
+
const baseline = base === "middle" ? "central" : base === "top" || base === "hanging" ? "hanging" : base === "bottom" || base === "ideographic" ? "text-after-edge" : null;
|
|
848
|
+
// turned where the transform turns (a name along an arc), spaced where the letters are
|
|
849
|
+
const [ma, mb] = this.st.m;
|
|
850
|
+
const turn = Math.atan2(mb, ma) * (180 / Math.PI);
|
|
851
|
+
const spacing = parseFloat(this.st.letterSpacing) || 0;
|
|
852
|
+
return (`x="${n2(px)}" y="${n2(py)}" font-size="${n2(size)}"` +
|
|
853
|
+
(Math.abs(turn) > 0.01 ? ` transform="rotate(${n2(turn)} ${n2(px)} ${n2(py)})"` : "") +
|
|
854
|
+
(spacing ? ` letter-spacing="${n2(spacing * this.scale())}"` : "") +
|
|
855
|
+
(weight && weight !== "400" && weight !== "normal" ? ` font-weight="${weight}"` : "") +
|
|
856
|
+
(italic ? ` font-style="italic"` : "") +
|
|
857
|
+
(family !== this.family ? ` font-family="${esc(family)}"` : "") +
|
|
858
|
+
(anchor ? ` text-anchor="${anchor}"` : "") +
|
|
859
|
+
(baseline ? ` dominant-baseline="${baseline}"` : ""));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/** The picture as SVG: what `draw` draws with an SvgContext in the screen's pixels, within the
|
|
863
|
+
* rectangle `frame` of the screen, `size` pixels in size; on `background` where there is one.
|
|
864
|
+
* `family`: the board's font, the picture's own. */
|
|
865
|
+
export function artSvg(frame, size, background, family, measure, draw, faces = "") {
|
|
866
|
+
const pen = new SvgContext(measure, family);
|
|
867
|
+
draw(pen);
|
|
868
|
+
const bg = background ? svgColour(background) : null;
|
|
869
|
+
return (`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n2(frame.w)} ${n2(frame.h)}" width="${size.w}" height="${size.h}" font-family="${esc(family)}">` +
|
|
870
|
+
`<defs>${faces ? `<style>${faces}</style>` : ""}<clipPath id="frame"><rect width="${n2(frame.w)}" height="${n2(frame.h)}"/></clipPath>${pen.defs.join("")}</defs>` +
|
|
871
|
+
(bg ? `<rect width="100%" height="100%"${paint("fill", bg)}/>` : "") +
|
|
872
|
+
`<g clip-path="url(#frame)"><g transform="translate(${n2(-frame.x)} ${n2(-frame.y)})">${pen.body.join("")}</g></g></svg>`);
|
|
873
|
+
}
|
|
874
|
+
/** A name for the picture's file, from the view's title: what no file system refuses. */
|
|
875
|
+
export function artName(title, ext) {
|
|
876
|
+
const base = title.replace(/[<>:"/\\|?*\u0000-\u001f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 80).replace(/[. ]+$/, "") || "VisualProof";
|
|
877
|
+
return `${base}.${ext}`;
|
|
878
|
+
}
|
|
879
|
+
// ---------------------------------------------------------------- PNG, in bands
|
|
880
|
+
const CRC_TABLE = (() => {
|
|
881
|
+
const t = new Uint32Array(256);
|
|
882
|
+
for (let n = 0; n < 256; n++) {
|
|
883
|
+
let c = n;
|
|
884
|
+
for (let k = 0; k < 8; k++)
|
|
885
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
886
|
+
t[n] = c >>> 0;
|
|
887
|
+
}
|
|
888
|
+
return t;
|
|
889
|
+
})();
|
|
890
|
+
function crc32(parts) {
|
|
891
|
+
let c = 0xffffffff;
|
|
892
|
+
for (const p of parts)
|
|
893
|
+
for (let i = 0; i < p.length; i++)
|
|
894
|
+
c = CRC_TABLE[(c ^ p[i]) & 0xff] ^ (c >>> 8);
|
|
895
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
896
|
+
}
|
|
897
|
+
/** A PNG chunk: its length, type, data and CRC. */
|
|
898
|
+
function chunk(type, data) {
|
|
899
|
+
const head = new Uint8Array(8);
|
|
900
|
+
const dv = new DataView(head.buffer);
|
|
901
|
+
dv.setUint32(0, data.length);
|
|
902
|
+
const t = new TextEncoder().encode(type);
|
|
903
|
+
head.set(t, 4);
|
|
904
|
+
const tail = new Uint8Array(4);
|
|
905
|
+
new DataView(tail.buffer).setUint32(0, crc32([t, data]));
|
|
906
|
+
return [head, data, tail];
|
|
907
|
+
}
|
|
908
|
+
/** A PNG of `w` × `h` RGBA pixels, its rows given band by band (`bands` yields each band's pixels,
|
|
909
|
+
* rows top to bottom, 4 bytes a pixel), compressed as they come: no picture of the full size is ever
|
|
910
|
+
* held, so the size is not the browser's canvas's to limit. */
|
|
911
|
+
export async function encodePng(w, h, bands) {
|
|
912
|
+
const z = new CompressionStream("deflate");
|
|
913
|
+
const writer = z.writable.getWriter();
|
|
914
|
+
const out = [];
|
|
915
|
+
const reading = (async () => {
|
|
916
|
+
const r = z.readable.getReader();
|
|
917
|
+
for (;;) {
|
|
918
|
+
const { done, value } = await r.read();
|
|
919
|
+
if (done)
|
|
920
|
+
break;
|
|
921
|
+
out.push(value);
|
|
922
|
+
}
|
|
923
|
+
})();
|
|
924
|
+
const stride = 4 * w;
|
|
925
|
+
let rows = 0;
|
|
926
|
+
for await (const band of bands) {
|
|
927
|
+
const n = band.length / stride;
|
|
928
|
+
// each row: filter type 0, then its pixels
|
|
929
|
+
const buf = new Uint8Array(n * (stride + 1));
|
|
930
|
+
for (let y = 0; y < n; y++)
|
|
931
|
+
buf.set(band.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
932
|
+
await writer.write(buf);
|
|
933
|
+
rows += n;
|
|
934
|
+
}
|
|
935
|
+
if (rows !== h)
|
|
936
|
+
throw new Error(`a PNG of ${h} rows was given ${rows}`);
|
|
937
|
+
await writer.close();
|
|
938
|
+
await reading;
|
|
939
|
+
let size = 0;
|
|
940
|
+
for (const p of out)
|
|
941
|
+
size += p.length;
|
|
942
|
+
const idat = new Uint8Array(size);
|
|
943
|
+
let at = 0;
|
|
944
|
+
for (const p of out) {
|
|
945
|
+
idat.set(p, at);
|
|
946
|
+
at += p.length;
|
|
947
|
+
}
|
|
948
|
+
const ihdr = new Uint8Array(13);
|
|
949
|
+
const dv = new DataView(ihdr.buffer);
|
|
950
|
+
dv.setUint32(0, w);
|
|
951
|
+
dv.setUint32(4, h);
|
|
952
|
+
ihdr.set([8, 6, 0, 0, 0], 8);
|
|
953
|
+
const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
954
|
+
return new Blob([sig, ...chunk("IHDR", ihdr), ...chunk("IDAT", idat), ...chunk("IEND", new Uint8Array(0))], { type: "image/png" });
|
|
955
|
+
}
|
|
956
|
+
/** Names ticked (`on`) or unticked in the artwork's list (By selection; web/src/artframe.ts,
|
|
957
|
+
* chooseNames), `written` those the board writes now: a name ticked is no longer hidden, and is
|
|
958
|
+
* shown besides where the board does not write it now — a name a recipe's rule chose becomes the
|
|
959
|
+
* reader's so, written wherever it falls; a name unticked is no longer shown besides, nor the
|
|
960
|
+
* rule's, and is hidden where the board would write it all the same (from As shown). The lists as
|
|
961
|
+
* they become, and what is to be added to those shown and to those hidden (each within what the
|
|
962
|
+
* artwork carries: withChosen). */
|
|
963
|
+
export function chooseIn(s, written, ids, on) {
|
|
964
|
+
const hidden = new Set(s.hiddenNames), shown = new Set(s.shownNames), ruled = new Set(s.ruledNames);
|
|
965
|
+
const add = [], hide = [];
|
|
966
|
+
for (const id of ids) {
|
|
967
|
+
if (on) {
|
|
968
|
+
hidden.delete(id);
|
|
969
|
+
if (!written.has(id) && !shown.has(id)) {
|
|
970
|
+
add.push(id);
|
|
971
|
+
ruled.delete(id);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
else if (shown.has(id))
|
|
975
|
+
shown.delete(id);
|
|
976
|
+
else if (ruled.has(id)) {
|
|
977
|
+
ruled.delete(id);
|
|
978
|
+
if (s.namesFrom === "board")
|
|
979
|
+
hide.push(id);
|
|
980
|
+
}
|
|
981
|
+
else if (s.namesFrom === "board" && written.has(id))
|
|
982
|
+
hide.push(id);
|
|
983
|
+
}
|
|
984
|
+
return { hidden: [...hidden], shown: [...shown], ruled: [...ruled], add, hide };
|
|
985
|
+
}
|
|
986
|
+
/** Wait until the board has been drawn after `since` and then not again for `quiet` ms — still,
|
|
987
|
+
* with what came after `since` on it — at most `most` ms (a link's recipe saves then, ExportSpec
|
|
988
|
+
* decision 20); whether it was still before that. A frame drawn before `since` (before the mode
|
|
989
|
+
* came) never counts. */
|
|
990
|
+
export async function untilStill(o, since, quiet = 800, most = 20_000) {
|
|
991
|
+
const t0 = o.now();
|
|
992
|
+
for (;;) {
|
|
993
|
+
const t = o.now();
|
|
994
|
+
if (o.drawnAt() >= since && t - o.drawnAt() >= quiet)
|
|
995
|
+
return true;
|
|
996
|
+
if (t - t0 >= most)
|
|
997
|
+
return false;
|
|
998
|
+
await o.sleep(100);
|
|
999
|
+
}
|
|
1000
|
+
}
|