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,81 @@
|
|
|
1
|
+
// A view's scope changed by one part from the canvas's menu (ViewSpec, "The canvas's menu",
|
|
2
|
+
// decided 2026-10-09): "Remove from this view" is `scope except part`, "Add to view" is
|
|
3
|
+
// `scope or part`, a part being a declaration, `decl(X)`, or a cluster as the selection's cluster
|
|
4
|
+
// picks write it, `ns(P) except external` or `file(P)` (web/src/selection.ts, clusterSet). The
|
|
5
|
+
// scope stays short as the reader removes and adds: a declaration joins the `decl(…)` that ends the
|
|
6
|
+
// scope with the same word (or that is the scope), and adding back what was taken out undoes the taking out rather than
|
|
7
|
+
// piling a second part on the first. What is written is always exactly the set asked for; where a
|
|
8
|
+
// caller can tell that a shorter scope holds the same set (`same`, evaluated on its graph and
|
|
9
|
+
// review), that one is written, and a scope that holds the set asked for already is left as it is.
|
|
10
|
+
import { sameQuery } from "./query.js";
|
|
11
|
+
/** A declaration's one name, when the part is `decl(X)`. */
|
|
12
|
+
const single = (q) => (q.op === "decl" && q.names.length === 1 ? q.names[0] : null);
|
|
13
|
+
/** `q` without `name` in its trailing `decl(…)` (`a <op> decl(…)`): the `decl` shorter by one, or
|
|
14
|
+
* `a` when it held that name alone; null when `q` does not end so. */
|
|
15
|
+
function dropName(q, op, name) {
|
|
16
|
+
if (q.op !== op || q.b.op !== "decl" || !q.b.names.includes(name))
|
|
17
|
+
return null;
|
|
18
|
+
const names = q.b.names.filter((n) => n !== name);
|
|
19
|
+
return names.length ? { op, a: q.a, b: { op: "decl", names } } : q.a;
|
|
20
|
+
}
|
|
21
|
+
/** `q <op> decl(name)`, the name joining a trailing `decl(…)` of the same op. */
|
|
22
|
+
function addName(q, op, name) {
|
|
23
|
+
if (q.op === op && q.b.op === "decl")
|
|
24
|
+
return q.b.names.includes(name) ? q : { op, a: q.a, b: { op: "decl", names: [...q.b.names, name] } };
|
|
25
|
+
return { op, a: q, b: { op: "decl", names: [name] } };
|
|
26
|
+
}
|
|
27
|
+
/** The scope that holds `part` too: `scope or part`, as short as it can be written. */
|
|
28
|
+
export function scopeWith(scope, part, same) {
|
|
29
|
+
const next = withPart(scope, part, same);
|
|
30
|
+
return same?.(scope, next) ? scope : next;
|
|
31
|
+
}
|
|
32
|
+
/** The scope that leaves `part` out: `scope except part`, as short as it can be written. */
|
|
33
|
+
export function scopeWithout(scope, part, same) {
|
|
34
|
+
const next = withoutPart(scope, part, same);
|
|
35
|
+
return same?.(scope, next) ? scope : next;
|
|
36
|
+
}
|
|
37
|
+
function withPart(scope, part, same) {
|
|
38
|
+
const name = single(part);
|
|
39
|
+
// a scope of declarations by name takes one more
|
|
40
|
+
if (name !== null && scope.op === "decl")
|
|
41
|
+
return scope.names.includes(name) ? scope : { op: "decl", names: [...scope.names, name] };
|
|
42
|
+
if (name !== null) {
|
|
43
|
+
// what was taken out comes back: the name leaves the `except decl(…)` that took it out
|
|
44
|
+
const back = dropName(scope, "except", name);
|
|
45
|
+
if (back) {
|
|
46
|
+
if (same?.(back, addName(back, "or", name)))
|
|
47
|
+
return back;
|
|
48
|
+
return addName(back, "or", name);
|
|
49
|
+
}
|
|
50
|
+
return addName(scope, "or", name);
|
|
51
|
+
}
|
|
52
|
+
// a cluster taken out comes back: what the scope was, and the cluster with it
|
|
53
|
+
if (scope.op === "except" && sameQuery(scope.b, part)) {
|
|
54
|
+
if (same?.(scope.a, { op: "or", a: scope.a, b: part }))
|
|
55
|
+
return scope.a;
|
|
56
|
+
return { op: "or", a: scope.a, b: part };
|
|
57
|
+
}
|
|
58
|
+
return { op: "or", a: scope, b: part };
|
|
59
|
+
}
|
|
60
|
+
function withoutPart(scope, part, same) {
|
|
61
|
+
const name = single(part);
|
|
62
|
+
// a scope of declarations by name loses one, while one is left
|
|
63
|
+
if (name !== null && scope.op === "decl" && scope.names.length > 1)
|
|
64
|
+
return { op: "decl", names: scope.names.filter((n) => n !== name) };
|
|
65
|
+
if (name !== null) {
|
|
66
|
+
// what was added goes: the name leaves the `or decl(…)` that added it
|
|
67
|
+
const back = dropName(scope, "or", name);
|
|
68
|
+
if (back) {
|
|
69
|
+
if (same?.(back, addName(back, "except", name)))
|
|
70
|
+
return back;
|
|
71
|
+
return addName(back, "except", name);
|
|
72
|
+
}
|
|
73
|
+
return addName(scope, "except", name);
|
|
74
|
+
}
|
|
75
|
+
if (scope.op === "or" && sameQuery(scope.b, part)) {
|
|
76
|
+
if (same?.(scope.a, { op: "except", a: scope.a, b: part }))
|
|
77
|
+
return scope.a;
|
|
78
|
+
return { op: "except", a: scope.a, b: part };
|
|
79
|
+
}
|
|
80
|
+
return { op: "except", a: scope, b: part };
|
|
81
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// The search (`/`): declarations by name first, then by what their statement says and what their
|
|
2
|
+
// docstring says. A reviewer asks "which statements mention Matrix.unitaryGroup?" as often as
|
|
3
|
+
// "where is flipAt?", and a docstring is where a paper's name and theorem number are written.
|
|
4
|
+
//
|
|
5
|
+
// Every word of the query has to be found, each in the name, the statement or the docstring. A
|
|
6
|
+
// declaration ranks by the weakest place it needed: all words in the name come first, then those
|
|
7
|
+
// that needed the statement, then those that needed the docstring. Each result shows that place
|
|
8
|
+
// around the word that needed it, the words marked.
|
|
9
|
+
//
|
|
10
|
+
// The texts are lowercased once per graph, offset for offset, so a keystroke is a plain substring
|
|
11
|
+
// scan, and a query that only adds to the last one looks through what the last one found.
|
|
12
|
+
//
|
|
13
|
+
// A unit is found by the names it folds too (ViewNode.also): `QOR.Name`, which Lean generated from
|
|
14
|
+
// the command that writes `QOR`, finds `QOR`, as a name does, and the hit says which (`via`).
|
|
15
|
+
//
|
|
16
|
+
// The page's search box (web/src/search.ts) and the agent API (server/agent.ts) both search so.
|
|
17
|
+
export const PLACES = ["name", "statement", "doc"];
|
|
18
|
+
/** `s` lowercased with every offset kept: the few letters that lowercase to two (`İ`) stay as
|
|
19
|
+
* they are, so that a place found in the lowercase text is the same place in `s`. */
|
|
20
|
+
export function lowerKeepingOffsets(s) {
|
|
21
|
+
const l = s.toLowerCase();
|
|
22
|
+
if (l.length === s.length)
|
|
23
|
+
return l;
|
|
24
|
+
let out = "";
|
|
25
|
+
for (const c of s) {
|
|
26
|
+
const lc = c.toLowerCase();
|
|
27
|
+
out += lc.length === c.length ? lc : c;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** The query's words, lowercased, each once. */
|
|
32
|
+
export function queryWords(q) {
|
|
33
|
+
return [...new Set(lowerKeepingOffsets(q).split(/\s+/).filter(Boolean))];
|
|
34
|
+
}
|
|
35
|
+
export class SearchIndex {
|
|
36
|
+
n;
|
|
37
|
+
/** per place (name, statement, docstring), each declaration's text, "" where it has none */
|
|
38
|
+
text;
|
|
39
|
+
/** the same, lowercased offset for offset */
|
|
40
|
+
lower;
|
|
41
|
+
/** where the externals start: they rank after the project's declarations of the same fit */
|
|
42
|
+
projectCount;
|
|
43
|
+
/** per declaration, the names it folds (Searchable.also), as they are and lowercased */
|
|
44
|
+
also;
|
|
45
|
+
alsoLower;
|
|
46
|
+
constructor(nodes, projectCount = nodes.length) {
|
|
47
|
+
this.n = nodes.length;
|
|
48
|
+
this.projectCount = projectCount;
|
|
49
|
+
this.text = [nodes.map((v) => v.name), nodes.map((v) => v.type ?? ""), nodes.map((v) => v.doc ?? "")];
|
|
50
|
+
this.lower = this.text.map((xs) => xs.map(lowerKeepingOffsets));
|
|
51
|
+
this.also = nodes.map((v) => v.also ?? []);
|
|
52
|
+
this.alsoLower = this.also.map((xs) => xs.map(lowerKeepingOffsets));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const IDENT = /[\p{L}\p{N}_'!?₀-₉ₐ-ₜ]/u;
|
|
56
|
+
const NOT_IDENT = /[^\p{L}\p{N}_'!?₀-₉ₐ-ₜ]/u;
|
|
57
|
+
/** Search the index. `within`: the declarations to look at (the last query's `matched`, when this
|
|
58
|
+
* one only adds to it); all of them when absent. */
|
|
59
|
+
export function search(ix, query, within) {
|
|
60
|
+
const words = queryWords(query);
|
|
61
|
+
const hits = { name: [], statement: [], doc: [] };
|
|
62
|
+
const matched = [];
|
|
63
|
+
const found = { query, words, hits, matched: new Int32Array(0) };
|
|
64
|
+
if (!words.length)
|
|
65
|
+
return found;
|
|
66
|
+
// the longest word first: it rules out the most at the first look
|
|
67
|
+
const order = [...words].sort((a, b) => b.length - a.length);
|
|
68
|
+
const [names, stmts, docs] = ix.lower;
|
|
69
|
+
const m = within ? within.length : ix.n;
|
|
70
|
+
for (let k = 0; k < m; k++) {
|
|
71
|
+
const i = within ? within[k] : k;
|
|
72
|
+
const also = ix.alsoLower[i];
|
|
73
|
+
/** the first name it folds that holds `w`, when its own name does not */
|
|
74
|
+
const folded = (w) => (names[i].includes(w) ? -1 : also.findIndex((a) => a.includes(w)));
|
|
75
|
+
// the weakest place a word needed (0 name, 1 statement, 2 docstring), the first word that
|
|
76
|
+
// needed it, and how many did
|
|
77
|
+
let tier = 0, word = order[0], need = 0, ok = true;
|
|
78
|
+
for (const w of order) {
|
|
79
|
+
const t = names[i].includes(w) || folded(w) >= 0 ? 0 : stmts[i].includes(w) ? 1 : docs[i].includes(w) ? 2 : -1;
|
|
80
|
+
if (t < 0) {
|
|
81
|
+
ok = false;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (t > tier)
|
|
85
|
+
[tier, word, need] = [t, w, 1];
|
|
86
|
+
else if (t === tier)
|
|
87
|
+
need++;
|
|
88
|
+
}
|
|
89
|
+
if (!ok)
|
|
90
|
+
continue;
|
|
91
|
+
matched.push(i);
|
|
92
|
+
const ext = i >= ix.projectCount;
|
|
93
|
+
let score;
|
|
94
|
+
let via;
|
|
95
|
+
if (tier === 0) {
|
|
96
|
+
// as the search always ranked a name: the words in its last component, exactly, at its start,
|
|
97
|
+
// inside it; then the project before the outside world; then the shorter name. A word found
|
|
98
|
+
// in a name it folds counts as found in that name.
|
|
99
|
+
let name = names[i];
|
|
100
|
+
for (const w of order) {
|
|
101
|
+
const a = folded(w);
|
|
102
|
+
if (a >= 0) {
|
|
103
|
+
name = also[a];
|
|
104
|
+
via = ix.also[i][a];
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const last = name.slice(name.lastIndexOf(".") + 1);
|
|
109
|
+
let fit = 0;
|
|
110
|
+
for (const w of order)
|
|
111
|
+
fit = Math.max(fit, last === w ? 0 : last.startsWith(w) ? 1 : last.includes(w) ? 2 : 3);
|
|
112
|
+
score = fit * 1000 + (ext ? 500 : 0) + Math.min(400, name.length);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// fewer words that needed this place first (the rest are in the name), then a word found
|
|
116
|
+
// whole rather than inside another, then the shorter text
|
|
117
|
+
const text = (tier === 1 ? stmts : docs)[i];
|
|
118
|
+
const at = text.indexOf(word);
|
|
119
|
+
const whole = at === 0 || !IDENT.test(text[at - 1]);
|
|
120
|
+
score = need * 1e7 + (whole ? 0 : 1e6) + Math.min(999_999, text.length);
|
|
121
|
+
}
|
|
122
|
+
hits[PLACES[tier]].push({ i, where: PLACES[tier], score, word, ...(via !== undefined ? { via } : {}) });
|
|
123
|
+
}
|
|
124
|
+
for (const w of PLACES)
|
|
125
|
+
hits[w].sort((a, b) => a.score - b.score || a.i - b.i);
|
|
126
|
+
found.matched = Int32Array.from(matched);
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
129
|
+
/** Whether `next` can only find what `prev` found: it adds to it (the words grow or more come). */
|
|
130
|
+
export function narrows(prev, next) {
|
|
131
|
+
const a = lowerKeepingOffsets(prev.trim()), b = lowerKeepingOffsets(next.trim());
|
|
132
|
+
return a.length > 0 && b.startsWith(a);
|
|
133
|
+
}
|
|
134
|
+
/** `text` from `from` to `to`, in pieces marked where the words occur (found in `lower`, its
|
|
135
|
+
* lowercase); runs of white space become one space. */
|
|
136
|
+
export function marked(text, lower, words, from = 0, to = text.length) {
|
|
137
|
+
const ranges = [];
|
|
138
|
+
for (const w of words) {
|
|
139
|
+
for (let at = lower.indexOf(w, Math.max(0, from - w.length + 1)); at >= 0 && at < to; at = lower.indexOf(w, at + 1)) {
|
|
140
|
+
ranges.push([Math.max(from, at), Math.min(to, at + w.length)]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
144
|
+
const pieces = [];
|
|
145
|
+
const push = (a, b, mark) => {
|
|
146
|
+
if (b > a)
|
|
147
|
+
pieces.push({ text: text.slice(a, b).replace(/\s+/g, " "), mark });
|
|
148
|
+
};
|
|
149
|
+
let pos = from;
|
|
150
|
+
for (let k = 0; k < ranges.length; k++) {
|
|
151
|
+
let [a, b] = ranges[k];
|
|
152
|
+
// overlapping words are one mark
|
|
153
|
+
while (k + 1 < ranges.length && ranges[k + 1][0] <= b)
|
|
154
|
+
b = Math.max(b, ranges[++k][1]);
|
|
155
|
+
a = Math.max(a, pos);
|
|
156
|
+
push(pos, a, false);
|
|
157
|
+
push(a, b, true);
|
|
158
|
+
pos = Math.max(pos, b);
|
|
159
|
+
}
|
|
160
|
+
push(pos, to, false);
|
|
161
|
+
return pieces;
|
|
162
|
+
}
|
|
163
|
+
const isLowSurrogate = (c) => c >= 0xdc00 && c <= 0xdfff;
|
|
164
|
+
/** A short stretch of `text` around the first `focus`, beginning at a word where one begins
|
|
165
|
+
* nearby, the words marked. `lead`: how much comes before the focus; `width`: how much in all
|
|
166
|
+
* (the row cuts what does not fit). */
|
|
167
|
+
export function snippet(text, lower, words, focus, width = 96, lead = 28) {
|
|
168
|
+
const at = Math.max(0, lower.indexOf(focus));
|
|
169
|
+
// close enough to the beginning: from the beginning
|
|
170
|
+
let start = at - lead <= 12 ? 0 : at - lead;
|
|
171
|
+
if (start > 0) {
|
|
172
|
+
// statements name everything in full, with no space for long stretches: a dot or a bracket is
|
|
173
|
+
// as good a place to begin
|
|
174
|
+
const gap = text.slice(start, at).search(NOT_IDENT);
|
|
175
|
+
if (gap >= 0 && gap < 12)
|
|
176
|
+
start += gap + 1;
|
|
177
|
+
// never between the two halves of a character
|
|
178
|
+
else if (isLowSurrogate(text.charCodeAt(start)))
|
|
179
|
+
start--;
|
|
180
|
+
}
|
|
181
|
+
let end = Math.min(text.length, Math.max(at + focus.length, start + width));
|
|
182
|
+
if (end < text.length && isLowSurrogate(text.charCodeAt(end)))
|
|
183
|
+
end++;
|
|
184
|
+
const pieces = marked(text, lower, words, start, end);
|
|
185
|
+
// the edges of the stretch: no white space where it is not cut
|
|
186
|
+
if (pieces.length && start === 0)
|
|
187
|
+
pieces[0].text = pieces[0].text.trimStart();
|
|
188
|
+
if (pieces.length && end === text.length)
|
|
189
|
+
pieces[pieces.length - 1].text = pieces[pieces.length - 1].text.trimEnd();
|
|
190
|
+
return { pieces: pieces.filter((p) => p.text), before: start > 0, after: end < text.length };
|
|
191
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Separating the children of one cluster: the page's scene (web/src/scene.ts, "the layout event")
|
|
2
|
+
// and the layout (shared/layout.ts, on the server and for boards on the page) both use it.
|
|
3
|
+
//
|
|
4
|
+
// Bodies are discs: a few large ones (sub-clusters) and up to a few thousand small ones
|
|
5
|
+
// (declarations, all of radius LEAF_R). A round pushes every overlapping pair apart; what holds its
|
|
6
|
+
// place (a drop) stays against what does not, and of two drops the later one stays. Only pairs
|
|
7
|
+
// touching something that moved in the previous round are looked at again, and small bodies find
|
|
8
|
+
// their neighbours through a hash grid kept in typed arrays, so a drop in a cluster of 2,000
|
|
9
|
+
// declarations settles in milliseconds rather than rebuilding everything every round.
|
|
10
|
+
/** Packed bodies touch exactly; only a real overlap counts. */
|
|
11
|
+
const SLACK = 0.5;
|
|
12
|
+
export class Separator {
|
|
13
|
+
head = new Int32Array(0);
|
|
14
|
+
next = new Int32Array(0);
|
|
15
|
+
mask = 0;
|
|
16
|
+
moved = new Uint8Array(0);
|
|
17
|
+
movedNext = new Uint8Array(0);
|
|
18
|
+
movedSmall = new Int32Array(0);
|
|
19
|
+
cell;
|
|
20
|
+
constructor(cell) {
|
|
21
|
+
this.cell = cell;
|
|
22
|
+
}
|
|
23
|
+
ensure(n, nSmall) {
|
|
24
|
+
if (this.next.length < n) {
|
|
25
|
+
this.next = new Int32Array(n);
|
|
26
|
+
this.moved = new Uint8Array(n);
|
|
27
|
+
this.movedNext = new Uint8Array(n);
|
|
28
|
+
this.movedSmall = new Int32Array(n);
|
|
29
|
+
}
|
|
30
|
+
let size = 64;
|
|
31
|
+
while (size < 2 * nSmall)
|
|
32
|
+
size <<= 1;
|
|
33
|
+
if (this.head.length !== size)
|
|
34
|
+
this.head = new Int32Array(size);
|
|
35
|
+
this.mask = size - 1;
|
|
36
|
+
}
|
|
37
|
+
key(x, y) {
|
|
38
|
+
return (Math.imul(Math.floor(x / this.cell), 73856093) ^ Math.imul(Math.floor(y / this.cell), 19349663)) & this.mask;
|
|
39
|
+
}
|
|
40
|
+
build(B) {
|
|
41
|
+
this.head.fill(-1);
|
|
42
|
+
for (let s = 0; s < B.small.length; s++) {
|
|
43
|
+
const k = B.small[s];
|
|
44
|
+
const h = this.key(B.x[k], B.y[k]);
|
|
45
|
+
this.next[k] = this.head[h];
|
|
46
|
+
this.head[h] = k;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Push a and b apart if they overlap; marks what moved. */
|
|
50
|
+
push(B, a, b) {
|
|
51
|
+
const dx = B.x[b] - B.x[a], dy = B.y[b] - B.y[a];
|
|
52
|
+
const need = B.r[a] + B.r[b];
|
|
53
|
+
const d2 = dx * dx + dy * dy;
|
|
54
|
+
if (d2 >= (need - SLACK) * (need - SLACK))
|
|
55
|
+
return false;
|
|
56
|
+
const pa = B.pinned[a], pb = B.pinned[b];
|
|
57
|
+
const fixA = pa && (!pb || B.seq[a] > B.seq[b]);
|
|
58
|
+
const fixB = pb && (!pa || B.seq[b] > B.seq[a]);
|
|
59
|
+
const d = Math.sqrt(d2) || 1e-3;
|
|
60
|
+
const over = need - d;
|
|
61
|
+
const ra = B.r[a] * B.r[a], rb = B.r[b] * B.r[b];
|
|
62
|
+
const wa = fixA ? 0 : fixB ? 1 : rb / (ra + rb);
|
|
63
|
+
const ux = d2 ? dx / d : 1, uy = d2 ? dy / d : 0;
|
|
64
|
+
if (wa > 0) {
|
|
65
|
+
B.x[a] -= ux * over * wa;
|
|
66
|
+
B.y[a] -= uy * over * wa;
|
|
67
|
+
this.movedNext[a] = 1;
|
|
68
|
+
}
|
|
69
|
+
if (wa < 1) {
|
|
70
|
+
B.x[b] += ux * over * (1 - wa);
|
|
71
|
+
B.y[b] += uy * over * (1 - wa);
|
|
72
|
+
this.movedNext[b] = 1;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
overlap(B, a, b) {
|
|
77
|
+
const dx = B.x[b] - B.x[a], dy = B.y[b] - B.y[a];
|
|
78
|
+
const need = B.r[a] + B.r[b] - SLACK;
|
|
79
|
+
return dx * dx + dy * dy < need * need;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Up to `rounds` rounds of separation. After each round a body further from the origin than
|
|
83
|
+
* `limit[k]` is brought back to it (null: no limit). Returns whether anything still moved in
|
|
84
|
+
* the last round.
|
|
85
|
+
*/
|
|
86
|
+
separate(B, rounds, limit) {
|
|
87
|
+
const { big, small } = B;
|
|
88
|
+
this.ensure(B.n, small.length);
|
|
89
|
+
const moved = this.moved, next = this.movedNext;
|
|
90
|
+
moved.fill(1, 0, B.n);
|
|
91
|
+
let any = false;
|
|
92
|
+
for (let round = 0; round < rounds; round++) {
|
|
93
|
+
any = false;
|
|
94
|
+
next.fill(0, 0, B.n);
|
|
95
|
+
this.build(B);
|
|
96
|
+
let nMovedSmall = 0;
|
|
97
|
+
for (let s = 0; s < small.length; s++)
|
|
98
|
+
if (moved[small[s]])
|
|
99
|
+
this.movedSmall[nMovedSmall++] = small[s];
|
|
100
|
+
// large against large, and against the small ones (all of them when the large one moved)
|
|
101
|
+
for (let p = 0; p < big.length; p++) {
|
|
102
|
+
const a = big[p];
|
|
103
|
+
for (let q = p + 1; q < big.length; q++) {
|
|
104
|
+
const b = big[q];
|
|
105
|
+
if ((moved[a] || moved[b]) && this.push(B, a, b))
|
|
106
|
+
any = true;
|
|
107
|
+
}
|
|
108
|
+
if (moved[a]) {
|
|
109
|
+
for (let s = 0; s < small.length; s++)
|
|
110
|
+
if (this.push(B, a, small[s]))
|
|
111
|
+
any = true;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
for (let s = 0; s < nMovedSmall; s++)
|
|
115
|
+
if (this.push(B, a, this.movedSmall[s]))
|
|
116
|
+
any = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// small against their grid neighbours, where one of the two moved
|
|
120
|
+
for (let s = 0; s < nMovedSmall; s++) {
|
|
121
|
+
const a = this.movedSmall[s];
|
|
122
|
+
const gx = Math.floor(B.x[a] / this.cell), gy = Math.floor(B.y[a] / this.cell);
|
|
123
|
+
for (let i = gx - 1; i <= gx + 1; i++) {
|
|
124
|
+
for (let j = gy - 1; j <= gy + 1; j++) {
|
|
125
|
+
const h = (Math.imul(i, 73856093) ^ Math.imul(j, 19349663)) & this.mask;
|
|
126
|
+
for (let b = this.head[h]; b >= 0; b = this.next[b]) {
|
|
127
|
+
if (b === a || (moved[b] && b < a))
|
|
128
|
+
continue;
|
|
129
|
+
if (this.push(B, a, b))
|
|
130
|
+
any = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (limit) {
|
|
136
|
+
for (let k = 0; k < B.n; k++) {
|
|
137
|
+
const d = Math.hypot(B.x[k], B.y[k]);
|
|
138
|
+
if (d > limit[k]) {
|
|
139
|
+
B.x[k] *= limit[k] / d;
|
|
140
|
+
B.y[k] *= limit[k] / d;
|
|
141
|
+
next[k] = 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
moved.set(next.subarray(0, B.n));
|
|
146
|
+
if (!any)
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
return any;
|
|
150
|
+
}
|
|
151
|
+
/** Whether any two bodies overlap (two drops on each other included). */
|
|
152
|
+
overlaps(B) {
|
|
153
|
+
const { big, small } = B;
|
|
154
|
+
this.ensure(B.n, small.length);
|
|
155
|
+
for (let p = 0; p < big.length; p++) {
|
|
156
|
+
for (let q = p + 1; q < big.length; q++)
|
|
157
|
+
if (this.overlap(B, big[p], big[q]))
|
|
158
|
+
return true;
|
|
159
|
+
for (let s = 0; s < small.length; s++)
|
|
160
|
+
if (this.overlap(B, big[p], small[s]))
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
this.build(B);
|
|
164
|
+
for (let s = 0; s < small.length; s++) {
|
|
165
|
+
const a = small[s];
|
|
166
|
+
const gx = Math.floor(B.x[a] / this.cell), gy = Math.floor(B.y[a] / this.cell);
|
|
167
|
+
for (let i = gx - 1; i <= gx + 1; i++) {
|
|
168
|
+
for (let j = gy - 1; j <= gy + 1; j++) {
|
|
169
|
+
const h = (Math.imul(i, 73856093) ^ Math.imul(j, 19349663)) & this.mask;
|
|
170
|
+
for (let b = this.head[h]; b >= 0; b = this.next[b])
|
|
171
|
+
if (b > a && this.overlap(B, a, b))
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|