visualproof 0.2.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.
Files changed (59) hide show
  1. package/README.md +40 -26
  2. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  3. package/dist/assets/webfonts/metrics.json +1 -0
  4. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  5. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  6. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  7. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  8. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  9. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  10. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  11. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  12. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  13. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  14. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  15. package/dist/server/agent.js +84 -86
  16. package/dist/server/agentdocs.js +1 -1
  17. package/dist/server/artcmd.js +335 -0
  18. package/dist/server/live.js +6 -0
  19. package/dist/server/main.js +136 -8
  20. package/dist/server/mcptools.js +4 -1
  21. package/dist/server/pipeline.js +15 -9
  22. package/dist/server/readonlygate.js +1 -1
  23. package/dist/server/review.js +4 -1
  24. package/dist/server/reviewapi.js +35 -1
  25. package/dist/server/snapshot.js +10 -1
  26. package/dist/server/view.js +0 -7
  27. package/dist/server/views.js +24 -0
  28. package/dist/shared/artfonts.js +81 -0
  29. package/dist/shared/artrecipe.js +316 -0
  30. package/dist/shared/collab.js +7 -2
  31. package/dist/shared/declfind.js +95 -0
  32. package/dist/shared/lostfound.js +1 -0
  33. package/dist/shared/pastgraph.js +6 -6
  34. package/dist/shared/review.js +62 -5
  35. package/dist/web/bundle/app.js +713 -164
  36. package/dist/web/bundle/edges.js +1 -1
  37. package/dist/web/src/agentmark.js +14 -0
  38. package/dist/web/src/artwork.js +1000 -0
  39. package/dist/web/src/bundles.js +151 -0
  40. package/dist/web/src/canvas.js +3694 -0
  41. package/dist/web/src/drag.js +61 -0
  42. package/dist/web/src/edgequeue.js +204 -0
  43. package/dist/web/src/gl/gl.js +123 -0
  44. package/dist/web/src/gl/renderer.js +451 -0
  45. package/dist/web/src/halo.js +95 -0
  46. package/dist/web/src/highlight.js +108 -0
  47. package/dist/web/src/overview.js +393 -0
  48. package/dist/web/src/reviewqueue.js +250 -0
  49. package/dist/web/src/selection.js +272 -0
  50. package/dist/web/src/status.js +80 -0
  51. package/dist/web/src/visitors.js +318 -0
  52. package/dist/web/src/visits.js +174 -0
  53. package/dist/web/style.css +7 -0
  54. package/docs/agent-api/README.md +1 -1
  55. package/docs/agent-api/export.md +41 -2
  56. package/docs/agent-api/history.md +2 -1
  57. package/docs/agent-api/mcp.md +1 -1
  58. package/docs/agent-api/reading.md +2 -1
  59. package/package.json +3 -1
