visualproof 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +99 -2
- package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
- package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
- package/dist/assets/latex/visualproof.sty +308 -0
- package/dist/extractor/Extract.lean +1180 -0
- package/dist/extractor/Usage.lean +900 -0
- package/dist/mcp/visualproof-mcp.mjs +476 -0
- package/dist/server/access.js +95 -0
- package/dist/server/agent.js +3235 -0
- package/dist/server/agentdocs.js +35 -0
- package/dist/server/agentlibrary.js +202 -0
- package/dist/server/agentnotes.js +701 -0
- package/dist/server/agentviews.js +703 -0
- package/dist/server/askfollow.js +286 -0
- package/dist/server/boardspots.js +159 -0
- package/dist/server/dirs.js +175 -0
- package/dist/server/doctor.js +186 -0
- package/dist/server/events.js +198 -0
- package/dist/server/export.js +420 -0
- package/dist/server/extract.js +204 -0
- package/dist/server/fetchurl.js +321 -0
- package/dist/server/figures.js +158 -0
- package/dist/server/history.js +741 -0
- package/dist/server/historyapi.js +304 -0
- package/dist/server/hover.js +595 -0
- package/dist/server/identity.js +114 -0
- package/dist/server/invites.js +241 -0
- package/dist/server/layoutthread.js +51 -0
- package/dist/server/library.js +600 -0
- package/dist/server/libraryroute.js +211 -0
- package/dist/server/live.js +1101 -0
- package/dist/server/lostfound.js +199 -0
- package/dist/server/main.js +1394 -0
- package/dist/server/mcptools.js +497 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +156 -0
- package/dist/server/poppler.js +251 -0
- package/dist/server/presence.js +252 -0
- package/dist/server/project.js +222 -0
- package/dist/server/prompt.js +53 -0
- package/dist/server/readonly.js +152 -0
- package/dist/server/readonlygate.js +123 -0
- package/dist/server/renames.js +268 -0
- package/dist/server/review.js +334 -0
- package/dist/server/reviewapi.js +188 -0
- package/dist/server/snapshot.js +49 -0
- package/dist/server/sourcestore.js +473 -0
- package/dist/server/status.js +360 -0
- package/dist/server/talk.js +476 -0
- package/dist/server/tokens.js +221 -0
- package/dist/server/usage.js +501 -0
- package/dist/server/versions.js +82 -0
- package/dist/server/view.js +157 -0
- package/dist/server/views.js +1167 -0
- package/dist/shared/askfollow.js +46 -0
- package/dist/shared/board.js +332 -0
- package/dist/shared/build.js +2 -0
- package/dist/shared/collab.js +148 -0
- package/dist/shared/comment.js +285 -0
- package/dist/shared/context.js +157 -0
- package/dist/shared/doc/authors.js +98 -0
- package/dist/shared/doc/build.js +358 -0
- package/dist/shared/doc/latex.js +455 -0
- package/dist/shared/doc/markdown.js +159 -0
- package/dist/shared/doc/model.js +37 -0
- package/dist/shared/doc/order.js +107 -0
- package/dist/shared/doc/proof.js +272 -0
- package/dist/shared/doc/sections.js +93 -0
- package/dist/shared/doc/settings.js +75 -0
- package/dist/shared/edgecodec.js +102 -0
- package/dist/shared/editor.js +64 -0
- package/dist/shared/excerpt.js +87 -0
- package/dist/shared/graph.js +16 -0
- package/dist/shared/items.js +92 -0
- package/dist/shared/keys.js +203 -0
- package/dist/shared/layout.js +926 -0
- package/dist/shared/lexer.js +130 -0
- package/dist/shared/library.js +711 -0
- package/dist/shared/lostfound.js +150 -0
- package/dist/shared/moves.js +243 -0
- package/dist/shared/notecard.js +295 -0
- package/dist/shared/noteplace.js +505 -0
- package/dist/shared/palette.js +46 -0
- package/dist/shared/pastgraph.js +95 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +810 -0
- package/dist/shared/review.js +642 -0
- package/dist/shared/reviewqueue.js +333 -0
- package/dist/shared/ring.js +366 -0
- package/dist/shared/scope.js +255 -0
- package/dist/shared/scopeedit.js +81 -0
- package/dist/shared/search.js +191 -0
- package/dist/shared/separate.js +178 -0
- package/dist/shared/sharesnap.js +280 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +298 -0
- package/dist/shared/viewgraph.js +139 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +48733 -0
- package/dist/web/bundle/edges.js +409 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +239 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +787 -0
- package/dist/web/style.css +2312 -0
- package/docs/agent-api/README.md +91 -0
- package/docs/agent-api/advice.md +108 -0
- package/docs/agent-api/export.md +120 -0
- package/docs/agent-api/following.md +140 -0
- package/docs/agent-api/history.md +142 -0
- package/docs/agent-api/library.md +65 -0
- package/docs/agent-api/mcp.md +104 -0
- package/docs/agent-api/notes.md +93 -0
- package/docs/agent-api/reading.md +166 -0
- package/docs/agent-api/requests.md +94 -0
- package/docs/agent-api/talking.md +100 -0
- package/docs/agent-api/tcb.md +135 -0
- package/docs/agent-api/views.md +124 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Asking others to follow (CollabSpec decision 10, AgentSpec decision 8; the owner's decisions of
|
|
2
|
+
// 2026-10-12): what the server and the pages share of it — how long a request lasts, what a page
|
|
3
|
+
// answers, how the answers of one person's pages make theirs, and the outcome in words.
|
|
4
|
+
/** How long a request to follow is shown, and how soon after one the same asker may ask again. */
|
|
5
|
+
export const ASK_MS = 30_000;
|
|
6
|
+
/** How long the server waits past ASK_MS for an answer on its way before it counts none: a page's
|
|
7
|
+
* own countdown answers first. */
|
|
8
|
+
export const ASK_GRACE_MS = 3_000;
|
|
9
|
+
/** What a page asked answers: Follow; Not now, or Esc; nothing before the request ran out (or a newer
|
|
10
|
+
* one replaced it, or the page closed); or nothing to answer, its reader following the asker
|
|
11
|
+
* already — such a reader is not asked, and is shown nothing. */
|
|
12
|
+
export const ASK_ANSWERS = ["follow", "decline", "timeout", "following"];
|
|
13
|
+
/** A person's part in a request, from the answers of their pages: following where one of them
|
|
14
|
+
* followed; waiting while one may still answer; following already, declined, or no answer. */
|
|
15
|
+
export function foldAnswers(xs) {
|
|
16
|
+
if (xs.includes("follow"))
|
|
17
|
+
return "follow";
|
|
18
|
+
if (xs.includes(null))
|
|
19
|
+
return "waiting";
|
|
20
|
+
if (xs.includes("following"))
|
|
21
|
+
return "following";
|
|
22
|
+
if (xs.includes("decline"))
|
|
23
|
+
return "decline";
|
|
24
|
+
return "timeout";
|
|
25
|
+
}
|
|
26
|
+
/** Each state in words, as the asker reads it. */
|
|
27
|
+
export const ASK_WORDS = { follow: "following", decline: "declined", timeout: "no answer", following: "already following", waiting: "waiting" };
|
|
28
|
+
/** The outcome of a request in a line, from each person's part (a private reader's each its own):
|
|
29
|
+
* "2 following, 1 declined, 1 no answer". Those following the asker already were not asked, and are
|
|
30
|
+
* not counted: null where nobody else was. */
|
|
31
|
+
export function askLine(states) {
|
|
32
|
+
const asked = states.filter((s) => s !== "following");
|
|
33
|
+
if (!asked.length)
|
|
34
|
+
return null;
|
|
35
|
+
const parts = [];
|
|
36
|
+
for (const s of ["follow", "decline", "timeout", "waiting"]) {
|
|
37
|
+
const n = asked.filter((x) => x === s).length;
|
|
38
|
+
if (n)
|
|
39
|
+
parts.push(`${n} ${ASK_WORDS[s]}`);
|
|
40
|
+
}
|
|
41
|
+
return parts.join(", ");
|
|
42
|
+
}
|
|
43
|
+
/** An answer a page sent, checked: one of ASK_ANSWERS, or null. */
|
|
44
|
+
export function cleanAnswer(x) {
|
|
45
|
+
return ASK_ANSWERS.includes(x) ? x : null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// What a board shows (ViewSpec decision 2): the declarations its scope holds, laid out by the same
|
|
2
|
+
// rules as the whole project. A scope that holds every declaration of the project is shown on the
|
|
3
|
+
// server's own layout, with the outside world or without it; any other is taken out of the graph
|
|
4
|
+
// and laid out on its own (CanvasSpec, "Navigation", implementation notes).
|
|
5
|
+
//
|
|
6
|
+
// A board is shared, so its picture must be the same on every page that shows it and stay put
|
|
7
|
+
// when its set changes. The layout of a set is a function of the set (shared/layout.ts); what is
|
|
8
|
+
// kept between sets is the layout's memory (shared/layout.ts `stabilize`), and that memory is the
|
|
9
|
+
// board's, kept by the server per view and way of clustering together with the set it was made
|
|
10
|
+
// for. A page that finds the memory made for the set it shows recalls that layout exactly; one
|
|
11
|
+
// whose set changed lays it out again from the memory, keeping in place what stays, and hands the
|
|
12
|
+
// new memory to the server. Pages that start from the same memory and the same set come to the
|
|
13
|
+
// same picture, so whichever hands it over first, they agree. The server and the agents' API read
|
|
14
|
+
// boards the same way.
|
|
15
|
+
//
|
|
16
|
+
// A board may hide levels of its cluster tree, per way of clustering (ViewSpec decision 24). A
|
|
17
|
+
// board laid out on its own is laid out with them taken out, and its memory is made for its set
|
|
18
|
+
// under those levels (`layoutKey`): hiding or showing one starts from the memory before and keeps
|
|
19
|
+
// everything in place (shared/layout.ts, `regroup`), so a Re-layout is what packs the flattened
|
|
20
|
+
// tree afresh. A whole-project board stays the server's layout, its hidden circles taken out
|
|
21
|
+
// (`wholeLayout`): computed the same on every page from what they all have, it keeps no memory.
|
|
22
|
+
// Pins are places in their parent's circle, so the pins of what a level hidden or shown gives
|
|
23
|
+
// another parent are moved into that parent's coordinates, at the same place (`reframePins`).
|
|
24
|
+
//
|
|
25
|
+
// What a selection lights up on a board is computed on the whole graph and then cut to what the
|
|
26
|
+
// board holds (the owner's decision, 2026-10-08): a board's own graph keeps only the edges among its
|
|
27
|
+
// members, and a path through a declaration it leaves out — a lemma used only in a proof, a trusted
|
|
28
|
+
// definition on a board of a target's review set — is still a path. So what is lit is exactly what a view of it holds
|
|
29
|
+
// (`lightUp`, `zoomScope`), and Zoom in shows what was lit.
|
|
30
|
+
import { clusterTree, computeLayout, memoryOf, regroup, stabilize, withLocal } from "./layout.js";
|
|
31
|
+
import { evaluate, within } from "./query.js";
|
|
32
|
+
import { Graph } from "./viewgraph.js";
|
|
33
|
+
/** The declarations `set` holds, as a graph of their own (edges among them only). */
|
|
34
|
+
export function subgraph(g, set) {
|
|
35
|
+
// whole-graph order keeps the project's declarations before the outside world, as Graph expects
|
|
36
|
+
const toFull = [];
|
|
37
|
+
const toSub = new Int32Array(g.n).fill(-1);
|
|
38
|
+
let projectCount = 0;
|
|
39
|
+
for (let i = 0; i < g.n; i++) {
|
|
40
|
+
if (!set[i])
|
|
41
|
+
continue;
|
|
42
|
+
toSub[i] = toFull.length;
|
|
43
|
+
toFull.push(i);
|
|
44
|
+
if (i < g.projectCount)
|
|
45
|
+
projectCount++;
|
|
46
|
+
}
|
|
47
|
+
const edges = [];
|
|
48
|
+
for (const u of toFull) {
|
|
49
|
+
for (let e = g.outStart[u]; e < g.outStart[u + 1]; e++) {
|
|
50
|
+
const v = g.outDst[e];
|
|
51
|
+
if (toSub[v] >= 0)
|
|
52
|
+
edges.push(toSub[u], toSub[v], g.outSite[e]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const meta = {
|
|
56
|
+
...g.meta,
|
|
57
|
+
nodes: toFull.map((i) => g.nodes[i]),
|
|
58
|
+
projectCount,
|
|
59
|
+
edgeCount: edges.length / 3,
|
|
60
|
+
layouts: {},
|
|
61
|
+
};
|
|
62
|
+
const sub = new Graph(meta, new Int32Array(edges));
|
|
63
|
+
// the other names a declaration answers to (a past graph's: its name now, web/src/past.ts) go with
|
|
64
|
+
// it
|
|
65
|
+
for (const [name, i] of g.byName)
|
|
66
|
+
if (toSub[i] >= 0 && g.nodes[i].name !== name && !sub.byName.has(name))
|
|
67
|
+
sub.byName.set(name, toSub[i]);
|
|
68
|
+
return { graph: sub, toFull: Int32Array.from(toFull), toSub };
|
|
69
|
+
}
|
|
70
|
+
/** A graph's edges as the layout reads them: (source, target, site) triples. */
|
|
71
|
+
export function edgesOf(g) {
|
|
72
|
+
const edges = new Int32Array(3 * g.outDst.length);
|
|
73
|
+
let k = 0;
|
|
74
|
+
for (let u = 0; u < g.n; u++) {
|
|
75
|
+
for (let e = g.outStart[u]; e < g.outStart[u + 1]; e++) {
|
|
76
|
+
edges[k++] = u;
|
|
77
|
+
edges[k++] = g.outDst[e];
|
|
78
|
+
edges[k++] = g.outSite[e];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return edges;
|
|
82
|
+
}
|
|
83
|
+
/** A short key for a set of declarations, by their names: the same set gives the same key on
|
|
84
|
+
* every page and on the server, whatever the numbering. */
|
|
85
|
+
export function setKey(g, set) {
|
|
86
|
+
let sum = 0, mix = 0, count = 0;
|
|
87
|
+
for (let i = 0; i < g.n; i++) {
|
|
88
|
+
if (!set[i])
|
|
89
|
+
continue;
|
|
90
|
+
count++;
|
|
91
|
+
// two FNV-1a hashes of the name, combined so that the order does not matter
|
|
92
|
+
let h1 = 2166136261, h2 = 374761393;
|
|
93
|
+
for (const ch of g.nodes[i].name) {
|
|
94
|
+
const c = ch.codePointAt(0);
|
|
95
|
+
h1 = Math.imul(h1 ^ c, 16777619);
|
|
96
|
+
h2 = Math.imul(h2 ^ c, 2246822519);
|
|
97
|
+
}
|
|
98
|
+
sum = (sum + (h1 >>> 0)) >>> 0;
|
|
99
|
+
mix = (mix ^ Math.imul(h2, 3266489917)) >>> 0;
|
|
100
|
+
}
|
|
101
|
+
return `${count}.${sum.toString(36)}.${mix.toString(36)}`;
|
|
102
|
+
}
|
|
103
|
+
/** The board a scope makes of a graph and a review. A scope holding the whole project and either
|
|
104
|
+
* all of the outside world or none of it is the server's layout; anything else is laid out on its
|
|
105
|
+
* own. */
|
|
106
|
+
export function boardSet(g, review, scope) {
|
|
107
|
+
const set = evaluate(scope, { g, review });
|
|
108
|
+
let own = 0, ext = 0;
|
|
109
|
+
for (let i = 0; i < g.n; i++)
|
|
110
|
+
if (set[i])
|
|
111
|
+
i < g.projectCount ? own++ : ext++;
|
|
112
|
+
const outsideAll = ext === g.n - g.projectCount;
|
|
113
|
+
const whole = own === g.projectCount && g.projectCount > 0 && (ext === 0 || outsideAll);
|
|
114
|
+
return { set, key: setKey(g, set), count: own + ext, whole, outside: whole && ext > 0, sub: whole ? null : subgraph(g, set) };
|
|
115
|
+
}
|
|
116
|
+
/** What query `q` lights up on board `bs` of graph `g` (the graph the board was taken from): the
|
|
117
|
+
* query's set in the whole graph, then what of it the board holds — never the outside world on a
|
|
118
|
+
* board without it. */
|
|
119
|
+
export function lightUp(g, review, bs, q) {
|
|
120
|
+
const full = evaluate(q, { g, review });
|
|
121
|
+
let total = 0, count = 0;
|
|
122
|
+
if (!bs.sub) {
|
|
123
|
+
// the whole graph is the board's: cut in place what its scope leaves out
|
|
124
|
+
const every = bs.count === g.n;
|
|
125
|
+
for (let i = 0; i < g.n; i++) {
|
|
126
|
+
if (!full[i])
|
|
127
|
+
continue;
|
|
128
|
+
total++;
|
|
129
|
+
if (every || bs.set[i])
|
|
130
|
+
count++;
|
|
131
|
+
else
|
|
132
|
+
full[i] = 0;
|
|
133
|
+
}
|
|
134
|
+
return { set: full, count, inside: count === total };
|
|
135
|
+
}
|
|
136
|
+
const { toFull } = bs.sub;
|
|
137
|
+
const set = new Uint8Array(toFull.length);
|
|
138
|
+
for (let i = 0; i < g.n; i++)
|
|
139
|
+
total += full[i];
|
|
140
|
+
for (let j = 0; j < toFull.length; j++) {
|
|
141
|
+
if (!full[toFull[j]])
|
|
142
|
+
continue;
|
|
143
|
+
set[j] = 1;
|
|
144
|
+
count++;
|
|
145
|
+
}
|
|
146
|
+
return { set, count, inside: count === total };
|
|
147
|
+
}
|
|
148
|
+
/** The scope of a view of what `q` lights up on a board of scope `scope` (Zoom in, ViewSpec
|
|
149
|
+
* decision 13): the query among what the board holds — its whole scope, so that the view holds
|
|
150
|
+
* exactly what was lit, on a board of a target's review set what is in the target's TCB — or the query alone where it
|
|
151
|
+
* holds nothing the board leaves out. Null when that view would hold nothing, or just what the
|
|
152
|
+
* board holds: the sets are compared, not the texts, so a view of the selection does not offer
|
|
153
|
+
* itself again. */
|
|
154
|
+
export function zoomScope(q, scope, lit, bs) {
|
|
155
|
+
// what is lit lies within the board: as many as the board holds is the board itself
|
|
156
|
+
if (!lit.count || lit.count === bs.count)
|
|
157
|
+
return null;
|
|
158
|
+
return lit.inside ? q : within(q, scope);
|
|
159
|
+
}
|
|
160
|
+
/** The key a board's layout memory is made for: the set it holds (setKey) and, where any, the levels
|
|
161
|
+
* it hides by that clustering — a memory made under other hidden levels is not recalled, and is
|
|
162
|
+
* handed over anew. Without hidden levels it is the set's key alone, as memories were before. */
|
|
163
|
+
export function layoutKey(set, hidden) {
|
|
164
|
+
if (!hidden.length)
|
|
165
|
+
return set;
|
|
166
|
+
let h1 = 2166136261, h2 = 374761393;
|
|
167
|
+
for (const ch of [...new Set(hidden)].sort().join("\n")) {
|
|
168
|
+
const c = ch.codePointAt(0);
|
|
169
|
+
h1 = Math.imul(h1 ^ c, 16777619);
|
|
170
|
+
h2 = Math.imul(h2 ^ c, 2246822519);
|
|
171
|
+
}
|
|
172
|
+
return `${set}~${(h1 >>> 0).toString(36)}.${(h2 >>> 0).toString(36)}`;
|
|
173
|
+
}
|
|
174
|
+
/** Whether two layout keys are of the same set, whatever levels they hide. */
|
|
175
|
+
export const sameSet = (a, b) => a.split("~")[0] === b.split("~")[0];
|
|
176
|
+
/** The key a board of a past build's graph keeps its memory under (HistorySpec decision 19, the
|
|
177
|
+
* owner's decision of 2026-10-10): its layout key under the snapshot's id, so that the memory of a
|
|
178
|
+
* past board and that of a board of the latest build are never taken for each other, though they
|
|
179
|
+
* hold the same names. */
|
|
180
|
+
export const pastSetKey = (snapshot, key) => `@${snapshot}:${key}`;
|
|
181
|
+
/** The snapshot a memory's key says it is of (pastSetKey), or null for the latest build's. */
|
|
182
|
+
export function snapshotOfKey(key) {
|
|
183
|
+
const m = /^@(\d+):/.exec(key);
|
|
184
|
+
return m ? Number(m[1]) : null;
|
|
185
|
+
}
|
|
186
|
+
/** The layout `mem` remembers, exactly, where it holds every declaration and cluster of `tree` in
|
|
187
|
+
* the same cluster; null where it does not. `tree` gives the tree (clusterTree, or a fresh
|
|
188
|
+
* layout). */
|
|
189
|
+
export function recall(tree, g, mem) {
|
|
190
|
+
const clusters = tree.clusters.map((c) => {
|
|
191
|
+
const m = mem.clusters[c.key];
|
|
192
|
+
return m ? { ...c, x: m[0], y: m[1], r: m[2] } : null;
|
|
193
|
+
});
|
|
194
|
+
if (clusters.some((c) => c === null))
|
|
195
|
+
return null;
|
|
196
|
+
const x = [], y = [];
|
|
197
|
+
for (let i = 0; i < g.n; i++) {
|
|
198
|
+
const m = mem.nodes[g.nodes[i].name];
|
|
199
|
+
const c = tree.cluster[i];
|
|
200
|
+
if (!m || m[2] !== (c >= 0 ? tree.clusters[c].key : ""))
|
|
201
|
+
return null;
|
|
202
|
+
x.push(m[0]);
|
|
203
|
+
y.push(m[1]);
|
|
204
|
+
}
|
|
205
|
+
return withLocal({ x, y, lx: [], ly: [], cluster: tree.cluster, clusters: clusters });
|
|
206
|
+
}
|
|
207
|
+
/** A board's layout by `mode`, for the set `set` (setKey) of graph `g` with the levels `hidden`
|
|
208
|
+
* hidden: recalled from the memory when it was made for this set and these levels; regrouped from
|
|
209
|
+
* it when only the levels differ (nothing moves); else laid out afresh keeping in place what the
|
|
210
|
+
* memory holds. `next` is the memory to hand to the server when it changed (null when the memory
|
|
211
|
+
* already fits). */
|
|
212
|
+
export function boardLayout(g, mode, set, memory, hidden = []) {
|
|
213
|
+
const key = layoutKey(set, hidden);
|
|
214
|
+
const quick = recalled(g, mode, key, memory, hidden);
|
|
215
|
+
if (quick)
|
|
216
|
+
return quick;
|
|
217
|
+
const layout = stabilize(computeLayout(mode, g.nodes, edgesOf(g), hidden), g.nodes, memory?.mem ?? null);
|
|
218
|
+
return { layout, next: { set: key, mem: memoryOf(layout, g.nodes) } };
|
|
219
|
+
}
|
|
220
|
+
/** The part of boardLayout that lays nothing out: the layout `memory` remembers for `key` (a layout
|
|
221
|
+
* key, layoutKey or pastSetKey), recalled, or regrouped from it where only the hidden levels differ;
|
|
222
|
+
* null where the board must be laid out, keeping in place what the memory holds. */
|
|
223
|
+
export function recalled(g, mode, key, memory, hidden) {
|
|
224
|
+
if (!memory || !(memory.set === key || sameSet(memory.set, key)))
|
|
225
|
+
return null;
|
|
226
|
+
const tree = clusterTree(mode, g.nodes, hidden);
|
|
227
|
+
const L = memory.set === key ? recall(tree, g, memory.mem) : null;
|
|
228
|
+
if (L)
|
|
229
|
+
return { layout: L, next: null };
|
|
230
|
+
if (memory.set === key)
|
|
231
|
+
return null;
|
|
232
|
+
const layout = regroup(tree, g.nodes, memory.mem);
|
|
233
|
+
return { layout, next: { set: key, mem: memoryOf(layout, g.nodes) } };
|
|
234
|
+
}
|
|
235
|
+
/** A whole-project board with the levels `hidden` hidden: the server's layout `base` of `nodes`,
|
|
236
|
+
* everything where it is there, the hidden levels' circles taken out (and a level the base has no
|
|
237
|
+
* circle for drawn round what it holds). Every page computes the same from the same, so it keeps
|
|
238
|
+
* no memory; Re-layout of the Project lays the base out anew. */
|
|
239
|
+
export function wholeLayout(base, nodes, mode, hidden) {
|
|
240
|
+
if (!hidden.length)
|
|
241
|
+
return base;
|
|
242
|
+
return regroup(clusterTree(mode, nodes, hidden), nodes, memoryOf(base, nodes), base);
|
|
243
|
+
}
|
|
244
|
+
/** The pins of a board whose frame a change of hidden levels moves, as operations: a pin is a place
|
|
245
|
+
* in its parent's coordinates, and a level hidden or shown gives what it holds directly another
|
|
246
|
+
* parent. Each such pin is put in its new parent's coordinates at the same place on the board, with
|
|
247
|
+
* the pins along both chains of parents. What is not pinned but would land elsewhere in its new
|
|
248
|
+
* parent — the members of a level that was dragged, whose layout places are relative to where the
|
|
249
|
+
* layout put it — is pinned where it is (at most `MAX_REFRAME` of them; beyond that they go to their
|
|
250
|
+
* layout places). A hidden cluster's own pin is kept, for when it is shown again; what is in a
|
|
251
|
+
* frame that stays is left alone. `before` and `after` are the board's layouts by `mode` of graph
|
|
252
|
+
* `g`, under the levels hidden before and after. */
|
|
253
|
+
/** the most places a change of hidden levels pins that were not pinned (shared/places.ts takes 2,000
|
|
254
|
+
* operations at a time) */
|
|
255
|
+
export const MAX_REFRAME = 1500;
|
|
256
|
+
export function reframePins(g, before, after, pins, mode) {
|
|
257
|
+
const ops = [];
|
|
258
|
+
const byKey = (L) => new Map(L.clusters.map((c, k) => [c.key, k]));
|
|
259
|
+
const kb = byKey(before);
|
|
260
|
+
// where each cluster of `before` is on the board, its pins applied along its chain
|
|
261
|
+
const wb = new Map();
|
|
262
|
+
const worldB = (c) => {
|
|
263
|
+
if (c < 0)
|
|
264
|
+
return [0, 0];
|
|
265
|
+
let w = wb.get(c);
|
|
266
|
+
if (!w) {
|
|
267
|
+
const cl = before.clusters[c];
|
|
268
|
+
const [px, py] = worldB(cl.parent);
|
|
269
|
+
const [x, y] = pins.clusters[cl.key] ?? [cl.lx, cl.ly];
|
|
270
|
+
wb.set(c, (w = [px + x, py + y]));
|
|
271
|
+
}
|
|
272
|
+
return w;
|
|
273
|
+
};
|
|
274
|
+
const r = (v) => Math.round(v * 10) / 10;
|
|
275
|
+
const away = (x1, y1, x2, y2) => Math.hypot(x1 - x2, y1 - y2) > 0.5;
|
|
276
|
+
let extra = 0;
|
|
277
|
+
// the same after, parents first, with the pins as they are made
|
|
278
|
+
const wa = [];
|
|
279
|
+
const worldA = (c) => (c < 0 ? [0, 0] : wa[c]);
|
|
280
|
+
after.clusters.forEach((cl, c) => {
|
|
281
|
+
const [px, py] = worldA(cl.parent);
|
|
282
|
+
const was = kb.get(cl.key);
|
|
283
|
+
const pin = pins.clusters[cl.key];
|
|
284
|
+
const parentKey = (L, k) => (L.clusters[k].parent >= 0 ? L.clusters[L.clusters[k].parent].key : "");
|
|
285
|
+
const [bx, by] = was !== undefined ? worldB(was) : [0, 0];
|
|
286
|
+
// its parent changed: the same place on the board, in the new parent's coordinates, if pinned
|
|
287
|
+
// or if its layout place there is not where it was
|
|
288
|
+
if (was !== undefined && parentKey(before, was) !== parentKey(after, c) && (pin || (extra < MAX_REFRAME && away(bx, by, px + cl.lx, py + cl.ly)))) {
|
|
289
|
+
if (!pin)
|
|
290
|
+
extra++;
|
|
291
|
+
const at = [r(bx - px), r(by - py)];
|
|
292
|
+
ops.push({ op: "pin", mode, kind: "cluster", key: cl.key, at });
|
|
293
|
+
wa[c] = [px + at[0], py + at[1]];
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
const [x, y] = pin ?? [cl.lx, cl.ly];
|
|
297
|
+
wa[c] = [px + x, py + y];
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
const index = new Map();
|
|
301
|
+
for (let i = 0; i < g.n; i++)
|
|
302
|
+
index.set(g.nodes[i].name, i);
|
|
303
|
+
const key = (L, c) => (c >= 0 ? L.clusters[c].key : "");
|
|
304
|
+
for (const [name, [x, y]] of Object.entries(pins.nodes)) {
|
|
305
|
+
const i = index.get(name);
|
|
306
|
+
if (i === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
const cb = before.cluster[i], ca = after.cluster[i];
|
|
309
|
+
if (key(before, cb) === key(after, ca))
|
|
310
|
+
continue;
|
|
311
|
+
const [bx, by] = worldB(cb);
|
|
312
|
+
const [ax, ay] = worldA(ca);
|
|
313
|
+
ops.push({ op: "pin", mode, kind: "node", key: name, at: [r(bx + x - ax), r(by + y - ay)] });
|
|
314
|
+
}
|
|
315
|
+
// a declaration that is not pinned and changed parent: pinned where it is, if it would move
|
|
316
|
+
for (let i = 0; i < g.n && extra < MAX_REFRAME; i++) {
|
|
317
|
+
const name = g.nodes[i].name;
|
|
318
|
+
if (pins.nodes[name] || i >= before.cluster.length || i >= after.cluster.length)
|
|
319
|
+
continue;
|
|
320
|
+
const cb = before.cluster[i], ca = after.cluster[i];
|
|
321
|
+
if (key(before, cb) === key(after, ca))
|
|
322
|
+
continue;
|
|
323
|
+
const [bx, by] = worldB(cb);
|
|
324
|
+
const [ax, ay] = worldA(ca);
|
|
325
|
+
const x = bx + before.lx[i], y = by + before.ly[i];
|
|
326
|
+
if (!away(x, y, ax + after.lx[i], ay + after.ly[i]))
|
|
327
|
+
continue;
|
|
328
|
+
extra++;
|
|
329
|
+
ops.push({ op: "pin", mode, kind: "node", key: name, at: [r(x - ax), r(y - ay)] });
|
|
330
|
+
}
|
|
331
|
+
return ops;
|
|
332
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// What the server and the page share about the people on a server (CollabSpec.md): names, the
|
|
2
|
+
// colour each name has, and what a reader tells the others about where they are.
|
|
3
|
+
/** the most characters a name may have */
|
|
4
|
+
export const MAX_NAME = 60;
|
|
5
|
+
/** A name someone can be known by: 1–60 characters, no line breaks or other control characters. */
|
|
6
|
+
export function validName(s) {
|
|
7
|
+
const t = s.trim();
|
|
8
|
+
return t.length > 0 && t.length <= MAX_NAME && t === s && !/[\u0000-\u001f\u007f]/.test(t);
|
|
9
|
+
}
|
|
10
|
+
/** `s` within `room` characters (counted as validName counts them): itself, or cut short with "…",
|
|
11
|
+
* never inside a character. */
|
|
12
|
+
export function cutShort(s, room) {
|
|
13
|
+
if (s.length <= room)
|
|
14
|
+
return s;
|
|
15
|
+
let out = "";
|
|
16
|
+
for (const ch of s) {
|
|
17
|
+
if (out.length + ch.length > room - 1)
|
|
18
|
+
break;
|
|
19
|
+
out += ch;
|
|
20
|
+
}
|
|
21
|
+
return out + "…";
|
|
22
|
+
}
|
|
23
|
+
/** The name Share gives an agent unless its person writes another (AgentSpec decision 7): "Alice's
|
|
24
|
+
* AI", within the characters a name may have — a long name is cut short. */
|
|
25
|
+
export function defaultAgentName(by) {
|
|
26
|
+
return `${cutShort(by.trim(), MAX_NAME - "'s AI".length)}'s AI`;
|
|
27
|
+
}
|
|
28
|
+
/** An agent's name told apart from the names `taken` (the tokens in force), whatever the case: the
|
|
29
|
+
* name itself, else numbered — "Alice's AI 2", "Alice's AI 3" — within the characters a name may
|
|
30
|
+
* have, the name cut short to make room for the number. */
|
|
31
|
+
export function uniqueName(name, taken) {
|
|
32
|
+
const seen = new Set([...taken].map((t) => t.toLowerCase()));
|
|
33
|
+
for (let n = 1;; n++) {
|
|
34
|
+
const s = n === 1 ? name : `${cutShort(name, MAX_NAME - ` ${n}`.length)} ${n}`;
|
|
35
|
+
if (!seen.has(s.toLowerCase()))
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Whether two names are the same name to a reader: whatever the case, and however Unicode writes
|
|
40
|
+
* the same letters. A guest cannot take a name someone invited by a personal link holds in this
|
|
41
|
+
* sense (CollabSpec decision 9), so that "alice" does not pass for "Alice". */
|
|
42
|
+
export function sameName(a, b) {
|
|
43
|
+
const key = (s) => s.trim().normalize("NFKC").toLowerCase();
|
|
44
|
+
return key(a) === key(b);
|
|
45
|
+
}
|
|
46
|
+
/** Up to two letters for a name's round badge: the first letters of its first two words. */
|
|
47
|
+
export function initials(name) {
|
|
48
|
+
const words = name.trim().split(/[\s._-]+/).filter(Boolean);
|
|
49
|
+
const first = (w) => [...w][0] ?? "";
|
|
50
|
+
return ((words.length > 1 ? first(words[0]) + first(words[1]) : [...name.trim()].slice(0, 2).join("")) || "?").toUpperCase();
|
|
51
|
+
}
|
|
52
|
+
/** A person's colour, fixed by their name: the same on every page and every server. */
|
|
53
|
+
export function colorOf(name) {
|
|
54
|
+
let h = 2166136261;
|
|
55
|
+
for (const ch of name) {
|
|
56
|
+
h ^= ch.codePointAt(0);
|
|
57
|
+
h = Math.imul(h, 16777619);
|
|
58
|
+
}
|
|
59
|
+
// hues spread around the wheel, saturated and dark enough to read white initials on
|
|
60
|
+
const hue = ((h >>> 0) % 360 + 360) % 360;
|
|
61
|
+
return `hsl(${hue} 62% 46%)`;
|
|
62
|
+
}
|
|
63
|
+
/** the most source windows a reader pins at once (web/src/sources.ts): a few declarations side by
|
|
64
|
+
* side, not a wall of them */
|
|
65
|
+
export const MAX_PINNED = 6;
|
|
66
|
+
/** The legend's schemes (CanvasSpec, "Colour schemes"). */
|
|
67
|
+
export const SCHEMES = ["kind", "status"];
|
|
68
|
+
/** Activity's filters: the kind of event, and whose (web/src/activity.ts). */
|
|
69
|
+
export const ACTIVITY_KINDS = ["all", "review", "build", "change"];
|
|
70
|
+
export const ACTIVITY_WHO = ["all", "agents", "people"];
|
|
71
|
+
/** A session id: what a page calls itself for as long as it is open, in its event stream's address
|
|
72
|
+
* only (the server keeps a page's streams few by it); it is never told to the others. */
|
|
73
|
+
export function validSession(s) {
|
|
74
|
+
return typeof s === "string" && /^[0-9a-f-]{8,64}$/.test(s);
|
|
75
|
+
}
|
|
76
|
+
/** A view as a page sent it, checked and bounded; null when it is not one. */
|
|
77
|
+
export function cleanView(v) {
|
|
78
|
+
if (!v || typeof v !== "object")
|
|
79
|
+
return null;
|
|
80
|
+
const o = v;
|
|
81
|
+
const str = (x, max = 2000) => (typeof x === "string" && x.length <= max ? x : null);
|
|
82
|
+
const num = (x) => (typeof x === "number" && Number.isFinite(x) ? x : null);
|
|
83
|
+
const cam = o.cam && typeof o.cam === "object" ? o.cam : null;
|
|
84
|
+
const cx = num(cam?.x), cy = num(cam?.y), ck = num(cam?.k);
|
|
85
|
+
const selection = str(o.selection, 20000);
|
|
86
|
+
const h = o.history && typeof o.history === "object" ? o.history.snapshot : null;
|
|
87
|
+
const snapshot = typeof h === "number" && Number.isInteger(h) && h > 0 && h < 1e9 ? h : null;
|
|
88
|
+
const source = o.source === true;
|
|
89
|
+
// the visitors, at most a trail's worth (web/src/visitors.ts, TRAIL), each a name, where it was
|
|
90
|
+
// reached from and its place: the cluster and the spot in it, and where it is drawn
|
|
91
|
+
const pair = (p) => Array.isArray(p) && p.length === 2 && p.every((n) => num(n) !== null && Math.abs(n) < 1e8) ? [p[0], p[1]] : null;
|
|
92
|
+
const visitors = (Array.isArray(o.visitors) ? o.visitors.slice(0, 8) : []).flatMap((w) => {
|
|
93
|
+
const x = w && typeof w === "object" ? w : null;
|
|
94
|
+
const decl = x ? str(x.decl) : null;
|
|
95
|
+
if (!decl)
|
|
96
|
+
return [];
|
|
97
|
+
const at = pair(x.at), spot = pair(x.spot), from = str(x.from), inn = typeof x.in === "string" ? str(x.in) : null;
|
|
98
|
+
return [{ decl, ...(at ? { at } : {}), ...(from ? { from } : {}), ...(inn !== null && spot ? { in: inn, spot } : {}) }];
|
|
99
|
+
});
|
|
100
|
+
// the reference window: a file by its hash, a page, the link it was opened on
|
|
101
|
+
const pw = o.paper && typeof o.paper === "object" ? o.paper : null;
|
|
102
|
+
const paper = pw && typeof pw.file === "string" && /^[0-9a-f]{64}$/.test(pw.file)
|
|
103
|
+
? {
|
|
104
|
+
file: pw.file,
|
|
105
|
+
...(Number.isInteger(pw.page) && pw.page >= 1 && pw.page <= 100000 ? { page: pw.page } : {}),
|
|
106
|
+
...(typeof pw.link === "string" && /^l[0-9a-f]{12}$/.test(pw.link) ? { link: pw.link } : {}),
|
|
107
|
+
...(pw.collapsed === true ? { collapsed: true } : {}),
|
|
108
|
+
}
|
|
109
|
+
: null;
|
|
110
|
+
// what the panels and the board show besides, each said or not at all (an older page says none)
|
|
111
|
+
const av = o.activity && typeof o.activity === "object" ? o.activity : null;
|
|
112
|
+
const one = (xs, x) => (xs.includes(x) ? x : null);
|
|
113
|
+
const kind = one(ACTIVITY_KINDS, av?.kind), who = one(ACTIVITY_WHO, av?.who);
|
|
114
|
+
const activity = av && typeof av.open === "boolean" && kind && who ? { open: av.open, kind, who, ...(typeof av.mine === "string" && validName(av.mine) ? { mine: av.mine } : {}) } : null;
|
|
115
|
+
const lost = o.lostFound && typeof o.lostFound === "object" ? o.lostFound.open : undefined;
|
|
116
|
+
const scheme = one(SCHEMES, o.scheme);
|
|
117
|
+
const pinned = Array.isArray(o.pinned)
|
|
118
|
+
? o.pinned
|
|
119
|
+
.slice(0, MAX_PINNED)
|
|
120
|
+
.map((w) => {
|
|
121
|
+
const decl = w && typeof w === "object" ? str(w.decl) : null;
|
|
122
|
+
return decl ? { decl, collapsed: w.collapsed === true } : null;
|
|
123
|
+
})
|
|
124
|
+
.filter((w) => w !== null)
|
|
125
|
+
: [];
|
|
126
|
+
return {
|
|
127
|
+
mode: str(o.mode, 16) ?? "tcb",
|
|
128
|
+
sel: str(o.sel),
|
|
129
|
+
...(selection !== null ? { selection } : {}),
|
|
130
|
+
source,
|
|
131
|
+
...(source && o.sourceCollapsed === true ? { sourceCollapsed: true } : {}),
|
|
132
|
+
...(pinned.length ? { pinned } : {}),
|
|
133
|
+
clusterMode: str(o.clusterMode, 16) ?? "namespace",
|
|
134
|
+
cam: cx !== null && cy !== null && ck !== null ? { x: cx, y: cy, k: ck } : null,
|
|
135
|
+
view: str(o.view, 300),
|
|
136
|
+
scope: str(o.scope, 4000),
|
|
137
|
+
tabs: Array.isArray(o.tabs) ? o.tabs.slice(0, 50).map((t) => str(t, 300)).filter((t) => t !== null) : [],
|
|
138
|
+
...(snapshot !== null ? { history: { snapshot } } : {}),
|
|
139
|
+
...(o.focus === null || str(o.focus) ? { focus: o.focus } : {}),
|
|
140
|
+
...(visitors.length ? { visitors } : {}),
|
|
141
|
+
...(paper ? { paper } : {}),
|
|
142
|
+
...(activity ? { activity } : {}),
|
|
143
|
+
...(typeof lost === "boolean" ? { lostFound: { open: lost } } : {}),
|
|
144
|
+
...(scheme ? { scheme } : {}),
|
|
145
|
+
...(typeof o.edges === "boolean" ? { edges: o.edges } : {}),
|
|
146
|
+
...(typeof o.proofEdges === "boolean" ? { proofEdges: o.proofEdges } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|