visualproof 0.0.0-stage → 0.2.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 (146) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  9. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  10. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  11. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  12. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  21. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  22. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  23. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  24. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  25. package/dist/extractor/Extract.lean +1180 -0
  26. package/dist/extractor/Usage.lean +900 -0
  27. package/dist/mcp/visualproof-mcp.mjs +476 -0
  28. package/dist/server/access.js +95 -0
  29. package/dist/server/agent.js +3270 -0
  30. package/dist/server/agentdocs.js +35 -0
  31. package/dist/server/agentlibrary.js +202 -0
  32. package/dist/server/agentnotes.js +701 -0
  33. package/dist/server/agentviews.js +703 -0
  34. package/dist/server/askfollow.js +286 -0
  35. package/dist/server/boardspots.js +159 -0
  36. package/dist/server/dirs.js +175 -0
  37. package/dist/server/doctor.js +186 -0
  38. package/dist/server/events.js +198 -0
  39. package/dist/server/export.js +420 -0
  40. package/dist/server/extract.js +204 -0
  41. package/dist/server/fetchurl.js +321 -0
  42. package/dist/server/figures.js +158 -0
  43. package/dist/server/history.js +780 -0
  44. package/dist/server/historyapi.js +304 -0
  45. package/dist/server/hover.js +595 -0
  46. package/dist/server/identity.js +114 -0
  47. package/dist/server/invites.js +241 -0
  48. package/dist/server/layoutthread.js +51 -0
  49. package/dist/server/library.js +600 -0
  50. package/dist/server/libraryroute.js +211 -0
  51. package/dist/server/live.js +1101 -0
  52. package/dist/server/lostfound.js +199 -0
  53. package/dist/server/main.js +1406 -0
  54. package/dist/server/mcptools.js +496 -0
  55. package/dist/server/pastdoc.js +73 -0
  56. package/dist/server/pipeline.js +159 -0
  57. package/dist/server/poppler.js +251 -0
  58. package/dist/server/presence.js +252 -0
  59. package/dist/server/project.js +222 -0
  60. package/dist/server/prompt.js +53 -0
  61. package/dist/server/readonly.js +152 -0
  62. package/dist/server/readonlygate.js +123 -0
  63. package/dist/server/renames.js +268 -0
  64. package/dist/server/review.js +334 -0
  65. package/dist/server/reviewapi.js +188 -0
  66. package/dist/server/snapshot.js +50 -0
  67. package/dist/server/sourcestore.js +473 -0
  68. package/dist/server/status.js +360 -0
  69. package/dist/server/talk.js +476 -0
  70. package/dist/server/tokens.js +221 -0
  71. package/dist/server/usage.js +501 -0
  72. package/dist/server/versions.js +82 -0
  73. package/dist/server/view.js +164 -0
  74. package/dist/server/views.js +1167 -0
  75. package/dist/shared/askfollow.js +46 -0
  76. package/dist/shared/board.js +332 -0
  77. package/dist/shared/build.js +2 -0
  78. package/dist/shared/collab.js +241 -0
  79. package/dist/shared/comment.js +285 -0
  80. package/dist/shared/context.js +157 -0
  81. package/dist/shared/doc/authors.js +98 -0
  82. package/dist/shared/doc/build.js +358 -0
  83. package/dist/shared/doc/latex.js +455 -0
  84. package/dist/shared/doc/markdown.js +159 -0
  85. package/dist/shared/doc/model.js +37 -0
  86. package/dist/shared/doc/order.js +124 -0
  87. package/dist/shared/doc/outline.js +45 -0
  88. package/dist/shared/doc/proof.js +272 -0
  89. package/dist/shared/doc/sections.js +93 -0
  90. package/dist/shared/doc/settings.js +75 -0
  91. package/dist/shared/edgecodec.js +102 -0
  92. package/dist/shared/editor.js +64 -0
  93. package/dist/shared/excerpt.js +87 -0
  94. package/dist/shared/graph.js +16 -0
  95. package/dist/shared/items.js +92 -0
  96. package/dist/shared/keys.js +203 -0
  97. package/dist/shared/layout.js +926 -0
  98. package/dist/shared/lexer.js +130 -0
  99. package/dist/shared/library.js +711 -0
  100. package/dist/shared/lostfound.js +150 -0
  101. package/dist/shared/moves.js +244 -0
  102. package/dist/shared/notecard.js +295 -0
  103. package/dist/shared/noteplace.js +505 -0
  104. package/dist/shared/palette.js +46 -0
  105. package/dist/shared/pastgraph.js +103 -0
  106. package/dist/shared/places.js +80 -0
  107. package/dist/shared/projectnotes.js +244 -0
  108. package/dist/shared/query.js +818 -0
  109. package/dist/shared/review.js +642 -0
  110. package/dist/shared/reviewqueue.js +333 -0
  111. package/dist/shared/ring.js +366 -0
  112. package/dist/shared/scope.js +255 -0
  113. package/dist/shared/scopeedit.js +81 -0
  114. package/dist/shared/search.js +191 -0
  115. package/dist/shared/separate.js +178 -0
  116. package/dist/shared/sharesnap.js +281 -0
  117. package/dist/shared/sticky.js +501 -0
  118. package/dist/shared/timeline.js +73 -0
  119. package/dist/shared/titles.js +300 -0
  120. package/dist/shared/viewgraph.js +157 -0
  121. package/dist/shared/views.js +859 -0
  122. package/dist/web/bundle/app.js +52671 -0
  123. package/dist/web/bundle/edges.js +407 -0
  124. package/dist/web/bundle/layout.js +1010 -0
  125. package/dist/web/bundle/pdfworker.js +48322 -0
  126. package/dist/web/index.html +257 -0
  127. package/dist/web/src/motion.js +430 -0
  128. package/dist/web/src/past.js +224 -0
  129. package/dist/web/src/scene.js +804 -0
  130. package/dist/web/style.css +2548 -0
  131. package/docs/agent-api/README.md +91 -0
  132. package/docs/agent-api/advice.md +108 -0
  133. package/docs/agent-api/export.md +120 -0
  134. package/docs/agent-api/following.md +140 -0
  135. package/docs/agent-api/history.md +142 -0
  136. package/docs/agent-api/library.md +65 -0
  137. package/docs/agent-api/mcp.md +104 -0
  138. package/docs/agent-api/notes.md +93 -0
  139. package/docs/agent-api/reading.md +166 -0
  140. package/docs/agent-api/requests.md +94 -0
  141. package/docs/agent-api/talking.md +100 -0
  142. package/docs/agent-api/tcb.md +135 -0
  143. package/docs/agent-api/views.md +125 -0
  144. package/docs/agent-api/words.md +56 -0
  145. package/package.json +39 -4
  146. package/test/toolchains.json +5 -0
