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,61 @@
|
|
|
1
|
+
// What a drag on the canvas does (CanvasSpec, "Hover, click, drag", decided 2026-10-09): it pans the
|
|
2
|
+
// view, by default, as a drag on empty space does; with the layout's lock let go (`l`, or the lock
|
|
3
|
+
// behind ⋯) a drag on a declaration, a circle or a note moves it, for everyone on the board. The drag
|
|
4
|
+
// key held at the press does the other way for that drag. The choice is the reader's, kept in this
|
|
5
|
+
// browser for every project, as the theme is: a new visitor starts with the lock on, panning.
|
|
6
|
+
//
|
|
7
|
+
// The drag key is ⌘ on a Mac and Ctrl elsewhere (the owner's decision, 2026-10-11): on a Mac,
|
|
8
|
+
// Ctrl+click is the secondary click, so Ctrl does nothing to drags there and a Ctrl+click stays the
|
|
9
|
+
// right click, the canvas's menu. Ctrl and the wheel (a trackpad's pinch) zooms on every platform.
|
|
10
|
+
const KEY = "visualproof:drag";
|
|
11
|
+
/** Whether the page runs on a Mac — macOS, or iPadOS and iOS, whose keyboards have ⌘ too — by the
|
|
12
|
+
* platform the browser names (`userAgentData` where it has one), else `navigator.platform` (an iPad
|
|
13
|
+
* asking for desktop pages says MacIntel), else its user agent. */
|
|
14
|
+
export function onMac(nav) {
|
|
15
|
+
const named = nav?.userAgentData?.platform || nav?.platform;
|
|
16
|
+
if (named)
|
|
17
|
+
return /^(mac|ios|iphone|ipad|ipod)/i.test(named);
|
|
18
|
+
return /Macintosh|Mac OS X|iPhone|iPad|iPod/.test(nav?.userAgent ?? "");
|
|
19
|
+
}
|
|
20
|
+
/** The page's platform, read once. */
|
|
21
|
+
export const MAC = onMac(typeof navigator === "undefined" ? undefined : navigator);
|
|
22
|
+
/** The drag key as the page names it: ⌘ on a Mac, Ctrl elsewhere. */
|
|
23
|
+
export const dragKeyName = (mac = MAC) => (mac ? "⌘" : "Ctrl");
|
|
24
|
+
/** Whether a pointer event was made with the drag key held: ⌘ alone on a Mac, Ctrl alone elsewhere
|
|
25
|
+
* (⊞ or Super is the system's there). */
|
|
26
|
+
export const dragKeyHeld = (e, mac = MAC) => (mac ? e.metaKey : e.ctrlKey);
|
|
27
|
+
/** Whether a key, pressed or let go, is the drag key itself (KeyboardEvent.key). */
|
|
28
|
+
export const isDragKey = (key, mac = MAC) => key === (mac ? "Meta" : "Control");
|
|
29
|
+
/** Whether a press of the main button is a Mac's Ctrl+click, its secondary click: the browser opens
|
|
30
|
+
* the canvas's menu on it (contextmenu), so let go where it was pressed it is no click of the page's
|
|
31
|
+
* — it selects, toggles and opens nothing — while dragged it is a drag as any, Ctrl doing nothing to
|
|
32
|
+
* drags there. */
|
|
33
|
+
export const menuClick = (e, mac = MAC) => mac && e.button === 0 && e.ctrlKey;
|
|
34
|
+
/** Whether a drag that starts on something moves it, rather than panning the view: the lock let go
|
|
35
|
+
* and the drag key not held, or the lock on and the drag key held. */
|
|
36
|
+
export function dragMoves(locked, held) {
|
|
37
|
+
return locked === held;
|
|
38
|
+
}
|
|
39
|
+
/** The lock as this browser keeps it: on unless the reader let it go. */
|
|
40
|
+
export function lockedChoice() {
|
|
41
|
+
try {
|
|
42
|
+
return localStorage.getItem(KEY) !== "move";
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// storage blocked: the default
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Keep the reader's choice: the default (locked) is kept as nothing, so that a later change of
|
|
50
|
+
* the default reaches whoever never chose. */
|
|
51
|
+
export function setLockedChoice(locked) {
|
|
52
|
+
try {
|
|
53
|
+
if (locked)
|
|
54
|
+
localStorage.removeItem(KEY);
|
|
55
|
+
else
|
|
56
|
+
localStorage.setItem(KEY, "move");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// not kept: in force for this page only
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The overview's zoom points, asked of a worker (web/src/edgeworker.ts) and kept as they come back
|
|
2
|
+
// (CanvasSpec.md, "Edges"; the owner's request, 2026-10-10). The page asks for the points the camera
|
|
3
|
+
// needs and one ahead in the direction it zooms; a few questions at most are out at once, and the
|
|
4
|
+
// latest wins: an answer older than the point already held is dropped, and so is one whose point is
|
|
5
|
+
// no longer near the camera when it comes, or that was chosen for a tree since replaced. Where no
|
|
6
|
+
// worker can be had (no `Worker`, its file not served, the tests), the points are chosen on the page's
|
|
7
|
+
// thread at once, as they used to be.
|
|
8
|
+
import { OverviewModel, indexLooks } from "./overview.js";
|
|
9
|
+
/** At most this many questions are out at once: one being answered, one waiting behind it. */
|
|
10
|
+
export const IN_FLIGHT = 2;
|
|
11
|
+
export class EdgeQueue {
|
|
12
|
+
/** the points held, by zoom-point index, all chosen for the tree now (`base`) */
|
|
13
|
+
points = new Map();
|
|
14
|
+
pending = new Map();
|
|
15
|
+
worker = null;
|
|
16
|
+
/** no worker: the page chooses itself, at once */
|
|
17
|
+
local = null;
|
|
18
|
+
spawn;
|
|
19
|
+
onReady;
|
|
20
|
+
graph = null;
|
|
21
|
+
tree = null;
|
|
22
|
+
toggles = { externals: true };
|
|
23
|
+
base = 0;
|
|
24
|
+
lo = -Infinity;
|
|
25
|
+
hi = Infinity;
|
|
26
|
+
next = 1;
|
|
27
|
+
serial = 0;
|
|
28
|
+
/** answers dropped (stale, superseded, another tree's) and answers kept, and how long the worker
|
|
29
|
+
* took for the last one, ms */
|
|
30
|
+
dropped = 0;
|
|
31
|
+
answered = 0;
|
|
32
|
+
lastMs = 0;
|
|
33
|
+
/** how long an answer takes to come, waiting included, ms (smoothed): how far ahead a fast zoom
|
|
34
|
+
* is asked for */
|
|
35
|
+
wait = 50;
|
|
36
|
+
/** `spawn`: a worker, or null where none can be had; `onReady`: something came back (a point, or a
|
|
37
|
+
* question let go), so the page draws again and asks for what still waits. */
|
|
38
|
+
constructor(spawn, onReady) {
|
|
39
|
+
this.spawn = spawn;
|
|
40
|
+
this.onReady = onReady;
|
|
41
|
+
}
|
|
42
|
+
/** Whether the points are chosen on the page's thread (no worker). */
|
|
43
|
+
get onPageThread() {
|
|
44
|
+
if (!this.worker && !this.local)
|
|
45
|
+
this.start();
|
|
46
|
+
return this.local !== null;
|
|
47
|
+
}
|
|
48
|
+
/** How many questions are out. */
|
|
49
|
+
get inFlight() {
|
|
50
|
+
return this.pending.size;
|
|
51
|
+
}
|
|
52
|
+
/** A new graph: its edges go to the worker (a copy: the page keeps its own). */
|
|
53
|
+
setGraph(g) {
|
|
54
|
+
this.graph = g;
|
|
55
|
+
if (this.onPageThread)
|
|
56
|
+
this.local.setGraph(g);
|
|
57
|
+
else
|
|
58
|
+
this.send({ type: "graph", n: g.n, projectCount: g.projectCount, outStart: g.outStart, outDst: g.outDst, outSite: g.outSite });
|
|
59
|
+
}
|
|
60
|
+
/** A new tree (a layout, folds) or new toggles: every point held is let go, and answers to
|
|
61
|
+
* questions asked before are dropped when they come. On the page's thread the scene's own arrays
|
|
62
|
+
* are read; a worker is sent a copy. */
|
|
63
|
+
setBase(tree, toggles) {
|
|
64
|
+
this.base++;
|
|
65
|
+
this.tree = tree;
|
|
66
|
+
this.toggles = { ...toggles };
|
|
67
|
+
this.points.clear();
|
|
68
|
+
this.lo = -Infinity;
|
|
69
|
+
this.hi = Infinity;
|
|
70
|
+
if (this.onPageThread)
|
|
71
|
+
this.local.setTree(tree, toggles);
|
|
72
|
+
else
|
|
73
|
+
this.sendTree();
|
|
74
|
+
}
|
|
75
|
+
/** The zoom points still wanted, [lo, hi]: the worker drops questions for others, and an answer
|
|
76
|
+
* for another is dropped when it comes. */
|
|
77
|
+
near(lo, hi) {
|
|
78
|
+
if (lo === this.lo && hi === this.hi)
|
|
79
|
+
return;
|
|
80
|
+
this.lo = lo;
|
|
81
|
+
this.hi = hi;
|
|
82
|
+
if (this.worker)
|
|
83
|
+
this.send({ type: "near", base: this.base, lo, hi });
|
|
84
|
+
}
|
|
85
|
+
/** The latest question out for point `i` under the tree now, if any. */
|
|
86
|
+
asked(i) {
|
|
87
|
+
let best;
|
|
88
|
+
for (const p of this.pending.values())
|
|
89
|
+
if (p.base === this.base && p.ask.point === i && (!best || p.id > best.id))
|
|
90
|
+
best = p;
|
|
91
|
+
return best?.ask;
|
|
92
|
+
}
|
|
93
|
+
/** Ask for a point, with the positions `places` gives (read at once on the page's thread, copied
|
|
94
|
+
* for a worker). False when IN_FLIGHT questions are already out: ask again when one comes back. */
|
|
95
|
+
ask(a, places) {
|
|
96
|
+
const id = this.next++;
|
|
97
|
+
if (this.onPageThread) {
|
|
98
|
+
this.keep(id, a, this.local.choose(a, places()));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (this.pending.size >= IN_FLIGHT)
|
|
102
|
+
return false;
|
|
103
|
+
this.pending.set(id, { id, base: this.base, ask: a, at: performance.now() });
|
|
104
|
+
const P = places();
|
|
105
|
+
const copy = { cwx: P.cwx.slice(), cwy: P.cwy.slice(), nwx: P.nwx.slice(), nwy: P.nwy.slice(), curR: P.curR.slice(), drawnR: P.drawnR.slice() };
|
|
106
|
+
const { style: _style, ...ask } = a;
|
|
107
|
+
this.send({ type: "choose", id, base: this.base, places: copy, ...ask }, Object.values(copy).map((x) => x.buffer));
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
/** Let go of the points `keep` does not want. */
|
|
111
|
+
evict(keep) {
|
|
112
|
+
for (const i of [...this.points.keys()])
|
|
113
|
+
if (!keep(i))
|
|
114
|
+
this.points.delete(i);
|
|
115
|
+
}
|
|
116
|
+
/** A point's looks by edge key. */
|
|
117
|
+
indexOf(p) {
|
|
118
|
+
return (p.index ??= indexLooks(p.looks));
|
|
119
|
+
}
|
|
120
|
+
keep(id, a, looks) {
|
|
121
|
+
this.answered++;
|
|
122
|
+
this.points.set(a.point, { point: a.point, looks, index: null, x: a.x, y: a.y, dim: a.dim, style: a.style, id, serial: ++this.serial });
|
|
123
|
+
}
|
|
124
|
+
start() {
|
|
125
|
+
let w = null;
|
|
126
|
+
try {
|
|
127
|
+
w = this.spawn();
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
w = null;
|
|
131
|
+
}
|
|
132
|
+
if (!w) {
|
|
133
|
+
this.toPageThread();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.worker = w;
|
|
137
|
+
w.onmessage = (e) => {
|
|
138
|
+
if (this.worker === w)
|
|
139
|
+
this.answer(e.data);
|
|
140
|
+
};
|
|
141
|
+
// a worker that cannot run (its file not served): the page chooses itself, from now on
|
|
142
|
+
w.onerror = () => {
|
|
143
|
+
if (this.worker !== w)
|
|
144
|
+
return;
|
|
145
|
+
this.fail();
|
|
146
|
+
this.onReady();
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** The worker is given up: the page chooses itself from now on, and what was out is asked again,
|
|
150
|
+
* on the page's thread, at the next frame. */
|
|
151
|
+
fail() {
|
|
152
|
+
this.worker?.terminate();
|
|
153
|
+
this.worker = null;
|
|
154
|
+
this.toPageThread();
|
|
155
|
+
this.pending.clear();
|
|
156
|
+
}
|
|
157
|
+
toPageThread() {
|
|
158
|
+
this.local = new OverviewModel();
|
|
159
|
+
if (this.graph)
|
|
160
|
+
this.local.setGraph(this.graph);
|
|
161
|
+
if (this.tree)
|
|
162
|
+
this.local.setTree(this.tree, this.toggles);
|
|
163
|
+
}
|
|
164
|
+
answer(m) {
|
|
165
|
+
const p = this.pending.get(m.id);
|
|
166
|
+
if (!p)
|
|
167
|
+
return;
|
|
168
|
+
this.pending.delete(m.id);
|
|
169
|
+
if (m.type === "looks") {
|
|
170
|
+
this.lastMs = m.ms;
|
|
171
|
+
this.wait = 0.7 * this.wait + 0.3 * (performance.now() - p.at);
|
|
172
|
+
const held = this.points.get(p.ask.point);
|
|
173
|
+
// another tree's, no longer near the camera, or older than what is held: the latest wins
|
|
174
|
+
if (p.base !== this.base || p.ask.point < this.lo || p.ask.point > this.hi || (held && held.id > m.id))
|
|
175
|
+
this.dropped++;
|
|
176
|
+
else
|
|
177
|
+
this.keep(m.id, p.ask, m.looks);
|
|
178
|
+
}
|
|
179
|
+
else if (m.type === "dropped")
|
|
180
|
+
this.dropped++;
|
|
181
|
+
else if (m.type === "error") {
|
|
182
|
+
// the worker could not: the page chooses itself from now on, where what goes wrong shows
|
|
183
|
+
console.warn(`the overview's worker could not choose a point: ${m.error}`);
|
|
184
|
+
this.fail();
|
|
185
|
+
}
|
|
186
|
+
this.onReady();
|
|
187
|
+
}
|
|
188
|
+
sendTree() {
|
|
189
|
+
const T = this.tree;
|
|
190
|
+
this.send({
|
|
191
|
+
type: "tree",
|
|
192
|
+
base: this.base,
|
|
193
|
+
nc: T.nc,
|
|
194
|
+
n: T.n,
|
|
195
|
+
parent: T.parent,
|
|
196
|
+
nodeCluster: T.nodeCluster,
|
|
197
|
+
rep: T.rep,
|
|
198
|
+
externals: this.toggles.externals,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
send(m, transfer = []) {
|
|
202
|
+
this.worker.postMessage(m, transfer);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Small WebGL2 helpers: programs, instanced buffers, colours.
|
|
2
|
+
const colorCache = new Map();
|
|
3
|
+
let probe = null;
|
|
4
|
+
/** Any CSS colour → premultiplied-ready straight RGBA in 0..1, by letting the browser parse it; with
|
|
5
|
+
* no browser (the artwork command, ExportSpec decision 21), as `parseColour` reads it. */
|
|
6
|
+
export function rgba(css, alpha = 1) {
|
|
7
|
+
const key = css;
|
|
8
|
+
let c = colorCache.get(key);
|
|
9
|
+
if (!c) {
|
|
10
|
+
if (typeof document === "undefined")
|
|
11
|
+
c = parseColour(css);
|
|
12
|
+
else {
|
|
13
|
+
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
14
|
+
probe.clearRect(0, 0, 1, 1);
|
|
15
|
+
probe.fillStyle = "#000";
|
|
16
|
+
probe.fillStyle = css || "transparent";
|
|
17
|
+
probe.fillRect(0, 0, 1, 1);
|
|
18
|
+
const d = probe.getImageData(0, 0, 1, 1).data;
|
|
19
|
+
c = [d[0] / 255, d[1] / 255, d[2] / 255, d[3] / 255];
|
|
20
|
+
}
|
|
21
|
+
colorCache.set(key, c);
|
|
22
|
+
}
|
|
23
|
+
return [c[0], c[1], c[2], c[3] * alpha];
|
|
24
|
+
}
|
|
25
|
+
/** the named colours the page's colours use, and those a canvas takes for granted */
|
|
26
|
+
const NAMED = { transparent: "#0000", black: "#000", white: "#fff", red: "#f00", green: "#008000", blue: "#00f", gray: "#808080", grey: "#808080", orange: "#ffa500", gold: "#ffd700" };
|
|
27
|
+
/** A CSS colour read without a browser, as a canvas would draw it (in 0..1, its alpha straight, its
|
|
28
|
+
* channels rounded to bytes as a canvas keeps them): `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`,
|
|
29
|
+
* `rgb()`/`rgba()` (commas or spaces, numbers or percentages, `/ alpha`), `hsl()`/`hsla()` and a few
|
|
30
|
+
* names; anything else is transparent. */
|
|
31
|
+
export function parseColour(css) {
|
|
32
|
+
let s = (css || "").trim().toLowerCase();
|
|
33
|
+
s = NAMED[s] ?? s;
|
|
34
|
+
const byte = (v) => Math.round(Math.min(255, Math.max(0, v))) / 255;
|
|
35
|
+
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s);
|
|
36
|
+
if (hex) {
|
|
37
|
+
let h = hex[1];
|
|
38
|
+
if (h.length <= 4)
|
|
39
|
+
h = [...h].map((x) => x + x).join("");
|
|
40
|
+
const n = (i) => parseInt(h.slice(i, i + 2), 16);
|
|
41
|
+
return [n(0) / 255, n(2) / 255, n(4) / 255, h.length === 8 ? n(6) / 255 : 1];
|
|
42
|
+
}
|
|
43
|
+
const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(s);
|
|
44
|
+
if (!fn)
|
|
45
|
+
return [0, 0, 0, 0];
|
|
46
|
+
const parts = fn[2].replace(/\//g, " / ").split(/[\s,]+/).filter((x) => x && x !== "/");
|
|
47
|
+
const num = (x, scale) => (x === undefined ? NaN : x.endsWith("%") ? (parseFloat(x) / 100) * scale : parseFloat(x));
|
|
48
|
+
const a = parts.length > 3 ? Math.min(1, Math.max(0, num(parts[3], 1))) : 1;
|
|
49
|
+
if (fn[1].startsWith("rgb")) {
|
|
50
|
+
const [r, g, b] = [0, 1, 2].map((i) => num(parts[i], 255));
|
|
51
|
+
return [r, g, b].some(Number.isNaN) ? [0, 0, 0, 0] : [byte(r), byte(g), byte(b), Number.isNaN(a) ? 1 : Math.round(a * 255) / 255];
|
|
52
|
+
}
|
|
53
|
+
const h = (((parseFloat(parts[0]) % 360) + 360) % 360) / 360, sat = num(parts[1], 1), l = num(parts[2], 1);
|
|
54
|
+
if ([h, sat, l].some(Number.isNaN))
|
|
55
|
+
return [0, 0, 0, 0];
|
|
56
|
+
const q = l < 0.5 ? l * (1 + sat) : l + sat - l * sat, p = 2 * l - q;
|
|
57
|
+
const t = (x) => {
|
|
58
|
+
x = (x + 1) % 1;
|
|
59
|
+
return x < 1 / 6 ? p + (q - p) * 6 * x : x < 1 / 2 ? q : x < 2 / 3 ? p + (q - p) * (2 / 3 - x) * 6 : p;
|
|
60
|
+
};
|
|
61
|
+
return [byte(t(h + 1 / 3) * 255), byte(t(h) * 255), byte(t(h - 1 / 3) * 255), Number.isNaN(a) ? 1 : Math.round(a * 255) / 255];
|
|
62
|
+
}
|
|
63
|
+
export function program(gl, vs, fs) {
|
|
64
|
+
const make = (type, src) => {
|
|
65
|
+
const s = gl.createShader(type);
|
|
66
|
+
gl.shaderSource(s, src);
|
|
67
|
+
gl.compileShader(s);
|
|
68
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
|
|
69
|
+
throw new Error(gl.getShaderInfoLog(s) ?? "shader");
|
|
70
|
+
return s;
|
|
71
|
+
};
|
|
72
|
+
const p = gl.createProgram();
|
|
73
|
+
gl.attachShader(p, make(gl.VERTEX_SHADER, vs));
|
|
74
|
+
gl.attachShader(p, make(gl.FRAGMENT_SHADER, fs));
|
|
75
|
+
gl.linkProgram(p);
|
|
76
|
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
|
|
77
|
+
throw new Error(gl.getProgramInfoLog(p) ?? "link");
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
/** A growable float buffer of fixed-stride instances, uploaded on demand; with no context (a
|
|
81
|
+
* renderer kept to its data, ExportSpec decision 21), never uploaded. */
|
|
82
|
+
export class Instances {
|
|
83
|
+
data;
|
|
84
|
+
count = 0;
|
|
85
|
+
stride;
|
|
86
|
+
buffer;
|
|
87
|
+
gl;
|
|
88
|
+
uploaded = -1;
|
|
89
|
+
version = 0;
|
|
90
|
+
constructor(gl, stride, capacity = 1024) {
|
|
91
|
+
this.gl = gl;
|
|
92
|
+
this.stride = stride;
|
|
93
|
+
this.data = new Float32Array(stride * capacity);
|
|
94
|
+
this.buffer = gl ? gl.createBuffer() : null;
|
|
95
|
+
}
|
|
96
|
+
clear() {
|
|
97
|
+
this.count = 0;
|
|
98
|
+
this.version++;
|
|
99
|
+
}
|
|
100
|
+
/** Room for one more instance; returns its offset into `data`. */
|
|
101
|
+
push() {
|
|
102
|
+
if ((this.count + 1) * this.stride > this.data.length) {
|
|
103
|
+
const next = new Float32Array(this.data.length * 2);
|
|
104
|
+
next.set(this.data);
|
|
105
|
+
this.data = next;
|
|
106
|
+
}
|
|
107
|
+
this.version++;
|
|
108
|
+
return this.count++ * this.stride;
|
|
109
|
+
}
|
|
110
|
+
touch() {
|
|
111
|
+
this.version++;
|
|
112
|
+
}
|
|
113
|
+
upload() {
|
|
114
|
+
if (this.uploaded === this.version)
|
|
115
|
+
return;
|
|
116
|
+
const gl = this.gl;
|
|
117
|
+
if (!gl)
|
|
118
|
+
return;
|
|
119
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
|
|
120
|
+
gl.bufferData(gl.ARRAY_BUFFER, this.data.subarray(0, this.count * this.stride), gl.DYNAMIC_DRAW);
|
|
121
|
+
this.uploaded = this.version;
|
|
122
|
+
}
|
|
123
|
+
}
|