@@ -0,0 +1,451 @@
1
+ // The WebGL renderer: named layers of instanced circles and segments, drawn in order. Everything is
2
+ // stored in world coordinates; the camera is a uniform, so panning and zooming upload nothing.
3
+ import { Instances, program } from "./gl.js";
4
+ const CIRCLE_VS = `#version 300 es
5
+ layout(location=0) in vec2 a_corner;
6
+ layout(location=1) in vec4 a_geo; // x, y, r, unit (0 world, 1 px, 2 dot)
7
+ layout(location=2) in vec4 a_fill;
8
+ layout(location=3) in vec4 a_stroke;
9
+ layout(location=4) in vec4 a_extra; // stroke width px, flags, ring offset px, min radius px
10
+ uniform vec2 u_cam;
11
+ uniform float u_k;
12
+ uniform vec2 u_view;
13
+ uniform float u_dot;
14
+ out vec2 v_local;
15
+ out float v_r;
16
+ out vec4 v_fill;
17
+ out vec4 v_stroke;
18
+ out vec3 v_extra;
19
+ void main() {
20
+ float r = a_geo.w < 0.5 ? a_geo.z * u_k : (a_geo.w < 1.5 ? a_geo.z : a_geo.z * u_dot);
21
+ r = max(r, a_extra.w);
22
+ float ext = max(r, r + a_extra.z + a_extra.x * 0.5) + 2.0;
23
+ vec2 c = (a_geo.xy - u_cam) * u_k + u_view * 0.5;
24
+ vec2 p = c + a_corner * ext;
25
+ gl_Position = vec4(p.x / u_view.x * 2.0 - 1.0, 1.0 - p.y / u_view.y * 2.0, 0.0, 1.0);
26
+ v_local = a_corner * ext;
27
+ v_r = r;
28
+ v_fill = a_fill;
29
+ v_stroke = a_stroke;
30
+ v_extra = a_extra.xyz;
31
+ }`;
32
+ const CIRCLE_FS = `#version 300 es
33
+ precision highp float;
34
+ in vec2 v_local;
35
+ in float v_r;
36
+ in vec4 v_fill;
37
+ in vec4 v_stroke;
38
+ in vec3 v_extra;
39
+ uniform float u_aa;
40
+ out vec4 o;
41
+ void main() {
42
+ float d = length(v_local);
43
+ float w = max(fwidth(d), u_aa);
44
+ int flags = int(v_extra.y + 0.5);
45
+ float fa = (1.0 - smoothstep(v_r - w, v_r + w, d)) * v_fill.a;
46
+ // an artwork's looks (ExportSpec decision 19): a glow fading from the centre to nothing at the
47
+ // edge; or a fill fading from a light up and to the left to a quarter at the edge
48
+ if ((flags & 4) != 0) fa = pow(1.0 - clamp(d / max(v_r, 0.001), 0.0, 1.0), 2.0) * v_fill.a;
49
+ else if ((flags & 8) != 0) fa *= mix(1.0, 0.25, clamp(length(v_local / max(v_r, 1.0) - vec2(-0.25, -0.3)) / 1.3, 0.0, 1.0));
50
+ vec3 fill = v_fill.rgb;
51
+ if ((flags & 2) != 0) {
52
+ // a soft light from the top left, for folded clusters
53
+ float t = clamp(length(v_local / max(v_r, 1.0) - vec2(-0.4, -0.4)) / 1.5, 0.0, 1.0);
54
+ fill = mix(min(fill * 1.3 + 0.1, 1.0), fill * 0.82, t);
55
+ }
56
+ float sa = 0.0;
57
+ if (v_extra.x > 0.0) {
58
+ float rr = v_r + v_extra.z;
59
+ sa = (1.0 - smoothstep(v_extra.x * 0.5 - w, v_extra.x * 0.5 + w, abs(d - rr))) * v_stroke.a;
60
+ // a halo: the ring fading from full on its circle to nothing at its edges
61
+ if ((flags & 16) != 0) sa = pow(clamp(1.0 - abs(d - rr) / (v_extra.x * 0.5), 0.0, 1.0), 2.0) * v_stroke.a;
62
+ if ((flags & 1) != 0) {
63
+ float s = atan(v_local.y, v_local.x) * rr;
64
+ if (mod(s, 9.0) > 5.0) sa = 0.0;
65
+ }
66
+ }
67
+ vec4 col = vec4(v_stroke.rgb * sa, sa) + vec4(fill * fa, fa) * (1.0 - sa);
68
+ if (col.a < 0.002) discard;
69
+ o = col;
70
+ }`;
71
+ // Small markers (dots, rings) as point sprites: one vertex each instead of an instanced quad.
72
+ // Much cheaper where WebGL runs in software, and no worse on a GPU.
73
+ const POINT_VS = `#version 300 es
74
+ layout(location=1) in vec4 a_geo;
75
+ layout(location=2) in vec4 a_fill;
76
+ layout(location=3) in vec4 a_stroke;
77
+ layout(location=4) in vec4 a_extra;
78
+ uniform vec2 u_cam;
79
+ uniform float u_k;
80
+ uniform vec2 u_view;
81
+ uniform float u_dot;
82
+ uniform float u_dpr;
83
+ out float v_ext;
84
+ out float v_r;
85
+ out vec4 v_fill;
86
+ out vec4 v_stroke;
87
+ out vec3 v_extra;
88
+ void main() {
89
+ float r = a_geo.w < 0.5 ? a_geo.z * u_k : (a_geo.w < 1.5 ? a_geo.z : a_geo.z * u_dot);
90
+ r = max(r, a_extra.w);
91
+ float ext = max(r, r + a_extra.z + a_extra.x * 0.5) + 1.5;
92
+ vec2 c = (a_geo.xy - u_cam) * u_k + u_view * 0.5;
93
+ gl_Position = vec4(c.x / u_view.x * 2.0 - 1.0, 1.0 - c.y / u_view.y * 2.0, 0.0, 1.0);
94
+ gl_PointSize = 2.0 * ext * u_dpr;
95
+ v_ext = ext;
96
+ v_r = r;
97
+ v_fill = a_fill;
98
+ v_stroke = a_stroke;
99
+ v_extra = a_extra.xyz;
100
+ }`;
101
+ const POINT_FS = CIRCLE_FS.replace("in vec2 v_local;", "in float v_ext;").replace("float d = length(v_local);", "vec2 v_local = (gl_PointCoord * 2.0 - 1.0) * v_ext;\n float d = length(v_local);");
102
+ // Bundled edges (Holten 2006). An edge is a uniform cubic B-spline through the world positions of
103
+ // the clusters on its tree path, straightened by beta. Positions come from a texture the canvas
104
+ // refreshes when things move, so the curves follow motion without being rebuilt. Each edge is one
105
+ // instance drawn as a triangle strip of 2 (SEGS + 1) vertices: every vertex evaluates the spline
106
+ // once, from the four control points of its span, and takes its normal from the derivative. An edge
107
+ // through eight clusters has nine spans: at 14 pieces it showed its corners, at 48 it reads as the
108
+ // curve it is (the owner's request, 2026-10-10). The cost is vertices alone — about 150,000 a frame
109
+ // for the 1,500 edges a board draws — and a frame took no longer, even rendered without a GPU.
110
+ export const BUNDLE_SEGS = 48;
111
+ const BUNDLE_VS = `#version 300 es
112
+ layout(location=1) in vec4 a_i0; // control point indices 0..3 (positions texture)
113
+ layout(location=2) in vec4 a_i1; // control point indices 4..7
114
+ layout(location=3) in vec4 a_meta; // count, beta, width px, -
115
+ layout(location=4) in vec4 a_c0; // colour at the source
116
+ layout(location=5) in vec4 a_c1; // colour at the target
117
+ uniform vec2 u_cam;
118
+ uniform float u_k;
119
+ uniform vec2 u_view;
120
+ uniform highp sampler2D u_pos;
121
+ uniform int u_segs;
122
+ out float v_across;
123
+ out float v_hw;
124
+ out vec4 v_color;
125
+ vec2 P(int j) {
126
+ int i = int((j < 4 ? a_i0[j] : a_i1[j - 4]) + 0.5);
127
+ return texelFetch(u_pos, ivec2(i % 1024, i / 1024), 0).xy;
128
+ }
129
+ void main() {
130
+ int n = int(a_meta.x + 0.5);
131
+ float beta = a_meta.y;
132
+ int k = gl_VertexID / 2;
133
+ float side = float(gl_VertexID % 2) * 2.0 - 1.0;
134
+ float t = float(k) / float(u_segs);
135
+ vec2 a = P(0), b = P(n - 1);
136
+ // clamped uniform cubic B-spline over the control points straightened towards the chord
137
+ float x = t * float(n + 1);
138
+ int s = min(int(x), n);
139
+ float f = x - float(s);
140
+ vec2 q0, q1, q2, q3;
141
+ float den = float(max(n - 1, 1));
142
+ int j0 = clamp(s - 2, 0, n - 1), j1 = clamp(s - 1, 0, n - 1), j2 = clamp(s, 0, n - 1), j3 = clamp(s + 1, 0, n - 1);
143
+ q0 = mix(a + (b - a) * (float(j0) / den), P(j0), beta);
144
+ q1 = mix(a + (b - a) * (float(j1) / den), P(j1), beta);
145
+ q2 = mix(a + (b - a) * (float(j2) / den), P(j2), beta);
146
+ q3 = mix(a + (b - a) * (float(j3) / den), P(j3), beta);
147
+ float f2 = f * f, f3 = f2 * f, g = 1.0 - f;
148
+ vec2 w = q0 * (g * g * g / 6.0) + q1 * ((4.0 - 6.0 * f2 + 3.0 * f3) / 6.0)
149
+ + q2 * ((1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0) + q3 * (f3 / 6.0);
150
+ vec2 tg = q0 * (-0.5 * g * g) + q1 * (0.5 * (3.0 * f2 - 4.0 * f))
151
+ + q2 * (0.5 * (-3.0 * f2 + 2.0 * f + 1.0)) + q3 * (0.5 * f2);
152
+ // the ends are clamped (repeated control points), where the derivative vanishes
153
+ if (dot(tg, tg) < 1e-12) tg = q3 - q0;
154
+ if (dot(tg, tg) < 1e-12) tg = b - a;
155
+ if (dot(tg, tg) < 1e-12) tg = vec2(1.0, 0.0);
156
+ vec2 nrm = normalize(vec2(-tg.y, tg.x));
157
+ float hw = a_meta.z * 0.5 + 1.0;
158
+ vec2 p = (w - u_cam) * u_k + u_view * 0.5 + nrm * side * hw;
159
+ gl_Position = vec4(p.x / u_view.x * 2.0 - 1.0, 1.0 - p.y / u_view.y * 2.0, 0.0, 1.0);
160
+ v_across = side * hw;
161
+ v_hw = a_meta.z * 0.5;
162
+ v_color = mix(a_c0, a_c1, t);
163
+ }`;
164
+ const LINE_VS = `#version 300 es
165
+ layout(location=0) in vec2 a_corner; // along in {0,1}, across in {-1,1}
166
+ layout(location=1) in vec4 a_seg; // x0, y0, x1, y1
167
+ layout(location=2) in vec4 a_color;
168
+ layout(location=3) in vec4 a_extra; // width px
169
+ uniform vec2 u_cam;
170
+ uniform float u_k;
171
+ uniform vec2 u_view;
172
+ out float v_across;
173
+ out float v_hw;
174
+ out vec4 v_color;
175
+ void main() {
176
+ vec2 p0 = (a_seg.xy - u_cam) * u_k + u_view * 0.5;
177
+ vec2 p1 = (a_seg.zw - u_cam) * u_k + u_view * 0.5;
178
+ vec2 d = p1 - p0;
179
+ float len = max(length(d), 1e-4);
180
+ vec2 n = vec2(-d.y, d.x) / len;
181
+ float hw = a_extra.x * 0.5 + 1.0;
182
+ vec2 p = mix(p0, p1, a_corner.x) + n * a_corner.y * hw;
183
+ gl_Position = vec4(p.x / u_view.x * 2.0 - 1.0, 1.0 - p.y / u_view.y * 2.0, 0.0, 1.0);
184
+ v_across = a_corner.y * hw;
185
+ v_hw = a_extra.x * 0.5;
186
+ v_color = a_color;
187
+ }`;
188
+ const LINE_FS = `#version 300 es
189
+ precision highp float;
190
+ in float v_across;
191
+ in float v_hw;
192
+ in vec4 v_color;
193
+ uniform float u_aa;
194
+ out vec4 o;
195
+ void main() {
196
+ float w = max(fwidth(v_across), u_aa);
197
+ float a = (1.0 - smoothstep(v_hw - w, v_hw + w, abs(v_across))) * v_color.a;
198
+ if (a < 0.002) discard;
199
+ o = vec4(v_color.rgb * a, a);
200
+ }`;
201
+ /** Floats per circle instance: geo(4) fill(4) stroke(4) extra(4). */
202
+ export const CIRCLE_STRIDE = 16;
203
+ /** Floats per segment instance: seg(4) color(4) extra(4). */
204
+ export const LINE_STRIDE = 12;
205
+ /** Floats per bundled edge: indices(8) meta(4) colours(8). */
206
+ export const BUNDLE_STRIDE = 20;
207
+ export const UNIT_WORLD = 0, UNIT_PX = 1, UNIT_DOT = 2;
208
+ export const FLAG_DASHED = 1, FLAG_SHADED = 2;
209
+ /** an artwork's looks (ExportSpec decision 19): a glow, its fill fading from the centre to nothing; a
210
+ * fill fading from a light at the top left to a quarter at the edge; a ring fading to its edges */
211
+ export const FLAG_GLOW = 4, FLAG_RADIAL = 8, FLAG_HALO = 16;
212
+ /** Write one bundled edge: up to 8 control point indices into the positions texture. */
213
+ export function bundle(L, path, beta, widthPx, c0, c1) {
214
+ bundlePath(L, path, 0, path.length, beta, widthPx, c0, c1);
215
+ }
216
+ /** A bundled edge whose `len` control points are `paths[off ..]` (the overview's packed looks,
217
+ * web/src/overview.ts). */
218
+ export function bundlePath(L, paths, off, len, beta, widthPx, c0, c1) {
219
+ const o = L.push();
220
+ const d = L.data;
221
+ for (let k = 0; k < 8; k++)
222
+ d[o + k] = paths[off + Math.min(k, len - 1)];
223
+ d[o + 8] = len;
224
+ d[o + 9] = beta;
225
+ d[o + 10] = widthPx;
226
+ d[o + 11] = 0;
227
+ d[o + 12] = c0[0];
228
+ d[o + 13] = c0[1];
229
+ d[o + 14] = c0[2];
230
+ d[o + 15] = c0[3];
231
+ d[o + 16] = c1[0];
232
+ d[o + 17] = c1[1];
233
+ d[o + 18] = c1[2];
234
+ d[o + 19] = c1[3];
235
+ }
236
+ /** The most pixels a side has where nothing is drawn on a graphics card: an artwork's largest side
237
+ * (shared/collab.ts, ART_MAX_SIDE) */
238
+ const NO_CARD_SIDE = 16384;
239
+ /** The board's drawing on the graphics card; or, given no canvas (`new Renderer(null)`: the artwork
240
+ * command, ExportSpec decision 21), kept to its data — the layers' instances, which an artwork's SVG
241
+ * reads (`drawn`) — with nothing drawn or uploaded. */
242
+ export class Renderer {
243
+ gl;
244
+ circleProg;
245
+ pointProg;
246
+ bundleProg;
247
+ posTex;
248
+ posRows = 0;
249
+ dpr = 1;
250
+ lineProg;
251
+ layers = new Map();
252
+ order = [];
253
+ circleCorners;
254
+ lineCorners;
255
+ constructor(canvas) {
256
+ if (!canvas) {
257
+ this.gl = null;
258
+ return;
259
+ }
260
+ const gl = canvas.getContext("webgl2", { antialias: false, premultipliedAlpha: true, alpha: true });
261
+ if (!gl)
262
+ throw new Error("WebGL2 is not available in this browser");
263
+ this.gl = gl;
264
+ this.circleProg = program(gl, CIRCLE_VS, CIRCLE_FS);
265
+ this.pointProg = program(gl, POINT_VS, POINT_FS);
266
+ this.bundleProg = program(gl, BUNDLE_VS, LINE_FS);
267
+ this.posTex = gl.createTexture();
268
+ this.lineProg = program(gl, LINE_VS, LINE_FS);
269
+ this.circleCorners = gl.createBuffer();
270
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.circleCorners);
271
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
272
+ this.lineCorners = gl.createBuffer();
273
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.lineCorners);
274
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, -1, 1, -1, 0, 1, 1, 1]), gl.STATIC_DRAW);
275
+ }
276
+ /** A layer, created on first use; layers draw in the order they were first asked for. */
277
+ layer(name, kind) {
278
+ const hit = this.layers.get(name);
279
+ if (hit)
280
+ return hit.inst;
281
+ const gl = this.gl;
282
+ const stride = kind === "lines" ? LINE_STRIDE : kind === "bundles" ? BUNDLE_STRIDE : CIRCLE_STRIDE;
283
+ const inst = new Instances(gl, stride);
284
+ if (!gl) {
285
+ this.layers.set(name, { kind, inst, vao: null, visible: true });
286
+ this.order.push(name);
287
+ return inst;
288
+ }
289
+ const vao = gl.createVertexArray();
290
+ gl.bindVertexArray(vao);
291
+ if (kind === "circles" || kind === "lines") {
292
+ gl.bindBuffer(gl.ARRAY_BUFFER, kind === "lines" ? this.lineCorners : this.circleCorners);
293
+ gl.enableVertexAttribArray(0);
294
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
295
+ }
296
+ gl.bindBuffer(gl.ARRAY_BUFFER, inst.buffer);
297
+ const groups = kind === "lines" ? 3 : kind === "bundles" ? 5 : 4;
298
+ for (let a = 0; a < groups; a++) {
299
+ gl.enableVertexAttribArray(1 + a);
300
+ gl.vertexAttribPointer(1 + a, 4, gl.FLOAT, false, stride * 4, a * 16);
301
+ // points: one vertex per marker; quads and bundled edges: one instance each
302
+ gl.vertexAttribDivisor(1 + a, kind === "points" ? 0 : 1);
303
+ }
304
+ gl.bindVertexArray(null);
305
+ this.layers.set(name, { kind, inst, vao, visible: true });
306
+ this.order.push(name);
307
+ return inst;
308
+ }
309
+ /** World positions the bundled edges read: (x, y) pairs, 1024 per texture row. */
310
+ setPositions(xy) {
311
+ const gl = this.gl;
312
+ if (!gl)
313
+ return;
314
+ const count = xy.length / 2;
315
+ const rows = Math.max(1, Math.ceil(count / 1024));
316
+ gl.bindTexture(gl.TEXTURE_2D, this.posTex);
317
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
318
+ const padded = new Float32Array(rows * 1024 * 2);
319
+ padded.set(xy);
320
+ if (rows !== this.posRows) {
321
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG32F, 1024, rows, 0, gl.RG, gl.FLOAT, padded);
322
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
323
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
324
+ this.posRows = rows;
325
+ }
326
+ else {
327
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 1024, rows, gl.RG, gl.FLOAT, padded);
328
+ }
329
+ }
330
+ uniforms = new Map();
331
+ uniform(prog, name) {
332
+ let m = this.uniforms.get(prog);
333
+ if (!m)
334
+ this.uniforms.set(prog, (m = new Map()));
335
+ if (!m.has(name))
336
+ m.set(name, this.gl.getUniformLocation(prog, name));
337
+ return m.get(name);
338
+ }
339
+ /** For diagnosis: hide or show a layer. */
340
+ setVisible(name, on) {
341
+ const L = this.layers.get(name);
342
+ if (L)
343
+ L.visible = on;
344
+ }
345
+ /** The layers drawn, in their order: what an artwork's SVG writes as vectors (ExportSpec decision
346
+ * 19, web/src/artwork.ts). */
347
+ drawn() {
348
+ return this.order.flatMap((name) => {
349
+ const L = this.layers.get(name);
350
+ return L.visible && L.inst.count ? [{ name, kind: L.kind, data: L.inst.data, count: L.inst.count, stride: L.inst.stride }] : [];
351
+ });
352
+ }
353
+ /** The most pixels a side of what this context draws at once can have. */
354
+ maxSide() {
355
+ const gl = this.gl;
356
+ if (!gl)
357
+ return NO_CARD_SIDE;
358
+ const dims = gl.getParameter(gl.MAX_VIEWPORT_DIMS);
359
+ return Math.min(dims[0], dims[1], gl.getParameter(gl.MAX_RENDERBUFFER_SIZE));
360
+ }
361
+ /** Draw every layer through camera `cam`, on `w` × `h` CSS pixels of `dpr` pixels each. `aa`: the
362
+ * least width an edge's soft rim has, in CSS pixels — a picture drawn at more pixels than the
363
+ * screen's (an artwork's PNG) keeps it as wide in pixels as the screen does. */
364
+ render(cam, w, h, dpr, dotPx, aa = 0.35) {
365
+ const gl = this.gl;
366
+ if (!gl)
367
+ return;
368
+ gl.viewport(0, 0, Math.round(w * dpr), Math.round(h * dpr));
369
+ gl.clearColor(0, 0, 0, 0);
370
+ gl.clear(gl.COLOR_BUFFER_BIT);
371
+ gl.enable(gl.BLEND);
372
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
373
+ for (const name of this.order) {
374
+ const L = this.layers.get(name);
375
+ if (!L.visible || L.inst.count === 0)
376
+ continue;
377
+ L.inst.upload();
378
+ const prog = L.kind === "circles" ? this.circleProg
379
+ : L.kind === "points" ? this.pointProg
380
+ : L.kind === "bundles" ? this.bundleProg
381
+ : this.lineProg;
382
+ gl.useProgram(prog);
383
+ const u = (name) => this.uniform(prog, name);
384
+ gl.uniform2f(u("u_cam"), cam.x, cam.y);
385
+ gl.uniform1f(u("u_k"), cam.k);
386
+ gl.uniform2f(u("u_view"), w, h);
387
+ gl.uniform1f(u("u_aa"), aa);
388
+ if (L.kind === "circles" || L.kind === "points")
389
+ gl.uniform1f(u("u_dot"), dotPx);
390
+ if (L.kind === "points")
391
+ gl.uniform1f(u("u_dpr"), dpr);
392
+ gl.bindVertexArray(L.vao);
393
+ if (L.kind === "bundles") {
394
+ gl.activeTexture(gl.TEXTURE1);
395
+ gl.bindTexture(gl.TEXTURE_2D, this.posTex);
396
+ gl.uniform1i(u("u_pos"), 1);
397
+ gl.uniform1i(u("u_segs"), BUNDLE_SEGS);
398
+ }
399
+ if (L.kind === "points")
400
+ gl.drawArrays(gl.POINTS, 0, L.inst.count);
401
+ else if (L.kind === "bundles")
402
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 2 * (BUNDLE_SEGS + 1), L.inst.count);
403
+ else
404
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, L.inst.count);
405
+ }
406
+ gl.bindVertexArray(null);
407
+ }
408
+ }
409
+ /** Write one circle instance. */
410
+ export function circle(L, x, y, r, unit, fill, stroke = null, strokePx = 0, flags = 0, ringOffset = 0, minPx = 0) {
411
+ const o = L.push();
412
+ const d = L.data;
413
+ d[o] = x;
414
+ d[o + 1] = y;
415
+ d[o + 2] = r;
416
+ d[o + 3] = unit;
417
+ d[o + 4] = fill[0];
418
+ d[o + 5] = fill[1];
419
+ d[o + 6] = fill[2];
420
+ d[o + 7] = fill[3];
421
+ if (stroke) {
422
+ d[o + 8] = stroke[0];
423
+ d[o + 9] = stroke[1];
424
+ d[o + 10] = stroke[2];
425
+ d[o + 11] = stroke[3];
426
+ }
427
+ else {
428
+ d[o + 8] = d[o + 9] = d[o + 10] = d[o + 11] = 0;
429
+ }
430
+ d[o + 12] = strokePx;
431
+ d[o + 13] = flags;
432
+ d[o + 14] = ringOffset;
433
+ d[o + 15] = minPx;
434
+ }
435
+ /** Write one segment instance. */
436
+ export function segment(L, x0, y0, x1, y1, color, widthPx) {
437
+ const o = L.push();
438
+ const d = L.data;
439
+ d[o] = x0;
440
+ d[o + 1] = y0;
441
+ d[o + 2] = x1;
442
+ d[o + 3] = y1;
443
+ d[o + 4] = color[0];
444
+ d[o + 5] = color[1];
445
+ d[o + 6] = color[2];
446
+ d[o + 7] = color[3];
447
+ d[o + 8] = widthPx;
448
+ d[o + 9] = 0;
449
+ d[o + 10] = 0;
450
+ d[o + 11] = 0;
451
+ }
@@ -0,0 +1,95 @@
1
+ // Names over everything else, each on a halo (the owner's decision of 2026-10-15): strokes of the
2
+ // background round its letters, each narrower and stronger than the last, so that the halo fades
3
+ // outward and what lies under a name — an edge, a point, a ring — never crosses it. The board writes
4
+ // its names so (web/src/canvas.ts), and an artwork's foot and title (web/src/artwork.ts). An
5
+ // artwork's cluster names may run along the top of their circle (`arc`), letter by letter.
6
+ /** The fonts an artwork writes in (assets/webfonts/, web/style.css): its names' and its foot's,
7
+ * and the starfield's. */
8
+ export const ART_SANS = `"Source Sans 3", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
9
+ export const ART_SERIF = `"Source Serif 4", Georgia, "Times New Roman", serif`;
10
+ /** an artwork's title and its top-level clusters' names, in spaced capitals, in every style (the
11
+ * owner's decisions of 2026-10-15) */
12
+ export const ART_DISPLAY = `"Jost", "Source Sans 3", -apple-system, "Segoe UI", sans-serif`;
13
+ /** The halo's strokes, outermost first: width in pixels, opacity. */
14
+ export const HALO = [
15
+ [7, 0.22],
16
+ [4.5, 0.4],
17
+ [2.6, 0.85],
18
+ ];
19
+ /** How wide `text` is in the context's font, with `spacing` pixels between its letters. */
20
+ export function spacedWidth(ctx, text, spacing = 0) {
21
+ if (!spacing)
22
+ return ctx.measureText(text).width;
23
+ let w = 0;
24
+ for (const ch of text)
25
+ w += ctx.measureText(ch).width + spacing;
26
+ return w - spacing;
27
+ }
28
+ /** How far round its circle a name along an arc reaches each way from the top, in radians. */
29
+ export function arcHalf(ctx, n) {
30
+ ctx.font = n.font;
31
+ return spacedWidth(ctx, n.text, n.spacing ?? 0) / n.arc.r / 2;
32
+ }
33
+ /** Write `names` with `ctx`, each on its halo of `colour`: the halos of all of them first, then the
34
+ * names, so that no halo covers another name. */
35
+ export function writeNames(ctx, names, colour) {
36
+ if (!names.length)
37
+ return;
38
+ ctx.save();
39
+ ctx.lineJoin = "round";
40
+ ctx.textBaseline = "alphabetic";
41
+ ctx.strokeStyle = colour;
42
+ let font = "", align = "";
43
+ const set = (n) => {
44
+ if (n.font !== font)
45
+ ctx.font = font = n.font;
46
+ if (n.align !== align)
47
+ ctx.textAlign = (align = n.align);
48
+ };
49
+ const one = (n, op) => {
50
+ set(n);
51
+ if (n.arc)
52
+ return along(ctx, n, op);
53
+ if (!n.spacing)
54
+ return op(n.text, n.x, n.y);
55
+ // letter by letter, from where the alignment starts it
56
+ const w = spacedWidth(ctx, n.text, n.spacing);
57
+ let x = n.align === "center" ? n.x - w / 2 : n.align === "right" || n.align === "end" ? n.x - w : n.x;
58
+ ctx.textAlign = "left";
59
+ for (const ch of n.text) {
60
+ op(ch, x, n.y);
61
+ x += ctx.measureText(ch).width + n.spacing;
62
+ }
63
+ ctx.textAlign = n.align;
64
+ };
65
+ for (const [width, alpha] of HALO) {
66
+ ctx.lineWidth = width;
67
+ ctx.globalAlpha = alpha;
68
+ for (const n of names)
69
+ one(n, (s, x, y) => ctx.strokeText(s, x, y));
70
+ }
71
+ ctx.globalAlpha = 1;
72
+ for (const n of names) {
73
+ ctx.fillStyle = n.fill;
74
+ one(n, (s, x, y) => ctx.fillText(s, x, y));
75
+ }
76
+ ctx.restore();
77
+ }
78
+ /** A name along the top of its circle, centred at the top, letter by letter, each upright to it. */
79
+ function along(ctx, n, op) {
80
+ const { cx, cy, r } = n.arc;
81
+ const sp = n.spacing ?? 0;
82
+ let a = -Math.PI / 2 - arcHalf(ctx, n);
83
+ ctx.textAlign = "center";
84
+ for (const ch of n.text) {
85
+ const w = ctx.measureText(ch).width;
86
+ const mid = a + w / 2 / r;
87
+ ctx.save();
88
+ ctx.translate(cx + r * Math.cos(mid), cy + r * Math.sin(mid));
89
+ ctx.rotate(mid + Math.PI / 2);
90
+ op(ch, 0, 0);
91
+ ctx.restore();
92
+ a += (w + sp) / r;
93
+ }
94
+ ctx.textAlign = n.align;
95
+ }
@@ -0,0 +1,108 @@
1
+ // The page's rendering of Lean source (the source and signature panes, hover cards, docs): the
2
+ // tokens of shared/lexer.ts as HTML, with the names of the graph's declarations as links. The
3
+ // exported document renders the same tokens as LaTeX (shared/doc/latex.ts).
4
+ import { tokenizeLean, tokenLines, wordKind } from "../../shared/lexer.js";
5
+ export { KEYWORDS } from "../../shared/lexer.js";
6
+ export function escapeHtml(s) {
7
+ return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
8
+ }
9
+ /** `isRef`: names of declarations in the graph, given with their offset in `src`; they become
10
+ * links (`data-ref`) to them — to the name it returns when it returns one (a short name resolved
11
+ * to its full one). */
12
+ export function highlightLean(src, isRef) {
13
+ return tokenizeLean(src)
14
+ .map((t) => tokenHtml(t, isRef))
15
+ .join("");
16
+ }
17
+ /** One token as HTML: a word a link where `isRef` says, the others by their kind. */
18
+ function tokenHtml(t, isRef) {
19
+ const span = (cls, text) => `<span class="tk-${cls}">${escapeHtml(text)}</span>`;
20
+ const link = (word, to) => `<span class="tk-ref${/^[A-Z]/.test(word) ? " tk-type" : ""}" data-ref="${escapeHtml(to)}">${escapeHtml(word)}</span>`;
21
+ const plain = (word) => {
22
+ const k = wordKind(word);
23
+ return k === "ident" ? escapeHtml(word) : span(k, word);
24
+ };
25
+ switch (t.kind) {
26
+ case "text":
27
+ return escapeHtml(t.text);
28
+ case "keyword":
29
+ case "type":
30
+ case "ident":
31
+ case "sorry": {
32
+ const word = t.text;
33
+ const ref = isRef?.(word, t.at);
34
+ const parts = word.split(".");
35
+ if (Array.isArray(ref) && ref.length === parts.length)
36
+ return parts.map((p, k) => (ref[k] ? link(p, ref[k]) : plain(p))).join(".");
37
+ if (ref && !Array.isArray(ref))
38
+ return link(word, typeof ref === "string" ? ref : word);
39
+ return plain(word);
40
+ }
41
+ default:
42
+ return span(t.kind, t.text);
43
+ }
44
+ }
45
+ /** the most indentation a wrapped line's continuations keep: a deeply indented line in a narrow
46
+ * window keeps room for its text */
47
+ const HANG_MAX = 24;
48
+ /** Lean highlighted as `highlightLean` does, each line in an element of its own (`.ln`) that carries
49
+ * its indentation (`--in`, in characters, at most HANG_MAX), so that a line wrapped to the window's
50
+ * width hangs its continuations a few characters further in (web/style.css). The text is the
51
+ * source's to the character, its line breaks included — each at the end of its line's element —
52
+ * so that offsets in it are offsets in the source (web/src/sourcewin.ts, positionAt) and copying
53
+ * it copies the source. A token across lines (a comment) is cut into one piece per line. */
54
+ export function highlightLines(src, isRef) {
55
+ const lines = tokenLines(tokenizeLean(src));
56
+ return src
57
+ .split("\n")
58
+ .map((text, k) => {
59
+ const indent = Math.min(/^ */.exec(text)[0].length, HANG_MAX);
60
+ const html = (lines[k] ?? []).map((t) => tokenHtml(t, isRef)).join("");
61
+ return `<span class="ln" style="--in:${indent}">${html}${k < lines.length - 1 ? "\n" : ""}</span>`;
62
+ })
63
+ .join("");
64
+ }
65
+ /** Docstrings are Markdown; this renders the part that matters in a side panel: paragraphs,
66
+ * code spans and blocks, emphasis. Everything is escaped first. */
67
+ /** Lean's hover text (markdown): code blocks highlighted, `***` as rules, the rest as a doc. */
68
+ export function renderHover(md, isRef) {
69
+ let html = "";
70
+ let at = 0;
71
+ const fence = /```[^\n]*\n([\s\S]*?)```/g;
72
+ const prose = (t) => {
73
+ for (const part of t.split(/^\s*\*\*\*\s*$/m)) {
74
+ if (part.trim())
75
+ html += renderDoc(part);
76
+ html += "<hr>";
77
+ }
78
+ html = html.replace(/(<hr>)+$/, "");
79
+ };
80
+ for (let m = fence.exec(md); m; m = fence.exec(md)) {
81
+ prose(md.slice(at, m.index));
82
+ html += `<pre class="code">${highlightLean(m[1].replace(/\n$/, ""), isRef)}</pre>`;
83
+ at = m.index + m[0].length;
84
+ }
85
+ prose(md.slice(at));
86
+ return html.replace(/^(<hr>)+/, "");
87
+ }
88
+ export function renderDoc(md) {
89
+ const blocks = md.trim().split(/\n```/);
90
+ let html = "";
91
+ blocks.forEach((b, k) => {
92
+ if (k % 2 === 1) {
93
+ const nl = b.indexOf("\n");
94
+ html += `<pre class="code">${highlightLean(b.slice(nl + 1))}</pre>`;
95
+ return;
96
+ }
97
+ for (const para of b.split(/\n\s*\n/)) {
98
+ if (!para.trim())
99
+ continue;
100
+ let t = escapeHtml(para.trim());
101
+ t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
102
+ t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
103
+ t = t.replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1<em>$2</em>");
104
+ html += `<p>${t}</p>`;
105
+ }
106
+ });
107
+ return html;
108
+ }