@@ -0,0 +1,366 @@
1
+ // The ring around a board (AttachmentSpec decision 16, the owner's of 2026-10-11): a circle just
2
+ // outside every cluster of the board, at the board's own extent, where the notes that have no
3
+ // cluster on the board are — at the top level of its clustering, where someone let one go, or with
4
+ // no place under that clustering yet, at the angle of what it is about. Notes on the ring keep their
5
+ // order and a fixed gap on the screen between them; where one ring cannot hold them all so at the
6
+ // zoom, the rest go on rings further out, concentric with it, a fixed step apart on the screen (the
7
+ // owner's decision of 2026-10-11). This file is the geometry, the same in the pages and the tests:
8
+ // angles are kept in degrees clockwise from the top, and worked in radians in the board's
9
+ // coordinates, whose y grows downwards as the screen's does. Which ring a note is on is the layout's,
10
+ // never kept: its place is its angle.
11
+ import { noteLook } from "./sticky.js";
12
+ /** How near the middle of the board, as a fraction of the ring's radius, the centre of what a note
13
+ * is about points nowhere: the note goes in the order notes were written instead. */
14
+ export const RING_NEAR = 0.2;
15
+ /** how far outside the farthest circle the ring is, as a fraction of its radius, and at least */
16
+ const RING_OUT = 0.03;
17
+ const RING_OUT_MIN = 12;
18
+ /** the room left between two notes on a ring, in pixels of the screen, whatever the zoom */
19
+ export const RING_GAP_PX = 6;
20
+ /** how much farther out each ring is than the one inside it, in pixels of the screen: an icon's
21
+ * 24 px and 8 px between two icons at one angle */
22
+ export const RING_STEP_PX = 32;
23
+ /** the farthest a note is drawn from the angle it wants, along its ring, in radians: an eighth of
24
+ * the turn, so that it still points toward what it is about; where its angle is too crowded to hold
25
+ * it so near, it goes on a ring further out */
26
+ export const RING_REACH = Math.PI / 4;
27
+ /** The ring around discs (the board's top-level circles, and its top-level declarations as small
28
+ * ones): centred on the middle of what they cover, just outside the farthest; null for nothing. */
29
+ export function ringAround(discs) {
30
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
31
+ const all = [];
32
+ for (const d of discs) {
33
+ all.push(d);
34
+ x0 = Math.min(x0, d.x - d.r);
35
+ y0 = Math.min(y0, d.y - d.r);
36
+ x1 = Math.max(x1, d.x + d.r);
37
+ y1 = Math.max(y1, d.y + d.r);
38
+ }
39
+ if (!all.length)
40
+ return null;
41
+ const x = (x0 + x1) / 2, y = (y0 + y1) / 2;
42
+ let r = 0;
43
+ for (const d of all)
44
+ r = Math.max(r, Math.hypot(d.x - x, d.y - y) + d.r);
45
+ return { x, y, r: r + Math.max(RING_OUT_MIN, r * RING_OUT) };
46
+ }
47
+ /** Degrees clockwise from the top as an angle in the board's coordinates, and back. */
48
+ export const ringRadians = (deg) => ((deg - 90) * Math.PI) / 180;
49
+ export const ringDegrees = (rad) => {
50
+ const d = (rad * 180) / Math.PI + 90;
51
+ return Math.round((((d % 360) + 360) % 360) * 10) / 10;
52
+ };
53
+ /** The angle of a spot of the board seen from the ring's centre, in degrees clockwise from the top:
54
+ * where a note let go at the top level goes on the ring. */
55
+ export const angleOf = (ring, x, y) => ringDegrees(Math.atan2(y - ring.y, x - ring.x));
56
+ /** The angle, in radians, of what a note is about — the centre of its declarations on the board —
57
+ * or null where that centre is too near the middle to point anywhere (RING_NEAR). */
58
+ export function aboutAngle(ring, centre) {
59
+ if (!centre)
60
+ return null;
61
+ const dx = centre[0] - ring.x, dy = centre[1] - ring.y;
62
+ return Math.hypot(dx, dy) < RING_NEAR * ring.r ? null : Math.atan2(dy, dx);
63
+ }
64
+ const TOP = -Math.PI / 2;
65
+ const TURN = 2 * Math.PI;
66
+ /** How much of a ring a note takes at angle a, along the ring: its icon's diameter, or, where its
67
+ * card shows (`cards`), the box's extent across the ray. */
68
+ function across(it, a, cards) {
69
+ if (!it.open || !cards)
70
+ return it.icon;
71
+ const [w, h] = it.open;
72
+ return Math.max(it.icon, Math.abs(w * Math.sin(a)) + Math.abs(h * Math.cos(a)));
73
+ }
74
+ /** Writing order, oldest first, then the order given: which note a ring keeps first. */
75
+ const written = (items) => (a, b) => (items[a].order < items[b].order ? -1 : items[a].order > items[b].order ? 1 : a - b);
76
+ /** The radius on the screen of ring `j` when the innermost is `rpx` pixels: each RING_STEP_PX
77
+ * farther out than the one inside it. */
78
+ export const ringPx = (rpx, j) => Math.max(rpx, 1) + j * RING_STEP_PX;
79
+ /** The positions along a circle `c` pixels round, in pixels from the top, of notes that want
80
+ * positions `u` (in their order round it, unwrapped from the top) and must keep `g[i]` pixels
81
+ * between note i and the next (the last's to the first, round the top): each moved as little as
82
+ * the gaps need, their order kept — the least squares of their moves, by isotonic regression of
83
+ * the positions less the gaps before them, its spread held within what the circle leaves. Null
84
+ * where the gaps alone go round more than the circle. */
85
+ function spreadOn(u, g, c) {
86
+ const n = u.length;
87
+ if (n === 1)
88
+ return [u[0]];
89
+ let total = 0;
90
+ for (const x of g)
91
+ total += x;
92
+ const slack = c - total;
93
+ if (slack < -1e-9)
94
+ return null;
95
+ const before = new Array(n).fill(0);
96
+ for (let i = 1; i < n; i++)
97
+ before[i] = before[i - 1] + g[i - 1];
98
+ const z = u.map((x, i) => x - before[i]);
99
+ // pool adjacent violators: blocks of equal value, merged while one is above the next
100
+ const val = [], wt = [];
101
+ for (const x of z) {
102
+ val.push(x);
103
+ wt.push(1);
104
+ while (val.length > 1 && val[val.length - 2] > val[val.length - 1]) {
105
+ const w = wt[wt.length - 2] + wt[wt.length - 1];
106
+ const v = (val[val.length - 2] * wt[wt.length - 2] + val[val.length - 1] * wt[wt.length - 1]) / w;
107
+ val.splice(-2, 2, v);
108
+ wt.splice(-2, 2, w);
109
+ }
110
+ }
111
+ let y = [];
112
+ val.forEach((v, k) => {
113
+ for (let j = 0; j < wt[k]; j++)
114
+ y.push(v);
115
+ });
116
+ // the last must stay clear of the first round the top: the values are held within a window as
117
+ // wide as the slack, placed where the squares are least — where their slope, rising with where
118
+ // the window starts, is nought, found by halving
119
+ if (y[n - 1] - y[0] > slack) {
120
+ const slope = (a) => y.reduce((s, v, i) => s + (v < a ? a - z[i] : v > a + slack ? a + slack - z[i] : 0), 0);
121
+ let lo = y[0], hi = y[n - 1] - slack;
122
+ for (let k = 0; k < 48; k++) {
123
+ const mid = (lo + hi) / 2;
124
+ if (slope(mid) < 0)
125
+ lo = mid;
126
+ else
127
+ hi = mid;
128
+ }
129
+ const a = (lo + hi) / 2;
130
+ y = y.map((v) => Math.min(a + slack, Math.max(a, v)));
131
+ }
132
+ return y.map((v, i) => v + before[i]);
133
+ }
134
+ /** How far apart along a ring of radius `r` two points must be for the straight line between them to
135
+ * be `d` pixels long: on a small ring the line is shorter than the arc. None, on a ring narrower
136
+ * than `d`. */
137
+ const along = (d, r) => (d > 2 * r ? Infinity : 2 * r * Math.asin(d / (2 * r)));
138
+ /** Notes `ks` laid out on ring `j` with the innermost `rpx` pixels round on the screen, their cards
139
+ * shown or not (`cards`). Those that want an angle first: in their order round the ring, spread so
140
+ * that RING_GAP_PX is between each two on the screen (spreadOn); what a card takes along the ring is
141
+ * read at the angle the note wants, then again where the first spreading put it, the larger kept, as
142
+ * a box's extent across the ray changes with the angle. Then those that want none, in the order
143
+ * written, each from the top clockwise at the first place the others leave room for. Their angles,
144
+ * in the order of `ks`; with `strict`, null where they do not fit: the gaps go round more than the
145
+ * ring, one that wants an angle is more than RING_REACH from it, or those that want none come round
146
+ * to the top again. */
147
+ function layRing(items, ks, rpx, j, strict, cards) {
148
+ const r = ringPx(rpx, j);
149
+ const c = TURN * r;
150
+ // how far apart two notes taking `e1` and `e2` along the ring are kept: on a ring too narrow for
151
+ // both, as far as it goes, where the layout is not being tried
152
+ const apart = (e1, e2) => {
153
+ const d = along((e1 + e2) / 2 + RING_GAP_PX, r);
154
+ return Number.isFinite(d) || strict ? d : Math.PI * r;
155
+ };
156
+ const at = new Map();
157
+ // those that want an angle, at it as near as the gaps let them be, in their order round the ring
158
+ const norm = (a) => ((((a - TOP) % TURN) + TURN) % TURN) + TOP;
159
+ const seq = ks.filter((k) => items[k].want !== null).sort((a, b) => norm(items[a].want) - norm(items[b].want) || written(items)(a, b));
160
+ const placed = [];
161
+ if (seq.length) {
162
+ const u = seq.map((k) => (norm(items[k].want) - TOP) * r);
163
+ let th = seq.map((k) => norm(items[k].want));
164
+ let ext = seq.map((k, i) => across(items[k], th[i], cards));
165
+ let x = null;
166
+ for (let pass = 0; pass < 2; pass++) {
167
+ const g = seq.map((_, i) => apart(ext[i], ext[(i + 1) % seq.length]));
168
+ x = spreadOn(u, g, c);
169
+ if (!x) {
170
+ if (strict)
171
+ return null;
172
+ x = u;
173
+ }
174
+ th = x.map((v) => TOP + v / r);
175
+ ext = seq.map((k, i) => Math.max(ext[i], across(items[k], th[i], cards)));
176
+ }
177
+ if (strict && x.some((v, i) => Math.abs(v - u[i]) > RING_REACH * r + 1e-6))
178
+ return null;
179
+ seq.forEach((k, i) => {
180
+ at.set(k, th[i]);
181
+ placed.push({ x: ((x[i] % c) + c) % c, e: ext[i] });
182
+ });
183
+ }
184
+ // those that want none, from the top clockwise in the order written, each at the first place
185
+ // clear of those laid already, round the ring once at most: the others in their order round it,
186
+ // once more on either side for the turn, are passed one after the other
187
+ const loose = ks.filter((k) => items[k].want === null).sort(written(items));
188
+ if (!loose.length)
189
+ return ks.map((k) => at.get(k));
190
+ placed.sort((a, b) => a.x - b.x);
191
+ const others = [...placed.map((q) => ({ x: q.x - c, e: q.e })), ...placed, ...placed.map((q) => ({ x: q.x + c, e: q.e }))];
192
+ const widest = others.reduce((m, q) => Math.max(m, q.e), 0);
193
+ let p = 0;
194
+ let prev = null;
195
+ let first = null;
196
+ for (const k of loose) {
197
+ let e = across(items[k], TOP + p / r, cards);
198
+ if (prev)
199
+ p = Math.max(p, prev.x + apart(prev.e, e));
200
+ for (let again = 0; again < 2; again++) {
201
+ // the first of the others that could reach this far back, by halving
202
+ const back = p - apart(e, widest);
203
+ let i = 0, hi = others.length;
204
+ while (i < hi) {
205
+ const mid = (i + hi) >> 1;
206
+ if (others[mid].x < back)
207
+ i = mid + 1;
208
+ else
209
+ hi = mid;
210
+ }
211
+ for (; i < others.length && others[i].x < p + apart(e, others[i].e); i++)
212
+ if (Math.abs(p - others[i].x) < apart(e, others[i].e) - 1e-9)
213
+ p = others[i].x + apart(e, others[i].e);
214
+ const wider = across(items[k], TOP + p / r, cards);
215
+ if (wider <= e)
216
+ break;
217
+ e = wider;
218
+ }
219
+ // round to the top again: the first that wanted none is there
220
+ if (strict && (!Number.isFinite(p) || p >= c || (first && p + apart(e, first.e) > first.x + c + 1e-9)))
221
+ return null;
222
+ first ??= { x: p, e };
223
+ prev = { x: p, e };
224
+ at.set(k, TOP + p / r);
225
+ }
226
+ return ks.map((k) => at.get(k));
227
+ }
228
+ /** The zooms packing decides at: the innermost ring 2^(m/12) pixels round on the screen, twelve to a
229
+ * doubling, from 1 pixel to 2^20. A view between two is packed as at the smaller, where every ring
230
+ * holds what it is given at least as easily — its cards counted where they show anywhere between
231
+ * the two. */
232
+ const STEPS = 12;
233
+ const M_TOP = 20 * STEPS;
234
+ const levelPx = (m) => 2 ** (m / STEPS);
235
+ const cardsAt = (m) => noteLook(true, levelPx(m + 1)) === "open";
236
+ /** The notes packed for the last set asked about, kept while the set stays the same (every frame
237
+ * while only the zoom changes, and every zoom Fit tries): the lowest zoom at which one ring holds
238
+ * them all (`top`), and which ring each is on at each zoom below it, worked out as far down as was
239
+ * asked (`low`). */
240
+ let packed = null;
241
+ /** Which ring each note is on at zoom `m` (levelPx), from where they are at the zoom above it
242
+ * (`above`): in the order they were written, each on the ring it was on there, or further out
243
+ * where that ring no longer holds it with the older ones on it — so that zooming out never brings
244
+ * one in, and moves one out only when it must. Those that want no angle keep their order ring after
245
+ * ring: one never goes inside an earlier one. A ring with nothing on it yet takes anything. */
246
+ function ringsAt(items, above, m) {
247
+ const rings = [];
248
+ const out = new Array(items.length);
249
+ let looseFrom = 0;
250
+ for (const k of items.map((_, k) => k).sort(written(items))) {
251
+ let j = Math.max(above[k], items[k].want === null ? looseFrom : 0);
252
+ while (rings[j]?.length && !layRing(items, [...rings[j], k], levelPx(m), j, true, cardsAt(m)))
253
+ j++;
254
+ (rings[j] ??= []).push(k);
255
+ out[k] = j;
256
+ if (items[k].want === null)
257
+ looseFrom = j;
258
+ }
259
+ return out;
260
+ }
261
+ /** Where the notes on the rings go when the innermost is `rpx` pixels round on the screen (the
262
+ * owner's decision of 2026-10-11): each on a ring at the angle it wants — those that want none from
263
+ * the top, clockwise, in the order they were written — at least RING_GAP_PX from its neighbours
264
+ * along the ring, its order round the ring kept. A ring holds only as many as fit so at this zoom,
265
+ * each within RING_REACH of its angle; the rest go to rings further out, RING_STEP_PX apart. Zoomed
266
+ * in far enough, one ring holds them all; zooming out, a note moves out a ring only when the ring it
267
+ * is on no longer holds it with the older ones there — where its angle is too crowded, near its
268
+ * angle, the newest of a crowd first — and never back in until the view is zoomed in again past
269
+ * the same zoom: at a given zoom the rings are always the same. In the order given. */
270
+ export function packRings(items, rpx) {
271
+ const n = items.length;
272
+ if (!n)
273
+ return [];
274
+ // the angles a note wants read to the degree: while the board settles they drift every frame, and
275
+ // packing is worked out again only as they move by more
276
+ const key = items.map((it) => `${it.want === null ? "-" : Math.round((it.want * 180) / Math.PI)}|${it.order}|${it.icon}|${it.open ? it.open.map(Math.round).join("x") : ""}`).join("\n");
277
+ if (packed?.key !== key) {
278
+ // the lowest zoom at which one ring holds them all, found by halving between 1 pixel and 2^20
279
+ const all = items.map((_, k) => k);
280
+ const fits = (m) => !!layRing(items, all, levelPx(m), 0, true, cardsAt(m));
281
+ let lo = 0, hi = M_TOP;
282
+ if (fits(0))
283
+ hi = 0;
284
+ else
285
+ while (hi - lo > 1) {
286
+ const mid = (lo + hi) >> 1;
287
+ if (fits(mid))
288
+ hi = mid;
289
+ else
290
+ lo = mid;
291
+ }
292
+ packed = { key, top: hi, low: hi, at: new Map() };
293
+ }
294
+ const P = packed;
295
+ const m = Math.max(0, Math.floor(STEPS * Math.log2(Math.max(rpx, 1)) + 1e-9));
296
+ let ring = new Array(n).fill(0);
297
+ if (m < P.top) {
298
+ // from the lowest zoom worked out so far down to this one, each from the zoom above it
299
+ if (P.low < P.top)
300
+ ring = P.at.get(P.low);
301
+ for (let z = P.low - 1; z >= m; z--) {
302
+ ring = ringsAt(items, ring, z);
303
+ P.at.set(z, ring);
304
+ P.low = z;
305
+ }
306
+ ring = P.at.get(m);
307
+ }
308
+ const rings = [];
309
+ ring.forEach((j, k) => (rings[j] ??= []).push(k));
310
+ const out = new Array(n);
311
+ const cards = noteLook(true, rpx) === "open";
312
+ rings.forEach((ks, j) => {
313
+ if (!ks)
314
+ return;
315
+ const a = layRing(items, ks, rpx, j, false, cards) ?? ks.map((k) => items[k].want ?? TOP);
316
+ ks.forEach((k, i) => (out[k] = { ring: j, a: a[i] }));
317
+ });
318
+ return out;
319
+ }
320
+ /** Where a note's box goes for a point of the ring at angle `a` (screen pixels): outside the ring,
321
+ * touching the point with the side that faces the middle — to its right on the right, above it at
322
+ * the top. The top left corner. */
323
+ export function ringBox(px, py, a, w, h) {
324
+ return [px + (w / 2) * (Math.cos(a) - 1), py + (h / 2) * (Math.sin(a) - 1)];
325
+ }
326
+ /** The view that fits what `boxes(k)` gives at zoom `k` into `vw` × `vh` pixels: the largest zoom, at
327
+ * most `kMax`, at which it all fits, and the point of the world to put in the middle. The boxes are
328
+ * asked for at each zoom tried, since notes on the ring are spread by how large the ring is on the
329
+ * screen. Null where nothing fits however far out the view goes: notes alone wider or taller than
330
+ * the room, which the caller then leaves out. */
331
+ export function fitFrame(boxes, vw, vh, kMax) {
332
+ const extent = (k) => {
333
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
334
+ for (const b of boxes(k)) {
335
+ x0 = Math.min(x0, b.x * k + b.dx0);
336
+ y0 = Math.min(y0, b.y * k + b.dy0);
337
+ x1 = Math.max(x1, b.x * k + b.dx1);
338
+ y1 = Math.max(y1, b.y * k + b.dy1);
339
+ }
340
+ return { x0, y0, x1, y1 };
341
+ };
342
+ const fits = (k) => {
343
+ const e = extent(k);
344
+ return e.x1 - e.x0 <= vw && e.y1 - e.y0 <= vh;
345
+ };
346
+ if (!Number.isFinite(extent(kMax).x0))
347
+ return null;
348
+ let k = kMax;
349
+ if (!fits(kMax)) {
350
+ // the extent grows with the zoom once the board outweighs the notes: the largest zoom that fits
351
+ // is found by halving the interval between one that fits and one that does not
352
+ let lo = kMax * 1e-6, hi = kMax;
353
+ if (!fits(lo))
354
+ return null;
355
+ for (let n = 0; n < 48; n++) {
356
+ const mid = Math.sqrt(lo * hi);
357
+ if (fits(mid))
358
+ lo = mid;
359
+ else
360
+ hi = mid;
361
+ }
362
+ k = lo;
363
+ }
364
+ const e = extent(k);
365
+ return { k, x: (e.x0 + e.x1) / 2 / k, y: (e.y0 + e.y1) / 2 / k };
366
+ }
@@ -0,0 +1,255 @@
1
+ // What is in force where a declaration is written, read from its file as Lean would read it: the
2
+ // namespaces and sections around it, the `open` commands (an `open … in` right before it
3
+ // included), the `variable`s, `universe`s, options and section-local commands. The page reads the
4
+ // namespaces and opens to link the short names in a declaration's source (web/src/scope.ts); the
5
+ // agents' API answers with all of it (server/agent.ts, `context`); shared/context.ts writes it as
6
+ // the block of Lean shown above a declaration's source and recorded with what an opinion judged.
7
+ // This is the text, read without elaborating it: where Lean is running, what Lean says wins.
8
+ /** The namespaces that exist in the graph: every proper prefix of a declaration's name. */
9
+ const namespaceSets = new WeakMap();
10
+ export function namespacesOf(g) {
11
+ let s = namespaceSets.get(g);
12
+ if (!s) {
13
+ s = new Set();
14
+ for (const v of g.nodes) {
15
+ let k = v.name.indexOf(".");
16
+ while (k > 0) {
17
+ s.add(v.name.slice(0, k));
18
+ k = v.name.indexOf(".", k + 1);
19
+ }
20
+ }
21
+ namespaceSets.set(g, s);
22
+ }
23
+ return s;
24
+ }
25
+ const frame = (kind, ns, name, line) => ({
26
+ kind, ns, name, line, opens: [], aliases: new Map(), open: [], variables: [], universes: [], options: [], local: [],
27
+ });
28
+ const NAMESPACE = /^namespace\s+(\S+)/;
29
+ const SECTION = /^(?:@\[[^\]]*\]\s*)?(?:(?:noncomputable|public|private)\s+)*section\b(?:\s+([^\s-]\S*))?/;
30
+ const MUTUAL = /^mutual\b/;
31
+ const END = /^end\b(?:\s+([^\s-]\S*))?/;
32
+ const OPEN = /^open\s+(.*)$/;
33
+ const VARIABLE = /^variable\b\s*(.*)$/;
34
+ const UNIVERSE = /^universe\s+(.*)$/;
35
+ const OPTION = /^set_option\s+(.*)$/;
36
+ /** what holds to the end of its section besides the above */
37
+ const LOCAL = /^(?:(?:local|scoped)[\s[]|attribute\s*\[\s*(?:local|scoped)\b|include\b|omit\b)/;
38
+ /** attributes alone on their line belong to the declaration below */
39
+ const ATTRIBUTES = /^@\[[^\]]*\]$/;
40
+ /** `… in`: the command holds for the next command only. Only these commands are read so; a
41
+ * declaration's text that happens to end in `in` is not one. */
42
+ const IN = /\sin$/;
43
+ const IN_HEAD = /^(?:open|set_option|variable|universe|include|omit|attribute|local|scoped)\b/;
44
+ /** A line without its comments, and how deep in nested block comments it ends. String literals
45
+ * are kept whole, so that `"--"` in a notation is not a comment. */
46
+ function stripComments(line, depth) {
47
+ let out = "";
48
+ for (let k = 0; k < line.length; k++) {
49
+ const c = line[k], d = line[k + 1];
50
+ if (depth > 0) {
51
+ if (c === "/" && d === "-") {
52
+ depth++;
53
+ k++;
54
+ }
55
+ else if (c === "-" && d === "/") {
56
+ depth--;
57
+ k++;
58
+ }
59
+ continue;
60
+ }
61
+ if (c === "/" && d === "-") {
62
+ depth++;
63
+ k++;
64
+ continue;
65
+ }
66
+ if (c === "-" && d === "-")
67
+ break;
68
+ if (c === '"') {
69
+ let j = k + 1;
70
+ while (j < line.length && line[j] !== '"')
71
+ j += line[j] === "\\" ? 2 : 1;
72
+ out += line.slice(k, j + 1);
73
+ k = j;
74
+ continue;
75
+ }
76
+ out += c;
77
+ }
78
+ return { text: out, depth };
79
+ }
80
+ /** Read a file up to line `upto` (0-based, the line a declaration's text starts on): the frames in
81
+ * force there, and the commands written `… in` right before it. */
82
+ function walk(lines, upto, hasNamespace) {
83
+ const stack = [frame("top", "", null, 0)];
84
+ /** `open … in` and the like apply to the next command only */
85
+ let pending = null;
86
+ const current = () => stack.map((f) => f.ns).filter(Boolean).join(".");
87
+ /** What `open X` opens: X under the namespaces around it, innermost first, or X itself. */
88
+ const resolveNs = (x) => {
89
+ const cur = current().split(".").filter(Boolean);
90
+ const out = [];
91
+ for (let k = cur.length; k >= 0; k--) {
92
+ const n = [...cur.slice(0, k), x].join(".");
93
+ if (hasNamespace(n))
94
+ out.push(n);
95
+ }
96
+ return out;
97
+ };
98
+ /** `open …` (its arguments): what it opens, into a frame, and the command as written. */
99
+ const open = (args, cmd, into) => {
100
+ const words = args.trim().split(/\s+/);
101
+ const scoped = words[0] === "scoped";
102
+ const spaces = [];
103
+ const rest = scoped ? args.trim().slice("scoped".length) : args;
104
+ // `open A (x y)`, `open A hiding x`, `open A renaming x → y`
105
+ const paren = /^([^\s(]+)\s*\(([^)]*)\)/.exec(rest.trim());
106
+ const renaming = /^([^\s]+)\s+renaming\s+(.*)$/.exec(rest.trim());
107
+ if (paren) {
108
+ for (const ns of resolveNs(paren[1])) {
109
+ spaces.push(ns);
110
+ if (!scoped)
111
+ for (const x of paren[2].trim().split(/\s+/))
112
+ if (x)
113
+ into.aliases.set(x, `${ns}.${x}`);
114
+ }
115
+ }
116
+ else if (renaming) {
117
+ for (const ns of resolveNs(renaming[1])) {
118
+ spaces.push(ns);
119
+ if (!scoped)
120
+ for (const pair of renaming[2].split(",")) {
121
+ const [from, to] = pair.split(/→|->/).map((s) => s.trim());
122
+ if (from && to)
123
+ into.aliases.set(to, `${ns}.${from}`);
124
+ }
125
+ }
126
+ }
127
+ else {
128
+ const hiding = rest.indexOf(" hiding ");
129
+ for (const w of (hiding >= 0 ? rest.slice(0, hiding) : rest).trim().split(/\s+/)) {
130
+ if (!w)
131
+ continue;
132
+ const ns = resolveNs(w);
133
+ spaces.push(...ns);
134
+ // scoped opens bring notation and instances, no names
135
+ if (!scoped)
136
+ into.opens.push(...ns);
137
+ }
138
+ }
139
+ into.open.push({ ...cmd, namespaces: [...new Set(spaces)], ...(scoped ? { scoped: true } : {}) });
140
+ };
141
+ /** What a whole command does, once all its lines are read. */
142
+ const finish = (text, line) => {
143
+ let m;
144
+ if ((m = NAMESPACE.exec(text))) {
145
+ stack.push(frame("namespace", m[1], m[1], line));
146
+ pending = null;
147
+ return;
148
+ }
149
+ if ((m = SECTION.exec(text))) {
150
+ const f = frame("section", "", m[1] ?? null, line);
151
+ if (/\bnoncomputable\s+section\b/.test(text))
152
+ f.noncomputable = true;
153
+ stack.push(f);
154
+ pending = null;
155
+ return;
156
+ }
157
+ if (MUTUAL.test(text)) {
158
+ stack.push(frame("mutual", "", null, line));
159
+ pending = null;
160
+ return;
161
+ }
162
+ if ((m = END.exec(text))) {
163
+ // `end A.B` closes as many namespaces as its name has parts, `namespace A.B` being one
164
+ let parts = m[1] ? m[1].split(".").length : 1;
165
+ while (parts > 0 && stack.length > 1)
166
+ parts -= (stack.pop().name ?? "_").split(".").length;
167
+ pending = null;
168
+ return;
169
+ }
170
+ if (ATTRIBUTES.test(text))
171
+ return;
172
+ const head = IN_HEAD.test(text);
173
+ // `open A in def x …` on one line holds for that command alone, which is not the one asked about
174
+ if (head && !VARIABLE.test(text) && /\sin\s/.test(text)) {
175
+ pending = null;
176
+ return;
177
+ }
178
+ const onlyNext = head && IN.test(text);
179
+ const body = onlyNext ? text.replace(IN, "") : text;
180
+ // a command that is not `… in` uses up what was pending: a declaration, or anything else
181
+ const into = onlyNext ? (pending ??= frame("top", "", null, line)) : stack[stack.length - 1];
182
+ if (!onlyNext)
183
+ pending = null;
184
+ const cmd = (t) => ({ line: line + 1, text: t, ...(onlyNext ? { in: true } : {}) });
185
+ if ((m = OPEN.exec(body)))
186
+ open(m[1], cmd(body), into);
187
+ else if ((m = VARIABLE.exec(body)))
188
+ into.variables.push(cmd(m[1]));
189
+ else if ((m = UNIVERSE.exec(body)))
190
+ into.universes.push(cmd(m[1]));
191
+ else if ((m = OPTION.exec(body)))
192
+ into.options.push(cmd(m[1]));
193
+ else if (LOCAL.test(body))
194
+ into.local.push(cmd(body.length > 400 ? body.slice(0, 400) + "…" : body));
195
+ };
196
+ let depth = 0;
197
+ /** the command being read: its first line, and its text so far */
198
+ let reading = null;
199
+ for (let l = 0; l < upto && l < lines.length; l++) {
200
+ const line = lines[l];
201
+ const inComment = depth > 0;
202
+ const s = stripComments(line, depth);
203
+ depth = s.depth;
204
+ const text = s.text.replace(/\s+/g, " ").trim();
205
+ if (!text)
206
+ continue;
207
+ // a command starts at the line's first column, outside a comment; anything else continues it
208
+ if (!inComment && !/^\s/.test(line)) {
209
+ if (reading)
210
+ finish(reading.text, reading.line);
211
+ reading = { line: l, text };
212
+ }
213
+ else if (reading)
214
+ reading.text += " " + text;
215
+ }
216
+ if (reading)
217
+ finish(reading.text, reading.line);
218
+ return { stack, pending };
219
+ }
220
+ /** The scope at line `upto` of a file (the line a declaration's text starts on): what a short name
221
+ * written there can mean. */
222
+ export function scopeAt(lines, upto, hasNamespace) {
223
+ const { stack, pending } = walk(lines, upto, hasNamespace);
224
+ const frames = pending ? [...stack, pending] : stack;
225
+ const cur = stack.map((f) => f.ns).filter(Boolean).join(".").split(".").filter(Boolean);
226
+ const namespaces = [];
227
+ for (let k = cur.length; k > 0; k--)
228
+ namespaces.push(cur.slice(0, k).join("."));
229
+ const aliases = new Map();
230
+ for (const f of frames)
231
+ for (const [k, v] of f.aliases)
232
+ aliases.set(k, v);
233
+ return { namespaces, opens: [...new Set(frames.flatMap((f) => f.opens))], aliases };
234
+ }
235
+ /** Everything in force at line `upto` of a file, as written: the context a declaration there is
236
+ * read in. */
237
+ export function contextAt(lines, upto, hasNamespace) {
238
+ const { stack, pending } = walk(lines, upto, hasNamespace);
239
+ const frames = pending ? [...stack, pending] : stack;
240
+ const namespace = stack.map((f) => f.ns).filter(Boolean).join(".");
241
+ const cur = namespace.split(".").filter(Boolean);
242
+ const namespaces = [];
243
+ for (let k = cur.length; k > 0; k--)
244
+ namespaces.push(cur.slice(0, k).join("."));
245
+ return {
246
+ namespace,
247
+ namespaces,
248
+ blocks: stack.slice(1).map((f) => ({ kind: f.kind, name: f.name, line: f.line + 1, ...(f.noncomputable ? { noncomputable: true } : {}) })),
249
+ opens: frames.flatMap((f) => f.open),
250
+ variables: frames.flatMap((f) => f.variables),
251
+ universes: frames.flatMap((f) => f.universes),
252
+ options: frames.flatMap((f) => f.options),
253
+ local: frames.flatMap((f) => f.local),
254
+ };
255
+ }