visualproof 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -26
- package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
- package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
- package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
- package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/metrics.json +1 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
- package/dist/server/agent.js +125 -92
- package/dist/server/agentdocs.js +1 -1
- package/dist/server/artcmd.js +335 -0
- package/dist/server/history.js +39 -0
- package/dist/server/historyapi.js +2 -2
- package/dist/server/hover.js +3 -3
- package/dist/server/layoutthread.js +1 -1
- package/dist/server/live.js +6 -0
- package/dist/server/main.js +151 -11
- package/dist/server/mcptools.js +6 -4
- package/dist/server/pastdoc.js +1 -1
- package/dist/server/pipeline.js +18 -9
- package/dist/server/readonlygate.js +2 -2
- package/dist/server/review.js +4 -1
- package/dist/server/reviewapi.js +35 -1
- package/dist/server/snapshot.js +13 -3
- package/dist/server/usage.js +2 -2
- package/dist/server/views.js +25 -1
- package/dist/shared/artfonts.js +81 -0
- package/dist/shared/artrecipe.js +316 -0
- package/dist/shared/collab.js +99 -1
- package/dist/shared/declfind.js +95 -0
- package/dist/shared/doc/order.js +17 -0
- package/dist/shared/doc/outline.js +45 -0
- package/dist/shared/keys.js +1 -1
- package/dist/shared/lostfound.js +1 -0
- package/dist/shared/moves.js +2 -1
- package/dist/shared/pastgraph.js +9 -1
- package/dist/shared/query.js +21 -13
- package/dist/shared/review.js +62 -5
- package/dist/shared/sharesnap.js +2 -1
- package/dist/shared/titles.js +2 -0
- package/dist/shared/viewgraph.js +18 -0
- package/dist/web/bundle/app.js +7709 -3222
- package/dist/web/bundle/edges.js +4 -6
- package/dist/web/index.html +24 -6
- package/dist/web/src/agentmark.js +14 -0
- package/dist/web/src/artwork.js +1000 -0
- package/dist/web/src/bundles.js +151 -0
- package/dist/web/src/canvas.js +3694 -0
- package/dist/web/src/drag.js +61 -0
- package/dist/web/src/edgequeue.js +204 -0
- package/dist/web/src/gl/gl.js +123 -0
- package/dist/web/src/gl/renderer.js +451 -0
- package/dist/web/src/halo.js +95 -0
- package/dist/web/src/highlight.js +108 -0
- package/dist/web/src/overview.js +393 -0
- package/dist/web/src/reviewqueue.js +250 -0
- package/dist/web/src/scene.js +17 -0
- package/dist/web/src/selection.js +272 -0
- package/dist/web/src/status.js +80 -0
- package/dist/web/src/visitors.js +318 -0
- package/dist/web/src/visits.js +174 -0
- package/dist/web/style.css +255 -12
- package/docs/agent-api/README.md +1 -1
- package/docs/agent-api/export.md +41 -2
- package/docs/agent-api/following.md +2 -2
- package/docs/agent-api/history.md +2 -1
- package/docs/agent-api/mcp.md +1 -1
- package/docs/agent-api/reading.md +2 -1
- package/docs/agent-api/views.md +3 -2
- package/package.json +3 -1
- package/test/toolchains.json +2 -2
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Text measured as an artwork's fonts draw it, without a browser (ExportSpec decision 21): the
|
|
2
|
+
// advance widths of the faces the package ships (assets/webfonts/metrics.json, made by
|
|
3
|
+
// scripts/artfonts.py), for a CSS font as a canvas takes it. A face stands for its family at the
|
|
4
|
+
// nearest weight it is made at; a character it has not is measured in the faces that stand in for
|
|
5
|
+
// it (Noto Sans, Noto Sans Math), as the renderer draws it from them. No kerning: the renderer the
|
|
6
|
+
// command draws with is given none either.
|
|
7
|
+
/** the families an artwork's fonts name that the package has not, and the face drawn for them: the
|
|
8
|
+
* system's own for the board's names (web/src/canvas.ts FONT), and the generic ones */
|
|
9
|
+
const STANDS_FOR = {
|
|
10
|
+
"-apple-system": "Source Sans 3",
|
|
11
|
+
"blinkmacsystemfont": "Source Sans 3",
|
|
12
|
+
"sf pro text": "Source Sans 3",
|
|
13
|
+
"inter": "Source Sans 3",
|
|
14
|
+
"segoe ui": "Source Sans 3",
|
|
15
|
+
"sans-serif": "Source Sans 3",
|
|
16
|
+
"system-ui": "Source Sans 3",
|
|
17
|
+
"georgia": "Source Serif 4",
|
|
18
|
+
"times new roman": "Source Serif 4",
|
|
19
|
+
"serif": "Source Serif 4",
|
|
20
|
+
};
|
|
21
|
+
/** A CSS font as a 2D context takes it ("italic 600 12px \"Jost\", sans-serif"): its slant, weight,
|
|
22
|
+
* size in pixels, and families in order. */
|
|
23
|
+
export function parseFont(font) {
|
|
24
|
+
const m = /^\s*((?:(?:italic|oblique|normal|bold|bolder|lighter|\d{3})\s+)*)([\d.]+)px(?:\/[^\s]+)?\s+(.+)$/i.exec(font);
|
|
25
|
+
if (!m)
|
|
26
|
+
return { style: "normal", weight: 400, px: 10, families: ["sans-serif"] };
|
|
27
|
+
const words = m[1].trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
28
|
+
const style = words.includes("italic") || words.includes("oblique") ? "italic" : "normal";
|
|
29
|
+
const num = words.find((w) => /^\d{3}$/.test(w));
|
|
30
|
+
const weight = num ? Number(num) : words.includes("bold") ? 700 : 400;
|
|
31
|
+
const families = m[3].split(",").map((f) => f.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
32
|
+
return { style, weight, px: Number(m[2]), families };
|
|
33
|
+
}
|
|
34
|
+
/** How wide `text` is in `font`, in pixels, as the package's faces draw it. */
|
|
35
|
+
export function textMeasurer(metrics) {
|
|
36
|
+
const families = new Map();
|
|
37
|
+
for (const f of metrics.faces)
|
|
38
|
+
families.set(f.family.toLowerCase(), [...(families.get(f.family.toLowerCase()) ?? []), f]);
|
|
39
|
+
const chosen = new Map();
|
|
40
|
+
/** the face a font is drawn in: its first family the package has (or stands one in for), at the
|
|
41
|
+
* slant asked where it is made in it, at the nearest weight */
|
|
42
|
+
const faceOf = (font) => {
|
|
43
|
+
const hit = chosen.get(font);
|
|
44
|
+
if (hit)
|
|
45
|
+
return hit;
|
|
46
|
+
const f = parseFont(font);
|
|
47
|
+
let set;
|
|
48
|
+
for (const fam of f.families) {
|
|
49
|
+
const k = fam.toLowerCase();
|
|
50
|
+
set = families.get(k) ?? families.get(STANDS_FOR[k]?.toLowerCase() ?? "");
|
|
51
|
+
if (set)
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
set ??= families.get("source sans 3") ?? metrics.faces;
|
|
55
|
+
const slanted = set.filter((x) => x.style === f.style);
|
|
56
|
+
const pool = slanted.length ? slanted : set;
|
|
57
|
+
const face = pool.reduce((a, b) => (Math.abs(b.weight - f.weight) < Math.abs(a.weight - f.weight) ? b : a));
|
|
58
|
+
const out = { face, px: f.px };
|
|
59
|
+
chosen.set(font, out);
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
return (text, font) => {
|
|
63
|
+
const { face, px } = faceOf(font);
|
|
64
|
+
let w = 0;
|
|
65
|
+
for (const ch of text) {
|
|
66
|
+
const cp = String(ch.codePointAt(0));
|
|
67
|
+
let units = face.widths[cp];
|
|
68
|
+
let upm = face.unitsPerEm;
|
|
69
|
+
if (units === undefined)
|
|
70
|
+
for (const fb of metrics.fallback)
|
|
71
|
+
if (fb.widths[cp] !== undefined) {
|
|
72
|
+
units = fb.widths[cp];
|
|
73
|
+
upm = fb.unitsPerEm;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
// a character no face has: about an en, as a renderer's missing glyph is drawn
|
|
77
|
+
w += ((units ?? face.unitsPerEm * 0.55) / upm) * px;
|
|
78
|
+
}
|
|
79
|
+
return w;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// An artwork recipe (ExportSpec decision 20, drafted 2026-10-15 for the owner's review): what a
|
|
2
|
+
// picture of the board is, in words that hold on any project — a size by its name, names by a rule
|
|
3
|
+
// ("the clusters to depth 1, and the targets"), targets and the selection by full name — so that
|
|
4
|
+
// one recipe makes the same kind of picture of every codebase. An agent sends one
|
|
5
|
+
// (POST /api/agent/artwork, server/agent.ts); the link it gets back carries it after `#`, and the
|
|
6
|
+
// page that opens it resolves it against the board it opens on (web/src/main.ts, openRecipe) and
|
|
7
|
+
// opens the artwork mode with what it says, saving the files where the link asks.
|
|
8
|
+
import { ART_MAX_SIDE, ART_NAMES_BYTES, ART_RULED_BYTES, ART_STYLES, ART_TARGETS_BYTES, artNameId, cleanNames, FOOT_SIZE, SCHEMES, TITLE_CHARS, TITLE_SIZE } from "./collab.js";
|
|
9
|
+
/** The sizes offered (web/src/artwork.ts, PRESETS), each in either orientation: given landscape;
|
|
10
|
+
* `key`, a recipe's name for it. */
|
|
11
|
+
export const ART_PRESETS = [
|
|
12
|
+
{ key: "hd", name: "HD", w: 1920, h: 1080 },
|
|
13
|
+
{ key: "4k", name: "4K", w: 3840, h: 2160 },
|
|
14
|
+
{ key: "square", name: "Square", w: 4096, h: 4096 },
|
|
15
|
+
{ key: "a3", name: "A3, 300 dpi", w: 4961, h: 3508 },
|
|
16
|
+
{ key: "a2", name: "A2, 300 dpi", w: 7016, h: 4961 },
|
|
17
|
+
];
|
|
18
|
+
/** How a click selects, as the address names it (web/src/selection.ts, TOOLS): Selection; Rests on's
|
|
19
|
+
* Uses, TCB, Keys and Direct; Used by. */
|
|
20
|
+
export const RECIPE_MODES = ["plain", "deps", "tcb", "keys", "refs", "apps"];
|
|
21
|
+
/** The most a recipe takes, in bytes of its JSON: the link carries it. */
|
|
22
|
+
export const RECIPE_BYTES = 6000;
|
|
23
|
+
const FIELDS = ["size", "orientation", "style", "theme", "transparent", "colouring", "border", "title", "subtitle", "titleSize", "footSize", "source", "names", "targets", "keys", "notes", "edges", "selection", "lit", "view"];
|
|
24
|
+
const NAME_FIELDS = ["from", "clusters", "minShare", "targets", "decls", "omit"];
|
|
25
|
+
/** A recipe as given, checked: each field as it says, nothing unknown (a field misspelt would
|
|
26
|
+
* otherwise be a picture silently unlike the one meant). */
|
|
27
|
+
export function readRecipe(v) {
|
|
28
|
+
const bad = (error, hint) => ({ error, hint });
|
|
29
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
30
|
+
return bad("recipe is an object.", 'E.g. {"size": "a2", "orientation": "portrait", "names": {"clusters": 1}}.');
|
|
31
|
+
if (new TextEncoder().encode(JSON.stringify(v)).length > RECIPE_BYTES)
|
|
32
|
+
return bad(`A recipe is at most ${RECIPE_BYTES} bytes.`, "Name clusters by a depth rather than one by one.");
|
|
33
|
+
const o = v;
|
|
34
|
+
const unknown = Object.keys(o).filter((k) => !FIELDS.includes(k));
|
|
35
|
+
if (unknown.length)
|
|
36
|
+
return bad(`recipe has no field ${unknown.join(", ")}.`, `Its fields: ${FIELDS.join(", ")}.`);
|
|
37
|
+
const r = {};
|
|
38
|
+
const bool = (k) => {
|
|
39
|
+
if (o[k] === undefined)
|
|
40
|
+
return null;
|
|
41
|
+
if (typeof o[k] !== "boolean")
|
|
42
|
+
return `${k} is true or false.`;
|
|
43
|
+
r[k] = o[k];
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
const oneOf = (k, of) => {
|
|
47
|
+
if (o[k] === undefined)
|
|
48
|
+
return null;
|
|
49
|
+
if (!of.includes(o[k]))
|
|
50
|
+
return `${k} is ${of.join(", ")}.`;
|
|
51
|
+
r[k] = o[k];
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
const num = (k, least, most) => {
|
|
55
|
+
if (o[k] === undefined)
|
|
56
|
+
return null;
|
|
57
|
+
if (typeof o[k] !== "number" || !Number.isFinite(o[k]) || o[k] < least || o[k] > most)
|
|
58
|
+
return `${k} is a number from ${least} to ${most} (hundredths of the picture's height).`;
|
|
59
|
+
r[k] = o[k];
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
const text = (k) => {
|
|
63
|
+
if (o[k] === undefined)
|
|
64
|
+
return null;
|
|
65
|
+
if (o[k] !== null && (typeof o[k] !== "string" || o[k].length > TITLE_CHARS))
|
|
66
|
+
return `${k} is text of at most ${TITLE_CHARS} characters, "" for none, or null for the project's own.`;
|
|
67
|
+
r[k] = o[k];
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
const names = (x) => Array.isArray(x) && x.length <= 2000 && x.every((n) => typeof n === "string" && n.length > 0 && n.length <= 1000);
|
|
71
|
+
const fault = bool("transparent") ?? bool("border") ?? bool("source") ?? bool("keys") ?? bool("notes") ?? bool("lit") ??
|
|
72
|
+
oneOf("orientation", ["landscape", "portrait"]) ?? oneOf("style", ART_STYLES) ?? oneOf("theme", ["light", "dark"]) ??
|
|
73
|
+
oneOf("colouring", SCHEMES) ?? oneOf("edges", ["all", "selection", "none"]) ??
|
|
74
|
+
num("titleSize", TITLE_SIZE.least, TITLE_SIZE.most) ?? num("footSize", FOOT_SIZE.least, FOOT_SIZE.most) ?? text("title") ?? text("subtitle");
|
|
75
|
+
if (fault)
|
|
76
|
+
return bad(fault, "See GET /api/agent/docs/export, \"Artwork\".");
|
|
77
|
+
if (o.size !== undefined) {
|
|
78
|
+
const s = o.size;
|
|
79
|
+
if (typeof s === "string") {
|
|
80
|
+
if (!ART_PRESETS.some((p) => p.key === s))
|
|
81
|
+
return bad(`size ${s} is no size.`, `A size by name (${ART_PRESETS.map((p) => p.key).join(", ")}) or {w, h} in pixels.`);
|
|
82
|
+
r.size = s;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const sw = s?.w, sh = s?.h;
|
|
86
|
+
const side = (x) => Number.isInteger(x) && x >= 16 && x <= ART_MAX_SIDE;
|
|
87
|
+
if (!side(sw) || !side(sh))
|
|
88
|
+
return bad(`size is a name or {w, h}: whole pixels from 16 to ${ART_MAX_SIDE}.`, `Names: ${ART_PRESETS.map((p) => p.key).join(", ")}.`);
|
|
89
|
+
r.size = { w: sw, h: sh };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (o.names !== undefined) {
|
|
93
|
+
const n = o.names;
|
|
94
|
+
if (n === "board" || n === "clusters" || n === "none")
|
|
95
|
+
r.names = n;
|
|
96
|
+
else if (n && typeof n === "object" && !Array.isArray(n)) {
|
|
97
|
+
const m = n;
|
|
98
|
+
const extra = Object.keys(m).filter((k) => !NAME_FIELDS.includes(k));
|
|
99
|
+
if (extra.length)
|
|
100
|
+
return bad(`names has no field ${extra.join(", ")}.`, `Its fields: ${NAME_FIELDS.join(", ")}.`);
|
|
101
|
+
const rule = {};
|
|
102
|
+
if (m.from !== undefined) {
|
|
103
|
+
if (m.from !== "none" && m.from !== "board")
|
|
104
|
+
return bad("names.from is none or board.", "From none: only what the rule names; from board: what the board writes, the rule's besides.");
|
|
105
|
+
rule.from = m.from;
|
|
106
|
+
}
|
|
107
|
+
if (m.clusters !== undefined) {
|
|
108
|
+
if (Number.isInteger(m.clusters) && m.clusters >= 0 && m.clusters <= 20)
|
|
109
|
+
rule.clusters = m.clusters;
|
|
110
|
+
else if (names(m.clusters))
|
|
111
|
+
rule.clusters = m.clusters;
|
|
112
|
+
else
|
|
113
|
+
return bad("names.clusters is a depth (0: the top level) or a list of clusters' keys.", "E.g. 1: the top level and the clusters right under it.");
|
|
114
|
+
}
|
|
115
|
+
if (m.minShare !== undefined) {
|
|
116
|
+
if (typeof m.minShare !== "number" || !(m.minShare >= 0 && m.minShare <= 1))
|
|
117
|
+
return bad("names.minShare is a share from 0 to 1.", "0.01: a cluster below the top named where it holds at least 1% of the project's declarations.");
|
|
118
|
+
rule.minShare = m.minShare;
|
|
119
|
+
}
|
|
120
|
+
if (m.targets !== undefined) {
|
|
121
|
+
if (typeof m.targets !== "boolean")
|
|
122
|
+
return bad("names.targets is true or false.", "The shown targets' names.");
|
|
123
|
+
rule.targets = m.targets;
|
|
124
|
+
}
|
|
125
|
+
for (const k of ["decls", "omit"]) {
|
|
126
|
+
if (m[k] === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
if (!names(m[k]))
|
|
129
|
+
return bad(`names.${k} is a list of names.`, k === "decls" ? "Declarations by full name." : "Clusters by key, declarations by full name.");
|
|
130
|
+
rule[k] = m[k];
|
|
131
|
+
}
|
|
132
|
+
r.names = rule;
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
return bad('names is "board", "clusters", "none" or a rule {from, clusters, minShare, targets, decls, omit}.', 'E.g. {"clusters": 1}: the top level and the clusters right under it, and the targets.');
|
|
136
|
+
}
|
|
137
|
+
if (o.targets !== undefined) {
|
|
138
|
+
const t = o.targets;
|
|
139
|
+
if (t === "all" || t === "none" || t === "faded")
|
|
140
|
+
r.targets = t;
|
|
141
|
+
else if (names(t))
|
|
142
|
+
r.targets = t;
|
|
143
|
+
else
|
|
144
|
+
return bad('targets is "all", "none", "faded" or a list of targets\' full names.', "Those named are marked, the others drawn as any declaration.");
|
|
145
|
+
}
|
|
146
|
+
if (o.selection !== undefined && o.selection !== null) {
|
|
147
|
+
const s = o.selection;
|
|
148
|
+
if (!s || typeof s !== "object" || typeof s.name !== "string" || !s.name || (s.mode !== undefined && !RECIPE_MODES.includes(s.mode)) || Object.keys(s).some((k) => k !== "name" && k !== "mode"))
|
|
149
|
+
return bad("selection is {name, mode?}: a declaration's full name, and how it is selected.", `mode: ${RECIPE_MODES.join(", ")} (default tcb).`);
|
|
150
|
+
r.selection = { name: s.name, ...(s.mode !== undefined ? { mode: s.mode } : {}) };
|
|
151
|
+
}
|
|
152
|
+
else if (o.selection === null)
|
|
153
|
+
r.selection = null;
|
|
154
|
+
if (o.view !== undefined && o.view !== null) {
|
|
155
|
+
if (typeof o.view !== "string" || !o.view || o.view.length > 200)
|
|
156
|
+
return bad("view is a view's id.", "GET /api/agent/views lists them; left out, the Project.");
|
|
157
|
+
r.view = o.view;
|
|
158
|
+
}
|
|
159
|
+
return { recipe: r };
|
|
160
|
+
}
|
|
161
|
+
/** the outside world's cluster, as a reader may call it */
|
|
162
|
+
const EXTERNAL = "⟨external⟩";
|
|
163
|
+
const keyOf = (k) => (k === "External" ? EXTERNAL : k.startsWith("External.") ? EXTERNAL + k.slice(8) : k);
|
|
164
|
+
/** A recipe resolved against a board: everything it leaves out as a new artwork has it (4K
|
|
165
|
+
* landscape, the board's look, light, names as the board writes them, every target marked). Names
|
|
166
|
+
* are written from none: the clusters named by key, the declarations and the targets' names
|
|
167
|
+
* wherever they fall (ArtworkLook `shownNames`), as names ticked by hand are; the clusters a depth
|
|
168
|
+
* rule names — the outside world's own, and the project's down to the depth that hold `minShare`
|
|
169
|
+
* of the project — only where they cover no name already written (`ruledNames`; the owner's
|
|
170
|
+
* decision of 2026-10-15: names chosen by a rule never overlap). */
|
|
171
|
+
export function resolveRecipe(r, b) {
|
|
172
|
+
const problems = [];
|
|
173
|
+
const preset = typeof r.size === "string" ? ART_PRESETS.find((p) => p.key === r.size) : ART_PRESETS[1];
|
|
174
|
+
const given = typeof r.size === "object" ? r.size : { w: preset.w, h: preset.h };
|
|
175
|
+
const portrait = r.orientation ? r.orientation === "portrait" : given.h > given.w;
|
|
176
|
+
const [long, short] = given.w >= given.h ? [given.w, given.h] : [given.h, given.w];
|
|
177
|
+
const { w, h } = portrait ? { w: short, h: long } : { w: long, h: short };
|
|
178
|
+
// what the recipe names that the board has not is said (`problems`); what is left out for room,
|
|
179
|
+
// counted (`left`); a target of the review the board has not — a target outlives its declaration's
|
|
180
|
+
// rename or removal — is left out unsaid, as the board leaves it
|
|
181
|
+
let left = 0;
|
|
182
|
+
const targetSet = new Set(b.targets);
|
|
183
|
+
const held = (n) => b.clusterOf(n) !== undefined;
|
|
184
|
+
let targets = "marked";
|
|
185
|
+
let shownTargets = [];
|
|
186
|
+
let marked = b.targets.filter(held);
|
|
187
|
+
if (r.targets === "none")
|
|
188
|
+
[targets, marked] = ["plain", []];
|
|
189
|
+
else if (r.targets === "faded")
|
|
190
|
+
targets = "highlight";
|
|
191
|
+
else if (Array.isArray(r.targets)) {
|
|
192
|
+
const unknown = r.targets.filter((t) => !targetSet.has(t));
|
|
193
|
+
if (unknown.length)
|
|
194
|
+
problems.push(`not targets of the review: ${unknown.join(", ")}`);
|
|
195
|
+
const off = r.targets.filter((t) => targetSet.has(t) && !held(t));
|
|
196
|
+
if (off.length)
|
|
197
|
+
problems.push(`targets not on the board: ${off.join(", ")}`);
|
|
198
|
+
const on = r.targets.filter((t) => targetSet.has(t) && held(t));
|
|
199
|
+
shownTargets = cleanNames(on, ART_TARGETS_BYTES);
|
|
200
|
+
left += new Set(on).size - shownTargets.length;
|
|
201
|
+
targets = "chosen";
|
|
202
|
+
marked = shownTargets;
|
|
203
|
+
}
|
|
204
|
+
// the names
|
|
205
|
+
let labels = "board";
|
|
206
|
+
let namesFrom = "board";
|
|
207
|
+
let shown = [];
|
|
208
|
+
let hidden = [];
|
|
209
|
+
let ruledIds = [];
|
|
210
|
+
const named = { clusters: [], ruled: [], decls: [] };
|
|
211
|
+
const n = r.names;
|
|
212
|
+
if (n === "board" || n === "clusters" || n === "none")
|
|
213
|
+
labels = n;
|
|
214
|
+
else if (n) {
|
|
215
|
+
labels = "chosen";
|
|
216
|
+
namesFrom = n.from ?? "none";
|
|
217
|
+
const omit = new Set((n.omit ?? []).map(keyOf));
|
|
218
|
+
const byKey = new Map(b.clusters.map((c, i) => [c.key, i]));
|
|
219
|
+
if (typeof n.clusters === "number") {
|
|
220
|
+
const least = Math.ceil((n.minShare ?? 0.01) * b.projectCount);
|
|
221
|
+
b.clusters.forEach((c) => {
|
|
222
|
+
if ((c.ext ? c.depth === 0 : c.depth <= n.clusters && c.count >= least) && !omit.has(c.key))
|
|
223
|
+
named.ruled.push(c.key);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else if (Array.isArray(n.clusters)) {
|
|
227
|
+
for (const k of n.clusters) {
|
|
228
|
+
const key = keyOf(k);
|
|
229
|
+
if (!byKey.has(key))
|
|
230
|
+
problems.push(`no cluster ${k} on the board`);
|
|
231
|
+
else if (!omit.has(key) && !named.clusters.includes(key))
|
|
232
|
+
named.clusters.push(key);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// the targets' names (all on the board), and the declarations named
|
|
236
|
+
const decls = n.targets === false ? [] : [...marked];
|
|
237
|
+
for (const d of n.decls ?? []) {
|
|
238
|
+
if (!held(d))
|
|
239
|
+
problems.push(`no declaration ${d} on the board`);
|
|
240
|
+
else
|
|
241
|
+
decls.push(d);
|
|
242
|
+
}
|
|
243
|
+
for (const d of decls)
|
|
244
|
+
if (!omit.has(d) && !named.decls.includes(d))
|
|
245
|
+
named.decls.push(d);
|
|
246
|
+
const ids = [...named.clusters.map((k) => artNameId("cluster", k)), ...named.decls.map((d) => artNameId("decl", d))];
|
|
247
|
+
shown = cleanNames(ids, ART_NAMES_BYTES);
|
|
248
|
+
const ruled = named.ruled.map((k) => artNameId("cluster", k));
|
|
249
|
+
ruledIds = cleanNames(ruled, ART_RULED_BYTES);
|
|
250
|
+
left += ids.length + ruled.length - shown.length - ruledIds.length;
|
|
251
|
+
// what is named is what the artwork carries
|
|
252
|
+
const kept = new Set([...shown, ...ruledIds]);
|
|
253
|
+
named.clusters = named.clusters.filter((k) => kept.has(artNameId("cluster", k)));
|
|
254
|
+
named.ruled = named.ruled.filter((k) => kept.has(artNameId("cluster", k)));
|
|
255
|
+
named.decls = named.decls.filter((d) => kept.has(artNameId("decl", d)));
|
|
256
|
+
// from the board, what to leave out is said as hidden
|
|
257
|
+
if (namesFrom === "board")
|
|
258
|
+
hidden = cleanNames([...omit].map((k) => (byKey.has(k) ? artNameId("cluster", k) : artNameId("decl", k))), ART_NAMES_BYTES);
|
|
259
|
+
}
|
|
260
|
+
const look = {
|
|
261
|
+
w,
|
|
262
|
+
h,
|
|
263
|
+
scheme: r.colouring ?? "kind",
|
|
264
|
+
theme: r.theme ?? "light",
|
|
265
|
+
background: r.transparent ? "transparent" : "theme",
|
|
266
|
+
labels,
|
|
267
|
+
namesFrom,
|
|
268
|
+
hiddenNames: hidden,
|
|
269
|
+
shownNames: shown,
|
|
270
|
+
ruledNames: ruledIds,
|
|
271
|
+
edges: r.edges ?? "all",
|
|
272
|
+
notes: r.notes ?? true,
|
|
273
|
+
targets,
|
|
274
|
+
shownTargets,
|
|
275
|
+
keys: r.keys ?? true,
|
|
276
|
+
selection: r.lit ?? true,
|
|
277
|
+
source: r.source ?? false,
|
|
278
|
+
style: r.style ?? "board",
|
|
279
|
+
title: r.title ?? null,
|
|
280
|
+
subtitle: r.subtitle ?? null,
|
|
281
|
+
titleSize: r.titleSize ?? TITLE_SIZE.first,
|
|
282
|
+
footSize: r.footSize ?? FOOT_SIZE.first,
|
|
283
|
+
border: r.border ?? false,
|
|
284
|
+
};
|
|
285
|
+
return { look, named, left, problems };
|
|
286
|
+
}
|
|
287
|
+
/** A recipe as the link carries it after `#`: its JSON, base64url. */
|
|
288
|
+
export function encodeRecipe(r) {
|
|
289
|
+
const bytes = new TextEncoder().encode(JSON.stringify(r));
|
|
290
|
+
let s = "";
|
|
291
|
+
for (const x of bytes)
|
|
292
|
+
s += String.fromCharCode(x);
|
|
293
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
294
|
+
}
|
|
295
|
+
/** A link's recipe, read back; null where it is none. */
|
|
296
|
+
export function decodeRecipe(text) {
|
|
297
|
+
try {
|
|
298
|
+
const s = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
299
|
+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(s, (c) => c.charCodeAt(0))));
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** the address's word for the recipe, and for the files to save */
|
|
306
|
+
export const RECIPE_PARAM = "art";
|
|
307
|
+
export const SAVE_PARAM = "save";
|
|
308
|
+
/** A fragment's recipe and the files it asks to save; null where it carries none. */
|
|
309
|
+
export function recipeIn(hash) {
|
|
310
|
+
const p = new URLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash);
|
|
311
|
+
const text = p.get(RECIPE_PARAM);
|
|
312
|
+
if (!text)
|
|
313
|
+
return null;
|
|
314
|
+
const save = (p.get(SAVE_PARAM) ?? "").split(",").filter((k) => k === "png" || k === "svg");
|
|
315
|
+
return { text, save: [...new Set(save)] };
|
|
316
|
+
}
|
package/dist/shared/collab.js
CHANGED
|
@@ -63,6 +63,103 @@ export function colorOf(name) {
|
|
|
63
63
|
/** the most source windows a reader pins at once (web/src/sources.ts): a few declarations side by
|
|
64
64
|
* side, not a wall of them */
|
|
65
65
|
export const MAX_PINNED = 6;
|
|
66
|
+
/** An artwork's ways of being drawn (ArtworkLook `style`). */
|
|
67
|
+
export const ART_STYLES = ["board", "cells", "stars"];
|
|
68
|
+
/** the title's size, in hundredths of the picture's height: the least, the most, the first */
|
|
69
|
+
export const TITLE_SIZE = { least: 3, most: 16, first: 7 };
|
|
70
|
+
/** the foot's size, in hundredths of the picture's height: the least, the most, the first */
|
|
71
|
+
export const FOOT_SIZE = { least: 0.8, most: 4, first: 1.6 };
|
|
72
|
+
/** the most characters a title, or the line under it, has */
|
|
73
|
+
export const TITLE_CHARS = 120;
|
|
74
|
+
/** An artwork's title or its line as given: the reader's words, at most so long, or null (the
|
|
75
|
+
* project's own). */
|
|
76
|
+
export function cleanTitle(v) {
|
|
77
|
+
return typeof v === "string" ? v.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, TITLE_CHARS) : null;
|
|
78
|
+
}
|
|
79
|
+
/** An artwork's title size as given, within its bounds; the first where it is none. */
|
|
80
|
+
export function cleanTitleSize(v) {
|
|
81
|
+
return typeof v === "number" && Number.isFinite(v) ? Math.min(TITLE_SIZE.most, Math.max(TITLE_SIZE.least, Math.round(v * 2) / 2)) : TITLE_SIZE.first;
|
|
82
|
+
}
|
|
83
|
+
/** An artwork's foot size as given, within its bounds, to a tenth; the first where it is none. */
|
|
84
|
+
export function cleanFootSize(v) {
|
|
85
|
+
return typeof v === "number" && Number.isFinite(v) ? Math.min(FOOT_SIZE.most, Math.max(FOOT_SIZE.least, Math.round(v * 10) / 10)) : FOOT_SIZE.first;
|
|
86
|
+
}
|
|
87
|
+
/** What a name of an artwork's names (ArtworkLook `names`) names: a cluster, by its key, or a
|
|
88
|
+
* declaration, by its full name. */
|
|
89
|
+
export function artNameId(kind, s) {
|
|
90
|
+
return `${kind === "cluster" ? "c" : "d"}:${s}`;
|
|
91
|
+
}
|
|
92
|
+
/** The most an artwork's names hidden, its names shown, and its targets chosen each take in what
|
|
93
|
+
* presence carries, in bytes of their JSON: a heartbeat is at most 16 KB (server/main.ts), and the
|
|
94
|
+
* rest of where a reader is has room beside them. Names by selection are kept as what differs from
|
|
95
|
+
* As shown, so that a few names hidden or added are a few names carried. */
|
|
96
|
+
export const ART_NAMES_BYTES = 4000;
|
|
97
|
+
export const ART_TARGETS_BYTES = 2500;
|
|
98
|
+
/** the names a recipe's rule chose (ArtworkLook `ruledNames`): a rule names a few clusters. The four
|
|
99
|
+
* lists full, with the longest title and line, leave a heartbeat room (test/sharesnap.test.ts) */
|
|
100
|
+
export const ART_RULED_BYTES = 1000;
|
|
101
|
+
/** What `x` adds to a list's JSON: itself, quoted, and a comma. */
|
|
102
|
+
const jsonBytes = (x) => new TextEncoder().encode(JSON.stringify(x)).length + 1;
|
|
103
|
+
/** A list of names as given, checked: strings, each once, none empty or longer than a name is (1,000
|
|
104
|
+
* characters), each as `ok` says; kept in their order while their JSON stays within `budget` bytes,
|
|
105
|
+
* the rest left out. */
|
|
106
|
+
export function cleanNames(v, budget, ok = () => true) {
|
|
107
|
+
const out = [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
let used = 2;
|
|
110
|
+
for (const x of Array.isArray(v) ? v : []) {
|
|
111
|
+
if (typeof x !== "string" || !x || x.length > 1000 || seen.has(x) || !ok(x))
|
|
112
|
+
continue;
|
|
113
|
+
const b = jsonBytes(x);
|
|
114
|
+
if (used + b > budget)
|
|
115
|
+
break;
|
|
116
|
+
used += b;
|
|
117
|
+
seen.add(x);
|
|
118
|
+
out.push(x);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/** a name of an artwork's names: a cluster's or a declaration's (artNameId) */
|
|
123
|
+
export const isArtNameId = (s) => /^[cd]:./.test(s);
|
|
124
|
+
/** the most pixels a side of an artwork has */
|
|
125
|
+
export const ART_MAX_SIDE = 16384;
|
|
126
|
+
/** A place with no artwork in it: an agent's (ExportSpec decision 19, agents have no artwork). */
|
|
127
|
+
export function withoutArtwork(v) {
|
|
128
|
+
if (!v || !("artwork" in v))
|
|
129
|
+
return v;
|
|
130
|
+
const { artwork: _artwork, ...rest } = v;
|
|
131
|
+
return rest;
|
|
132
|
+
}
|
|
133
|
+
/** An artwork's settings as given, checked; null where they are not. */
|
|
134
|
+
export function cleanArtwork(v) {
|
|
135
|
+
if (!v || typeof v !== "object")
|
|
136
|
+
return null;
|
|
137
|
+
const o = v;
|
|
138
|
+
const one = (xs, x) => (xs.includes(x) ? x : null);
|
|
139
|
+
const side = (x) => (Number.isInteger(x) && x >= 16 && x <= ART_MAX_SIDE ? x : null);
|
|
140
|
+
const r = Array.isArray(o.region) && o.region.length === 4 && o.region.every((n) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) < 1e9) ? o.region : null;
|
|
141
|
+
const w = side(o.w), h = side(o.h), scheme = one(SCHEMES, o.scheme), theme = one(["light", "dark"], o.theme);
|
|
142
|
+
const background = one(["theme", "transparent"], o.background), labels = one(["board", "clusters", "none", "chosen"], o.labels);
|
|
143
|
+
const edges = one(["all", "selection", "none"], o.edges), targets = one(["marked", "plain", "highlight", "chosen"], o.targets);
|
|
144
|
+
if (!r || r[2] <= 0 || r[3] <= 0 || !w || !h || !scheme || !theme || !background || !labels || !edges || !targets)
|
|
145
|
+
return null;
|
|
146
|
+
// (a proof edges' switch, said until 2026-10-15, is no more: an artwork draws none)
|
|
147
|
+
if (typeof o.notes !== "boolean" || typeof o.selection !== "boolean")
|
|
148
|
+
return null;
|
|
149
|
+
// the source, said since 2026-10-15: off where a page does not say it. The names and targets chosen
|
|
150
|
+
// and the key marks' switch, said since the evening of that day: none chosen where a page does not
|
|
151
|
+
// say them, and the key marks as the targets drew them before (with their stars, unless As any)
|
|
152
|
+
const namesFrom = o.namesFrom === "none" ? "none" : "board";
|
|
153
|
+
const hiddenNames = cleanNames(o.hiddenNames, ART_NAMES_BYTES, isArtNameId), shownNames = cleanNames(o.shownNames, ART_NAMES_BYTES, isArtNameId);
|
|
154
|
+
// the names a recipe's rule chose, said since later that night: none where a page does not say them
|
|
155
|
+
const ruledNames = cleanNames(o.ruledNames, ART_RULED_BYTES, isArtNameId);
|
|
156
|
+
const shownTargets = cleanNames(o.shownTargets, ART_TARGETS_BYTES);
|
|
157
|
+
const keys = typeof o.keys === "boolean" ? o.keys : targets !== "plain";
|
|
158
|
+
// the style and the title, said since that night: as the board, the project's own; the foot's
|
|
159
|
+
// size, said since later that night: the first
|
|
160
|
+
const style = one(ART_STYLES, o.style) ?? "board";
|
|
161
|
+
return { w, h, scheme, theme, background, labels, namesFrom, hiddenNames, shownNames, ruledNames, edges, notes: o.notes, targets, shownTargets, keys, selection: o.selection, source: o.source === true, style, title: cleanTitle(o.title), subtitle: cleanTitle(o.subtitle), titleSize: cleanTitleSize(o.titleSize), footSize: cleanFootSize(o.footSize), border: o.border === true, region: [...r] };
|
|
162
|
+
}
|
|
66
163
|
/** The legend's schemes (CanvasSpec, "Colour schemes"). */
|
|
67
164
|
export const SCHEMES = ["kind", "status"];
|
|
68
165
|
/** Activity's filters: the kind of event, and whose (web/src/activity.ts). */
|
|
@@ -143,6 +240,7 @@ export function cleanView(v) {
|
|
|
143
240
|
...(typeof lost === "boolean" ? { lostFound: { open: lost } } : {}),
|
|
144
241
|
...(scheme ? { scheme } : {}),
|
|
145
242
|
...(typeof o.edges === "boolean" ? { edges: o.edges } : {}),
|
|
146
|
-
...(typeof o.
|
|
243
|
+
...(typeof o.linear === "boolean" ? { linear: o.linear } : {}),
|
|
244
|
+
...(o.artwork === null ? { artwork: null } : cleanArtwork(o.artwork) ? { artwork: cleanArtwork(o.artwork) } : {}),
|
|
147
245
|
};
|
|
148
246
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A declaration by the name someone gives (the agents' API, server/agent.ts; the artwork command,
|
|
2
|
+
// server/artcmd.ts): the graph's own name; else the declaration a name it folds is folded into — a
|
|
3
|
+
// declaration Lean generated from a command, or a constructor, recursor, field, equation lemma of
|
|
4
|
+
// one — else, where none is, the names likely meant.
|
|
5
|
+
import { search } from "./search.js";
|
|
6
|
+
/** the last components of names Lean generates beside a declaration (extractor/Extract.lean,
|
|
7
|
+
* `generatedSuffixes`, and the like) */
|
|
8
|
+
export const GENERATED_LAST = /^(?:rec|recOn|casesOn|noConfusion|noConfusionType|ctorElim|ctorElimType|below|brecOn|binductionOn|ibelow|ctorIdx|toCtorIdx|ofNat|ofNat_ctorIdx|sizeOf_spec|injEq|inj|eq_def|induct|mutual_induct|fun_cases|induct_unfolding|ext_iff|congr_simp|mk|elim|(?:eq|match|proof)_\d+|_.*)$/;
|
|
9
|
+
/** The edit distance between two short strings, or `most + 1` once it is more than `most`. */
|
|
10
|
+
export function editDistance(a, b, most) {
|
|
11
|
+
if (Math.abs(a.length - b.length) > most)
|
|
12
|
+
return most + 1;
|
|
13
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
14
|
+
for (let i = 1; i <= a.length; i++) {
|
|
15
|
+
const row = [i];
|
|
16
|
+
let best = i;
|
|
17
|
+
for (let j = 1; j <= b.length; j++) {
|
|
18
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
19
|
+
best = Math.min(best, row[j]);
|
|
20
|
+
}
|
|
21
|
+
if (best > most)
|
|
22
|
+
return most + 1;
|
|
23
|
+
prev = row;
|
|
24
|
+
}
|
|
25
|
+
return Math.min(prev[b.length], most + 1);
|
|
26
|
+
}
|
|
27
|
+
/** The declaration a name the graph has no node for is folded into: a declaration Lean generated
|
|
28
|
+
* from a command (ViewNode.also), or a constructor, recursor, field, equation lemma… of a
|
|
29
|
+
* declaration or of one so generated, which is a name one component below it. A name one
|
|
30
|
+
* component below a declaration that is a typo or two away from a declaration beside it is
|
|
31
|
+
* taken for that typo, not for something folded, unless its last component is one Lean
|
|
32
|
+
* generates. */
|
|
33
|
+
export function foldedAt(g, name) {
|
|
34
|
+
const direct = g.foldedInto.get(name);
|
|
35
|
+
if (direct !== undefined)
|
|
36
|
+
return direct;
|
|
37
|
+
const dot = name.lastIndexOf(".");
|
|
38
|
+
if (dot <= 0)
|
|
39
|
+
return undefined;
|
|
40
|
+
const head = name.slice(0, dot), last = name.slice(dot + 1);
|
|
41
|
+
const at = g.byName.get(head) ?? g.foldedInto.get(head);
|
|
42
|
+
if (at === undefined || !last)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (!GENERATED_LAST.test(last)) {
|
|
45
|
+
const most = last.length < 5 ? 1 : 2;
|
|
46
|
+
const near = (n) => n.length > head.length + 1 && n.startsWith(head + ".") && !n.includes(".", head.length + 1) && editDistance(n.slice(head.length + 1).toLowerCase(), last.toLowerCase(), most) <= most;
|
|
47
|
+
for (const n of g.nodes)
|
|
48
|
+
if (near(n.name))
|
|
49
|
+
return undefined;
|
|
50
|
+
for (const n of g.foldedInto.keys())
|
|
51
|
+
if (near(n))
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return at;
|
|
55
|
+
}
|
|
56
|
+
/** Up to five names like `name`: the same name in another case; then the same last component,
|
|
57
|
+
* or one a typo or two away, in the same namespace first; then what the search finds. */
|
|
58
|
+
export function guessNames(g, index, name) {
|
|
59
|
+
const dot = name.lastIndexOf(".");
|
|
60
|
+
const last = name.slice(dot + 1).toLowerCase();
|
|
61
|
+
const ns = dot < 0 ? "" : name.slice(0, dot);
|
|
62
|
+
const scored = new Map();
|
|
63
|
+
const offer = (n, score) => {
|
|
64
|
+
if ((scored.get(n) ?? Infinity) > score)
|
|
65
|
+
scored.set(n, score);
|
|
66
|
+
};
|
|
67
|
+
const lower = name.toLowerCase();
|
|
68
|
+
const most = last.length < 5 ? 1 : 2;
|
|
69
|
+
// the graph's declarations, and the names Lean generated from a command that they fold
|
|
70
|
+
for (const name of [...g.nodes.map((n) => n.name), ...g.foldedInto.keys()]) {
|
|
71
|
+
if (name.toLowerCase() === lower)
|
|
72
|
+
offer(name, -1);
|
|
73
|
+
const k = name.lastIndexOf(".");
|
|
74
|
+
const d = editDistance(name.slice(k + 1).toLowerCase(), last, most);
|
|
75
|
+
if (d <= most)
|
|
76
|
+
offer(name, 2 * d + (name.slice(0, Math.max(0, k)) === ns ? 0 : 1));
|
|
77
|
+
}
|
|
78
|
+
let rank = 0;
|
|
79
|
+
for (const q of [name.slice(dot + 1), name.replace(/\./g, " ")]) {
|
|
80
|
+
if (q.trim().length < 2)
|
|
81
|
+
continue;
|
|
82
|
+
const f = search(index, q);
|
|
83
|
+
for (const h of [...f.hits.name, ...f.hits.statement].slice(0, 10))
|
|
84
|
+
offer(g.nodes[h.i].name, 10 + rank++);
|
|
85
|
+
}
|
|
86
|
+
return [...scored]
|
|
87
|
+
.sort((a, b) => a[1] - b[1] || a[0].length - b[0].length || (a[0] < b[0] ? -1 : 1))
|
|
88
|
+
.slice(0, 5)
|
|
89
|
+
.map(([n]) => n);
|
|
90
|
+
}
|
|
91
|
+
/** The declaration `name` names, its own or the one it is folded into; undefined where none is. */
|
|
92
|
+
export function declAt(g, name) {
|
|
93
|
+
const n = name.trim();
|
|
94
|
+
return g.byName.get(n) ?? foldedAt(g, n);
|
|
95
|
+
}
|
package/dist/shared/doc/order.js
CHANGED
|
@@ -28,6 +28,23 @@ export function boardOrder(xs, at) {
|
|
|
28
28
|
});
|
|
29
29
|
return keyed.map((e) => e.x);
|
|
30
30
|
}
|
|
31
|
+
/** Where `a` goes on the board to come between `prev` and `next` in the board's order (`prev` read
|
|
32
|
+
* before it, so lower; `next` after, higher), all three of one cluster: the linear form's drop
|
|
33
|
+
* (ExportSpec decision 18, web/src/linear.ts). At a height between theirs where they are across,
|
|
34
|
+
* where `a` is across; on their row, between them, where they are level; below the first, above
|
|
35
|
+
* the last, clear of it. Null where neither is given. */
|
|
36
|
+
export function spotBetween(a, prev, next) {
|
|
37
|
+
if (prev && next) {
|
|
38
|
+
if (Math.abs(prev.y - next.y) >= 1)
|
|
39
|
+
return [a.x, (prev.y + next.y) / 2];
|
|
40
|
+
return [(prev.x + next.x) / 2, prev.y];
|
|
41
|
+
}
|
|
42
|
+
if (next)
|
|
43
|
+
return [a.x, next.y + next.r + a.r + 2.5];
|
|
44
|
+
if (prev)
|
|
45
|
+
return [a.x, prev.y - prev.r - a.r - 2.5];
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
31
48
|
/** The outline of the document of `plan` (of graph `g`) in the order of the board `spots` describes. */
|
|
32
49
|
export function orderDoc(plan, g, spots) {
|
|
33
50
|
// the declarations the document prints, by name: where an attached note goes
|