@wxxb789/anc 0.0.1
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 +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The collection navigation an explorer rail renders.
|
|
3
|
+
*
|
|
4
|
+
* Two levels, both of which the artifact actually carries: a flat collection,
|
|
5
|
+
* and the notes inside it. Every published note belongs to exactly one group — a
|
|
6
|
+
* note with no `collection` lands in a final group rather than being dropped,
|
|
7
|
+
* because a navigation tree that silently omits part of the corpus is a map with
|
|
8
|
+
* a hole in it rather than a smaller map.
|
|
9
|
+
*
|
|
10
|
+
* **Membership is complete; what a group *draws* is bounded.** A group larger
|
|
11
|
+
* than {@link GROUP_WINDOW} renders a window of its members around the reader
|
|
12
|
+
* rather than all of them, and states its full size beside its label. That is
|
|
13
|
+
* the fix for a rail that was quadratic in the corpus — it renders on every page
|
|
14
|
+
* and listed every note, so a site paid n × n bytes — and the bound's own
|
|
15
|
+
* comment carries the measurements. Nothing becomes unreachable: a bounded group
|
|
16
|
+
* links its collection index, which lists every member, and the home page lists
|
|
17
|
+
* the whole corpus.
|
|
18
|
+
*
|
|
19
|
+
* Pure, like `routes.ts` and `relations.ts`: nothing here imports the artifact.
|
|
20
|
+
* The caller passes the corpus and the path being viewed, so every rule below is
|
|
21
|
+
* exercisable against synthetic fixtures without building the site.
|
|
22
|
+
*
|
|
23
|
+
* **What a deeper hierarchy would change, and what it would not.** `collection`
|
|
24
|
+
* is validated as a *flat* slug (`src/lib/schema.ts`) and `routes.ts` has no
|
|
25
|
+
* notion of nesting, so a collection inside a collection has no data behind it
|
|
26
|
+
* and is not invented here. If the contract grows a nested field, the change is
|
|
27
|
+
* this one function splitting a key into segments and emitting a group per
|
|
28
|
+
* ancestor — the group shape, the component, and the stylesheet are unchanged,
|
|
29
|
+
* because a nested group is the same disclosure with a different label and a
|
|
30
|
+
* `depth`. TK-19 owns the contract conversation; nothing here anticipates its
|
|
31
|
+
* outcome.
|
|
32
|
+
*/
|
|
33
|
+
import { collectionFacets, collectionRoute } from "./routes.js";
|
|
34
|
+
import { byTitleThenSlug } from "./relations.js";
|
|
35
|
+
import { noteRoute, noteSlugFromPath } from "./route-path.js";
|
|
36
|
+
/**
|
|
37
|
+
* Maximum notes drawn per group. Listing the entire corpus on every page made
|
|
38
|
+
* rail markup quadratic: a 300-note build spent 23,126 B of a 32,566 B page on
|
|
39
|
+
* the rail. Collection indexes and the home page still expose full membership.
|
|
40
|
+
*
|
|
41
|
+
* Twelve bounds markup, but does not by itself guarantee marker visibility:
|
|
42
|
+
* titles wrap and preceding collection summaries consume height. Keep the reader
|
|
43
|
+
* near the window's centre, including at the collection's end, and verify the
|
|
44
|
+
* actual layout with the 1280×720 browser gate in `tests/adoption.test.ts`.
|
|
45
|
+
*/
|
|
46
|
+
export const GROUP_WINDOW = 12;
|
|
47
|
+
/**
|
|
48
|
+
* The window of `notes` a group draws, centred on the reader where it holds
|
|
49
|
+
* them.
|
|
50
|
+
*
|
|
51
|
+
* Centred rather than truncated from the front, and that is the property the
|
|
52
|
+
* bound exists for: a group's first {@link GROUP_WINDOW} notes are an
|
|
53
|
+
* alphabetical slice that on most pages does not contain the reader, which would
|
|
54
|
+
* strand `aria-current="page"` exactly the way an unbounded rail does. At the
|
|
55
|
+
* collection's end the window may be shorter: backfilling earlier notes pushes
|
|
56
|
+
* the current marker down, where wrapped titles can hide it below the rail.
|
|
57
|
+
*
|
|
58
|
+
* A group not holding the reader has no centre to take, so it draws its first
|
|
59
|
+
* {@link GROUP_WINDOW}. Every group but one is in that case on any page, and
|
|
60
|
+
* they are all closed — a closed `<details>` shows its label and its count, so
|
|
61
|
+
* which of its members the markup happens to carry is not something the reader
|
|
62
|
+
* sees until they open it, and what they get when they do is the collection's
|
|
63
|
+
* beginning.
|
|
64
|
+
*/
|
|
65
|
+
function windowAround(notes) {
|
|
66
|
+
if (notes.length <= GROUP_WINDOW)
|
|
67
|
+
return [...notes];
|
|
68
|
+
const at = notes.findIndex((note) => note.isCurrent);
|
|
69
|
+
if (at < 0)
|
|
70
|
+
return notes.slice(0, GROUP_WINDOW);
|
|
71
|
+
// Do not backfill at the tail: the current row must not drift down just to
|
|
72
|
+
// fill a window whose limit is a maximum, not a required number of siblings.
|
|
73
|
+
const start = Math.max(0, at - Math.floor(GROUP_WINDOW / 2));
|
|
74
|
+
return notes.slice(start, start + GROUP_WINDOW);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The collection groups, in the order `/collections/` lists them, with the
|
|
78
|
+
* uncollected notes last.
|
|
79
|
+
*
|
|
80
|
+
* `currentPath` is the pathname being rendered. It marks a note
|
|
81
|
+
* (`/notes/<slug>/`) and, through it, that note's group; a collection index
|
|
82
|
+
* (`/collections/<key>/`) marks the group alone. Any other route — the home
|
|
83
|
+
* page, `/tags/`, the 404 — marks nothing, and every group renders closed. That
|
|
84
|
+
* is deliberate: on a page with no collection context there is no group the
|
|
85
|
+
* reader is "in", and opening an arbitrary one would be a guess presented as a
|
|
86
|
+
* location.
|
|
87
|
+
*
|
|
88
|
+
* Grouping comes from {@link collectionFacets} rather than a second pass over
|
|
89
|
+
* `entry.collection`, so a group here is exactly a page under `/collections/`:
|
|
90
|
+
* same members, same order, same key. A private second grouping would let the
|
|
91
|
+
* rail show a collection the site does not publish.
|
|
92
|
+
*
|
|
93
|
+
* `uncollectedLabel` is required rather than defaulted, and it is the one string
|
|
94
|
+
* a reader meets that this module does not get from the artifact. Taking it as a
|
|
95
|
+
* parameter is what keeps the module pure — it imports no locale table, so every
|
|
96
|
+
* rule here stays exercisable against synthetic fixtures — while letting the rail
|
|
97
|
+
* on a Chinese note read "未归入合集" and the same group on an English note read
|
|
98
|
+
* "Uncollected", in one build. A default would have been a second copy of a
|
|
99
|
+
* translated string living outside the contract, which is exactly the drift
|
|
100
|
+
* `tests/translations.test.ts` exists to prevent.
|
|
101
|
+
*
|
|
102
|
+
* Four of the thirty-two fixture notes land in that group; on the published
|
|
103
|
+
* one-note corpus it is the only group.
|
|
104
|
+
*/
|
|
105
|
+
export function collectionNavigation(entries, currentPath, uncollectedLabel) {
|
|
106
|
+
const currentSlug = noteSlugFromPath(currentPath);
|
|
107
|
+
// The collection holding the note being read, when a note is being read.
|
|
108
|
+
// Resolved from the entry rather than from the path, because a note route
|
|
109
|
+
// never carries its collection in the URL.
|
|
110
|
+
const openCollection = entries.find((entry) => entry.slug === currentSlug)?.collection;
|
|
111
|
+
const note = (entry) => ({
|
|
112
|
+
slug: entry.slug,
|
|
113
|
+
title: entry.title,
|
|
114
|
+
route: noteRoute(entry.slug),
|
|
115
|
+
isCurrent: entry.slug === currentSlug,
|
|
116
|
+
language: entry.language,
|
|
117
|
+
});
|
|
118
|
+
const groups = collectionFacets(entries).map((facet) => ({
|
|
119
|
+
label: facet.label,
|
|
120
|
+
route: collectionRoute(facet.key),
|
|
121
|
+
// Compared against the built route rather than a second regex over the
|
|
122
|
+
// path: `collectionRoute` is where that shape is defined, so the two cannot
|
|
123
|
+
// drift into disagreeing about a trailing slash.
|
|
124
|
+
isCurrent: collectionRoute(facet.key) === currentPath || facet.key === openCollection,
|
|
125
|
+
notes: windowAround(facet.entries.map(note)),
|
|
126
|
+
total: facet.entries.length,
|
|
127
|
+
}));
|
|
128
|
+
const uncollected = entries.filter((entry) => entry.collection === undefined);
|
|
129
|
+
if (uncollected.length > 0) {
|
|
130
|
+
// The same comparator `collectionFacets` gives a facet's members, imported
|
|
131
|
+
// rather than restated so the last group cannot come to read differently
|
|
132
|
+
// from every group above it.
|
|
133
|
+
const notes = [...uncollected].sort(byTitleThenSlug).map(note);
|
|
134
|
+
groups.push({
|
|
135
|
+
label: uncollectedLabel,
|
|
136
|
+
notes: windowAround(notes),
|
|
137
|
+
total: notes.length,
|
|
138
|
+
// Marked only by the note being read, and from the *whole* group rather
|
|
139
|
+
// than from the window: a reader whose note the window happened to drop
|
|
140
|
+
// would otherwise land in a group that closed itself around them.
|
|
141
|
+
isCurrent: notes.some((member) => member.isCurrent),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return groups;
|
|
145
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed accessor for the public content artifact.
|
|
3
|
+
*
|
|
4
|
+
* Pages import from here rather than from `src/data/content.json` directly, so
|
|
5
|
+
* every consumer sees an artifact that has already passed the contract in
|
|
6
|
+
* `schema.ts`. A malformed artifact throws at module evaluation, which fails
|
|
7
|
+
* `astro build` with a message naming the offending entry and field.
|
|
8
|
+
*
|
|
9
|
+
* Which file is read is `artifact-source.ts`'s decision, so `pnpm run
|
|
10
|
+
* build:fixture` can build the whole site from a fixture corpus without
|
|
11
|
+
* touching the generated artifact. The file is read rather than imported: a
|
|
12
|
+
* static `import ... with { type: 'json' }` names one path at parse time, which
|
|
13
|
+
* is exactly the indirection the fixture build needs.
|
|
14
|
+
*/
|
|
15
|
+
import { loadArtifact } from "./artifact-source.js";
|
|
16
|
+
import { notesForSlugs } from "./relations.js";
|
|
17
|
+
import { routeKey, tagFacets as producerTagFacets, tagRoute } from "./routes.js";
|
|
18
|
+
import { hydrateEntriesWithSnapshot, loadSnapshotRelations, snapshotMatchesEntries, } from "./snapshot-reader.js";
|
|
19
|
+
export const artifact = loadArtifact();
|
|
20
|
+
export const entries = artifact.entries;
|
|
21
|
+
const bySlug = new Map(entries.map((entry) => [entry.slug, entry]));
|
|
22
|
+
export function getEntry(slug) {
|
|
23
|
+
return bySlug.get(slug);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Relationship and tag authority is the finalized snapshot, not the private IR.
|
|
27
|
+
*
|
|
28
|
+
* When this build staged a snapshot whose bytes hash to the digest its binding
|
|
29
|
+
* names and whose node and edge sets are these entries' own, it replaces the
|
|
30
|
+
* producer's arrays, so every relationship and tag surface — static and
|
|
31
|
+
* interactive — reads the same SQL projection. A build without a snapshot
|
|
32
|
+
* (`astro dev`, a unit test) keeps the producer's resolved pairs, which are the
|
|
33
|
+
* exact bytes the snapshot is built from. So does a workspace left behind by an
|
|
34
|
+
* earlier build, or one staged for a different `CONTENT_ARTIFACT`: hydrating
|
|
35
|
+
* those would render relationships this corpus does not have, and the artifact's
|
|
36
|
+
* own pairs are the only authority provably tied to what is being rendered.
|
|
37
|
+
*
|
|
38
|
+
* The tag routes follow the same switch. Their key is `tags.key` read from the
|
|
39
|
+
* snapshot rather than `routeKey(tags.label)` recomputed here, and their member
|
|
40
|
+
* list is the snapshot's `node_tags` projection, so a static tag page, its
|
|
41
|
+
* sitemap URL, its note metadata links, and the browser's `byTag` query all
|
|
42
|
+
* name the rows the producer wrote. Only the no-snapshot fallback normalizes
|
|
43
|
+
* from the entries, because there the producer is the authority.
|
|
44
|
+
*/
|
|
45
|
+
const snapshot = loadSnapshotRelations();
|
|
46
|
+
const snapshotIsAuthority = snapshot !== undefined && snapshotMatchesEntries(entries, snapshot);
|
|
47
|
+
if (snapshotIsAuthority)
|
|
48
|
+
hydrateEntriesWithSnapshot(entries, snapshot);
|
|
49
|
+
/**
|
|
50
|
+
* The snapshot's facets as the `Facet` shape the static surfaces render.
|
|
51
|
+
*
|
|
52
|
+
* `entries` are the artifact's own objects, resolved through the same map
|
|
53
|
+
* `getEntry` uses, so a tag page and a note page list the same instances.
|
|
54
|
+
* `notesForSlugs` owns the presentation order and the skip of a member the
|
|
55
|
+
* artifact does not carry; `snapshotMatchesEntries` already proves the node
|
|
56
|
+
* sets are equal, so the skip is unreachable through the validated loader.
|
|
57
|
+
*/
|
|
58
|
+
function facetsFromSnapshot(relation, lookup) {
|
|
59
|
+
return relation.tagFacets.map((facet) => ({
|
|
60
|
+
key: facet.key,
|
|
61
|
+
label: facet.label,
|
|
62
|
+
entries: notesForSlugs(facet.slugs, (slug) => lookup.get(slug)),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
const facets = snapshotIsAuthority
|
|
66
|
+
? facetsFromSnapshot(snapshot, bySlug)
|
|
67
|
+
: producerTagFacets(entries);
|
|
68
|
+
/** Facet label to the key its own row carries, for note-page tag links. */
|
|
69
|
+
const keyByLabel = new Map(facets.map((facet) => [facet.label, facet.key]));
|
|
70
|
+
/**
|
|
71
|
+
* The tag facets this build renders, in canonical key order.
|
|
72
|
+
*
|
|
73
|
+
* This is the accessor the static tag surfaces call; `routes.ts`'s
|
|
74
|
+
* `tagFacets(entries)` remains the producer's normalization and collision
|
|
75
|
+
* check, used by the writer and as the no-snapshot fallback here.
|
|
76
|
+
*/
|
|
77
|
+
export function tagFacets() {
|
|
78
|
+
return facets;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The public route for one note's tag label, from the same facet index the tag
|
|
82
|
+
* pages render.
|
|
83
|
+
*
|
|
84
|
+
* A hydrated entry carries the snapshot's representative label, so every label
|
|
85
|
+
* on a note page resolves to the `tags.key` row it came from. The fallback is
|
|
86
|
+
* reachable only where the producer is already the authority — no snapshot, or
|
|
87
|
+
* a label the artifact spelled differently from the representative, which
|
|
88
|
+
* `producerTagFacets` groups under the same key.
|
|
89
|
+
*/
|
|
90
|
+
export function tagRouteForLabel(label) {
|
|
91
|
+
return tagRoute(keyByLabel.get(label) ?? routeKey(label));
|
|
92
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const DIAGRAM_MODE = 'client';
|
|
2
|
+
/**
|
|
3
|
+
* The `style-src` value each mode requires, measured rather than reasoned.
|
|
4
|
+
*
|
|
5
|
+
* `client` needs both the element and the attribute form relaxed and a single
|
|
6
|
+
* `'unsafe-inline'` in `style-src` is what does that; relaxing only
|
|
7
|
+
* `style-src-elem` keeps the theme but loses the sizing, and relaxing only
|
|
8
|
+
* `style-src-attr` loses the theme. Hash-pinning was tested and cannot work:
|
|
9
|
+
* hashes never cover a `style=` attribute, so `'unsafe-inline'` survives in
|
|
10
|
+
* `style-src-attr` and merely relocates. See the feasibility probe §7.2
|
|
11
|
+
* and §7.3.
|
|
12
|
+
*/
|
|
13
|
+
export const STYLE_SRC_BY_MODE = {
|
|
14
|
+
'build-time': "'self'",
|
|
15
|
+
client: "'self' 'unsafe-inline'",
|
|
16
|
+
};
|
|
17
|
+
/** The `style-src` the deployed policy must declare for the selected mode. */
|
|
18
|
+
export const REQUIRED_STYLE_SRC = STYLE_SRC_BY_MODE[DIAGRAM_MODE];
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph's geometry, as a leaf module.
|
|
3
|
+
*
|
|
4
|
+
* Pure and import-free so the build (`NoteGraph.astro`) and the browser
|
|
5
|
+
* explorer can lay out the same bounded selection without the client pulling in
|
|
6
|
+
* the content artifact, the route model, or Pagefind. Selection lives in
|
|
7
|
+
* `graph-selection.ts`; this module turns a selection into coordinates and a
|
|
8
|
+
* drawable edge set.
|
|
9
|
+
*
|
|
10
|
+
* Determinism is a hard requirement: every ordering ends in `slug`, every
|
|
11
|
+
* coordinate goes through {@link round}, and no layout step iterates to a fixed
|
|
12
|
+
* point, so two builds of one corpus are byte-equal.
|
|
13
|
+
*/
|
|
14
|
+
import { byTitleThenSlug } from "./graph-selection.js";
|
|
15
|
+
/** Distance between two concentric rings, in user units. */
|
|
16
|
+
const RING_STEP = 92;
|
|
17
|
+
/** The shortest arc the layout will leave between two nodes on one ring. */
|
|
18
|
+
const NODE_GAP = 78;
|
|
19
|
+
/** Room outside the outermost ring for a label to sit in. */
|
|
20
|
+
const LABEL_SPACE = 136;
|
|
21
|
+
/** Longest drawn label, in code points rather than UTF-16 units. */
|
|
22
|
+
const LABEL_CHARS = 18;
|
|
23
|
+
/** Radius of the note the reader is on, and of every other note. */
|
|
24
|
+
export const SUBJECT_RADIUS = 9;
|
|
25
|
+
export const NODE_RADIUS = 6;
|
|
26
|
+
/** How much of the line the arrowhead occupies, so an edge stops short of it. */
|
|
27
|
+
const MARKER_LENGTH = 9;
|
|
28
|
+
/** Two decimal places, with negative zero normalized away. */
|
|
29
|
+
function round(value) {
|
|
30
|
+
const rounded = Math.round(value * 100) / 100;
|
|
31
|
+
return rounded === 0 ? 0 : rounded;
|
|
32
|
+
}
|
|
33
|
+
/** A title shortened to fit beside its node. */
|
|
34
|
+
export function truncateLabel(title) {
|
|
35
|
+
const characters = [...title];
|
|
36
|
+
return characters.length <= LABEL_CHARS
|
|
37
|
+
? title
|
|
38
|
+
: `${characters.slice(0, LABEL_CHARS - 1).join('').trimEnd()}…`;
|
|
39
|
+
}
|
|
40
|
+
/** Place items evenly around a circle, starting at the top and going clockwise. */
|
|
41
|
+
function placeRing(items, radius) {
|
|
42
|
+
return items.map((item, index) => {
|
|
43
|
+
const angle = -Math.PI / 2 + (2 * Math.PI * index) / items.length;
|
|
44
|
+
return { item, x: round(radius * Math.cos(angle)), y: round(radius * Math.sin(angle)) };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** How many nodes ring `index` holds, from its own circumference. */
|
|
48
|
+
function ringCapacity(index) {
|
|
49
|
+
if (index === 0)
|
|
50
|
+
return 1;
|
|
51
|
+
return Math.max(1, Math.floor((2 * Math.PI * index * RING_STEP) / NODE_GAP));
|
|
52
|
+
}
|
|
53
|
+
function radiusOf(node) {
|
|
54
|
+
return node.isSubject ? SUBJECT_RADIUS : NODE_RADIUS;
|
|
55
|
+
}
|
|
56
|
+
/** How `other` relates to `subject`, from the directed edge set. */
|
|
57
|
+
function edgeDirection(edges, subject, other) {
|
|
58
|
+
const outgoing = edges.some((edge) => edge.from === subject && edge.to === other);
|
|
59
|
+
const incoming = edges.some((edge) => edge.from === other && edge.to === subject);
|
|
60
|
+
if (outgoing && incoming)
|
|
61
|
+
return 'mutual';
|
|
62
|
+
if (outgoing)
|
|
63
|
+
return 'outgoing';
|
|
64
|
+
if (incoming)
|
|
65
|
+
return 'incoming';
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export function relationKey(node) {
|
|
69
|
+
if (node.isSubject)
|
|
70
|
+
return 'subject';
|
|
71
|
+
switch (node.direction) {
|
|
72
|
+
case 'outgoing':
|
|
73
|
+
return 'outgoing';
|
|
74
|
+
case 'incoming':
|
|
75
|
+
return 'incoming';
|
|
76
|
+
case 'mutual':
|
|
77
|
+
return 'mutual';
|
|
78
|
+
// `/graph/` has no subject, so its nodes have no direction relative to one.
|
|
79
|
+
// Spelled as a case rather than a `default` so a sixth direction fails this
|
|
80
|
+
// function's return type instead of silently reading as `linked`.
|
|
81
|
+
case undefined:
|
|
82
|
+
return 'linked';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Every directed edge with both ends drawn, each unordered pair drawn once.
|
|
87
|
+
* A reciprocal pair becomes one line with two arrowheads.
|
|
88
|
+
*/
|
|
89
|
+
function drawEdges(nodes, edges, subject) {
|
|
90
|
+
const drawn = new Map(nodes.map((node) => [node.entry.slug, node]));
|
|
91
|
+
const pairs = new Map();
|
|
92
|
+
for (const edge of edges) {
|
|
93
|
+
if (!drawn.has(edge.from) || !drawn.has(edge.to))
|
|
94
|
+
continue;
|
|
95
|
+
const key = edge.from < edge.to ? `${edge.from} ${edge.to}` : `${edge.to} ${edge.from}`;
|
|
96
|
+
const existing = pairs.get(key);
|
|
97
|
+
if (existing === undefined)
|
|
98
|
+
pairs.set(key, { from: edge.from, to: edge.to, isMutual: false });
|
|
99
|
+
else if (existing.from !== edge.from)
|
|
100
|
+
existing.isMutual = true;
|
|
101
|
+
}
|
|
102
|
+
return [...pairs.values()]
|
|
103
|
+
.sort((a, b) => (a.from !== b.from ? (a.from < b.from ? -1 : 1) : a.to < b.to ? -1 : 1))
|
|
104
|
+
.map((pair) => {
|
|
105
|
+
const from = drawn.get(pair.from);
|
|
106
|
+
const to = drawn.get(pair.to);
|
|
107
|
+
const dx = to.x - from.x;
|
|
108
|
+
const dy = to.y - from.y;
|
|
109
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
110
|
+
const startTrim = radiusOf(from) + (pair.isMutual ? MARKER_LENGTH : 0);
|
|
111
|
+
const endTrim = radiusOf(to) + MARKER_LENGTH;
|
|
112
|
+
const scale = Math.min(startTrim, Math.max(0, (length - endTrim) / 2));
|
|
113
|
+
const other = subject === undefined
|
|
114
|
+
? undefined
|
|
115
|
+
: pair.from === subject
|
|
116
|
+
? pair.to
|
|
117
|
+
: pair.to === subject
|
|
118
|
+
? pair.from
|
|
119
|
+
: undefined;
|
|
120
|
+
return {
|
|
121
|
+
...pair,
|
|
122
|
+
direction: other === undefined ? undefined : edgeDirection(edges, subject, other),
|
|
123
|
+
x1: round(from.x + (dx / length) * scale),
|
|
124
|
+
y1: round(from.y + (dy / length) * scale),
|
|
125
|
+
x2: round(to.x - (dx / length) * Math.min(endTrim, length - scale)),
|
|
126
|
+
y2: round(to.y - (dy / length) * Math.min(endTrim, length - scale)),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Wrap placed nodes and their edges in a box that contains every label. */
|
|
131
|
+
function frame(nodes, omitted, edges, subject) {
|
|
132
|
+
const reach = Math.max(0, ...nodes.map((node) => Math.hypot(node.x, node.y)));
|
|
133
|
+
const half = Math.ceil(reach + LABEL_SPACE);
|
|
134
|
+
return {
|
|
135
|
+
nodes,
|
|
136
|
+
edges: drawEdges(nodes, edges, subject),
|
|
137
|
+
viewBox: `${-half} ${-half} ${half * 2} ${half * 2}`,
|
|
138
|
+
width: half * 2,
|
|
139
|
+
height: half * 2,
|
|
140
|
+
omitted,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Count each node's drawn edges, once reciprocal pairs are merged. */
|
|
144
|
+
function withDegrees(graph) {
|
|
145
|
+
const degrees = new Map();
|
|
146
|
+
for (const edge of graph.edges) {
|
|
147
|
+
degrees.set(edge.from, (degrees.get(edge.from) ?? 0) + 1);
|
|
148
|
+
degrees.set(edge.to, (degrees.get(edge.to) ?? 0) + 1);
|
|
149
|
+
}
|
|
150
|
+
for (const node of graph.nodes) {
|
|
151
|
+
node.degree = degrees.get(node.entry.slug) ?? 0;
|
|
152
|
+
}
|
|
153
|
+
return graph;
|
|
154
|
+
}
|
|
155
|
+
/** Lay out a local selection: center at the origin, neighbours on one ring. */
|
|
156
|
+
export function layoutLocal(selection) {
|
|
157
|
+
const radius = Math.max(RING_STEP, Math.ceil((NODE_GAP * selection.drawn.length) / (2 * Math.PI)));
|
|
158
|
+
const subject = {
|
|
159
|
+
entry: selection.center,
|
|
160
|
+
x: 0,
|
|
161
|
+
y: 0,
|
|
162
|
+
label: truncateLabel(selection.center.title),
|
|
163
|
+
isSubject: true,
|
|
164
|
+
degree: 0,
|
|
165
|
+
};
|
|
166
|
+
const nodes = [
|
|
167
|
+
subject,
|
|
168
|
+
...placeRing(selection.drawn, radius).map(({ item, x, y }) => ({
|
|
169
|
+
entry: item,
|
|
170
|
+
x,
|
|
171
|
+
y,
|
|
172
|
+
label: truncateLabel(item.title),
|
|
173
|
+
direction: edgeDirection(selection.edges, selection.center.slug, item.slug),
|
|
174
|
+
isSubject: false,
|
|
175
|
+
degree: 0,
|
|
176
|
+
})),
|
|
177
|
+
];
|
|
178
|
+
return withDegrees(frame(nodes, selection.omitted, selection.edges, selection.center.slug));
|
|
179
|
+
}
|
|
180
|
+
/** Lay out a global selection: ranked nodes packed onto concentric rings. */
|
|
181
|
+
export function layoutGlobal(selection) {
|
|
182
|
+
const nodes = [];
|
|
183
|
+
for (let ring = 0, placed = 0; placed < selection.nodes.length; ring += 1) {
|
|
184
|
+
const slice = selection.nodes.slice(placed, placed + ringCapacity(ring));
|
|
185
|
+
for (const { item, x, y } of placeRing(slice, ring * RING_STEP)) {
|
|
186
|
+
nodes.push({
|
|
187
|
+
entry: item,
|
|
188
|
+
x,
|
|
189
|
+
y,
|
|
190
|
+
label: truncateLabel(item.title),
|
|
191
|
+
isSubject: false,
|
|
192
|
+
degree: 0,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
placed += slice.length;
|
|
196
|
+
}
|
|
197
|
+
return withDegrees(frame(nodes, selection.omitted, selection.edges));
|
|
198
|
+
}
|
|
199
|
+
/** The two numbers the bound sentence states: drawn notes, and the total. */
|
|
200
|
+
export function boundCounts(graph) {
|
|
201
|
+
const shown = graph.nodes.length - graph.nodes.filter((node) => node.isSubject).length;
|
|
202
|
+
return { shown, total: shown + graph.omitted };
|
|
203
|
+
}
|
|
204
|
+
/** Whether there is a graph worth drawing at all. */
|
|
205
|
+
export function hasDrawableGraph(graph) {
|
|
206
|
+
return graph.edges.length > 0;
|
|
207
|
+
}
|
|
208
|
+
/** The notes each drawn node is joined to, within the figure. */
|
|
209
|
+
export function drawnNeighbours(graph) {
|
|
210
|
+
const bySlug = new Map(graph.nodes.map((node) => [node.entry.slug, node.entry]));
|
|
211
|
+
const joined = new Map(graph.nodes.map((node) => [node.entry.slug, []]));
|
|
212
|
+
for (const edge of graph.edges) {
|
|
213
|
+
joined.get(edge.from)?.push(bySlug.get(edge.to));
|
|
214
|
+
joined.get(edge.to)?.push(bySlug.get(edge.from));
|
|
215
|
+
}
|
|
216
|
+
for (const list of joined.values())
|
|
217
|
+
list.sort(byTitleThenSlug);
|
|
218
|
+
return joined;
|
|
219
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph selection contract, shared by the build and the browser Worker.
|
|
3
|
+
*
|
|
4
|
+
* `docs/core-design/content-semantics.md` owns these rules: selection precedes
|
|
5
|
+
* induced-edge extraction, degree counts distinct adjacent notes in the
|
|
6
|
+
* candidate graph, a reciprocal pair is one neighbour, and the limits are
|
|
7
|
+
* presentation bounds rather than corpus bounds.
|
|
8
|
+
*
|
|
9
|
+
* Pure and dependency-free, so the same functions can run over `node:sqlite`
|
|
10
|
+
* results at build time, over the Worker's WASM results, and in a unit test.
|
|
11
|
+
*/
|
|
12
|
+
/** Local presentation bound: drawn neighbours, not counting the center. */
|
|
13
|
+
export const LOCAL_NODE_LIMIT = 12;
|
|
14
|
+
/** Global presentation bound: drawn nodes. */
|
|
15
|
+
export const GLOBAL_NODE_LIMIT = 60;
|
|
16
|
+
/** Total order over notes: title, then the unique slug so no tie is left open. */
|
|
17
|
+
export function byTitleThenSlug(a, b) {
|
|
18
|
+
if (a.title !== b.title)
|
|
19
|
+
return a.title < b.title ? -1 : 1;
|
|
20
|
+
return a.slug < b.slug ? -1 : 1;
|
|
21
|
+
}
|
|
22
|
+
/** Every directed edge with both ends selected, deduplicated and sorted. */
|
|
23
|
+
export function inducedEdges(selected, edges) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const result = [];
|
|
26
|
+
for (const edge of edges) {
|
|
27
|
+
if (!selected.has(edge.from) || !selected.has(edge.to) || edge.from === edge.to)
|
|
28
|
+
continue;
|
|
29
|
+
const key = `${edge.from}\u0000${edge.to}`;
|
|
30
|
+
if (seen.has(key))
|
|
31
|
+
continue;
|
|
32
|
+
seen.add(key);
|
|
33
|
+
result.push({ from: edge.from, to: edge.to });
|
|
34
|
+
}
|
|
35
|
+
result.sort((a, b) => (a.from !== b.from ? (a.from < b.from ? -1 : 1) : a.to < b.to ? -1 : 1));
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Local contract: dedupe already done by the caller, sort by title/slug, take
|
|
40
|
+
* up to `limit` neighbours **plus** the center, then extract induced edges.
|
|
41
|
+
*/
|
|
42
|
+
export function selectLocal(center, neighbours, edges, limit = LOCAL_NODE_LIMIT) {
|
|
43
|
+
const candidates = [...neighbours].sort(byTitleThenSlug);
|
|
44
|
+
const drawn = candidates.slice(0, limit);
|
|
45
|
+
const selected = new Set([center.slug, ...drawn.map((node) => node.slug)]);
|
|
46
|
+
return { center, candidates, drawn, edges: inducedEdges(selected, edges), omitted: candidates.length - drawn.length };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The global ranking rule: degree in the candidate graph descending, then the
|
|
50
|
+
* shared title/slug order. Exported so the Worker's SQL-aggregated path and
|
|
51
|
+
* `selectGlobal`'s edge-walking path share one implementation instead of two
|
|
52
|
+
* copies that a test has to hold together.
|
|
53
|
+
*/
|
|
54
|
+
export function rankGlobal(candidates, degreeOf) {
|
|
55
|
+
return [...candidates].sort((a, b) => {
|
|
56
|
+
const left = degreeOf(a);
|
|
57
|
+
const right = degreeOf(b);
|
|
58
|
+
if (left !== right)
|
|
59
|
+
return right - left;
|
|
60
|
+
return byTitleThenSlug(a, b);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Global contract: rank by the number of distinct adjacent notes **in the
|
|
65
|
+
* candidate graph** (descending), then title/slug, then truncate and extract
|
|
66
|
+
* induced edges over the drawn set.
|
|
67
|
+
*/
|
|
68
|
+
export function selectGlobal(candidates, edges, limit = GLOBAL_NODE_LIMIT) {
|
|
69
|
+
const present = new Set(candidates.map((node) => node.slug));
|
|
70
|
+
const neighbours = new Map(candidates.map((node) => [node.slug, new Set()]));
|
|
71
|
+
for (const edge of edges) {
|
|
72
|
+
if (!present.has(edge.from) || !present.has(edge.to) || edge.from === edge.to)
|
|
73
|
+
continue;
|
|
74
|
+
neighbours.get(edge.from).add(edge.to);
|
|
75
|
+
neighbours.get(edge.to).add(edge.from);
|
|
76
|
+
}
|
|
77
|
+
const ranked = rankGlobal(candidates, (node) => neighbours.get(node.slug).size);
|
|
78
|
+
const nodes = ranked.slice(0, limit);
|
|
79
|
+
return {
|
|
80
|
+
nodes,
|
|
81
|
+
edges: inducedEdges(new Set(nodes.map((node) => node.slug)), edges),
|
|
82
|
+
omitted: ranked.length - nodes.length,
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/lib/graph.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph, as the build sees it.
|
|
3
|
+
*
|
|
4
|
+
* This module is the IR adapter over the shared contract: it turns a note's
|
|
5
|
+
* `outgoing`/`backlinks` into the directed edge set, asks `graph-selection.ts`
|
|
6
|
+
* for the bounded local/global selection, and asks `graph-layout.ts` for the
|
|
7
|
+
* coordinates. The Worker selects through the same `graph-selection.ts` and the
|
|
8
|
+
* explorer lays out through the same `graph-layout.ts`, so the static SVG/table
|
|
9
|
+
* and the browser graph cannot disagree about which notes are drawn.
|
|
10
|
+
*/
|
|
11
|
+
import { selectGlobal, selectLocal } from "./graph-selection.js";
|
|
12
|
+
import { layoutGlobal, layoutLocal, } from "./graph-layout.js";
|
|
13
|
+
export { GLOBAL_NODE_LIMIT, LOCAL_NODE_LIMIT } from "./graph-selection.js";
|
|
14
|
+
export { NODE_RADIUS, SUBJECT_RADIUS, boundCounts, drawnNeighbours, hasDrawableGraph, truncateLabel, } from "./graph-layout.js";
|
|
15
|
+
/** Every note this one is joined to by an authored edge, in either direction. */
|
|
16
|
+
function neighbourSlugs(entry) {
|
|
17
|
+
return [...new Set([...entry.outgoing, ...entry.backlinks])];
|
|
18
|
+
}
|
|
19
|
+
/** The directed edges among `nodes`, restricted to the drawn endpoints. */
|
|
20
|
+
function edgesAmong(nodes) {
|
|
21
|
+
const present = new Set(nodes.map((node) => node.slug));
|
|
22
|
+
const edges = [];
|
|
23
|
+
for (const node of nodes) {
|
|
24
|
+
for (const target of node.outgoing) {
|
|
25
|
+
if (present.has(target))
|
|
26
|
+
edges.push({ from: node.slug, to: target });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return edges;
|
|
30
|
+
}
|
|
31
|
+
/** The one-hop neighbourhood of one note: the union of incoming and outgoing. */
|
|
32
|
+
export function localGraph(entry, lookup, limit) {
|
|
33
|
+
const neighbours = neighbourSlugs(entry)
|
|
34
|
+
.map(lookup)
|
|
35
|
+
.filter((candidate) => candidate !== undefined);
|
|
36
|
+
return layoutLocal(selectLocal(entry, neighbours, edgesAmong([entry, ...neighbours]), limit));
|
|
37
|
+
}
|
|
38
|
+
/** The site-wide graph, ranked by distinct adjacent notes in the corpus. */
|
|
39
|
+
export function globalGraph(entries, limit) {
|
|
40
|
+
const corpus = [...entries];
|
|
41
|
+
return layoutGlobal(selectGlobal(corpus, edgesAmong(corpus), limit));
|
|
42
|
+
}
|