secondbrain-py 0.2.1__py3-none-any.whl
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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2302 @@
|
|
|
1
|
+
// Custom Quartz v4 graph renderer for brain vaults.
|
|
2
|
+
//
|
|
3
|
+
// This file is a TEMPLATE. It is installed at
|
|
4
|
+
// `<vault>/.quartz/quartz/components/scripts/graph.inline.ts` by
|
|
5
|
+
// `brain vault render --overlay`, FULL-REPLACING upstream's stock
|
|
6
|
+
// `graph.inline.ts`. It does NOT compile or run from the brain
|
|
7
|
+
// repo itself; the imports below resolve against the dependencies
|
|
8
|
+
// Quartz pulls into the cloned workspace via `npm install`, not
|
|
9
|
+
// against any package brain ships.
|
|
10
|
+
//
|
|
11
|
+
// Tested against Quartz v4.5.x (April 2026). When upstream churns,
|
|
12
|
+
// pull the latest renderer from
|
|
13
|
+
// https://github.com/jackyzha0/quartz/blob/v4/quartz/components/scripts/graph.inline.ts
|
|
14
|
+
// and re-apply the brain tweaks below. Brain modifications are
|
|
15
|
+
// flagged with `// brain:` for value/structural choices on
|
|
16
|
+
// upstream-supported logic, and `// brain-extension:` for behavior
|
|
17
|
+
// that doesn't exist in stock Quartz. To enumerate every delta:
|
|
18
|
+
//
|
|
19
|
+
// grep -n "brain:" graph.inline.ts
|
|
20
|
+
// grep -n "brain-extension:" graph.inline.ts
|
|
21
|
+
//
|
|
22
|
+
// Strategy — full replacement (Option A): the brain modifications
|
|
23
|
+
// touch the heart of `renderGraph` (filter pass, color resolution,
|
|
24
|
+
// recency sizing, edge-style branch, derived-edge tooltip), and
|
|
25
|
+
// the upstream uses module-level state plus an unconditional
|
|
26
|
+
// `document.addEventListener("nav", ...)` that runs on import — so
|
|
27
|
+
// a wrapping overlay can't compose cleanly without dueling
|
|
28
|
+
// listeners. We vendor faithfully and patch in place. Upstream's
|
|
29
|
+
// function names, comment phrasing, and structural ordering are
|
|
30
|
+
// preserved verbatim wherever the brain delta isn't touching them,
|
|
31
|
+
// to keep `diff -u <upstream> <ours>` a useful upgrade tool.
|
|
32
|
+
//
|
|
33
|
+
// Type-narrowing note: every property access on a
|
|
34
|
+
// `BrainContentDetails` value (other than the named `tier` /
|
|
35
|
+
// `source` / `linkRecords` fields) reads through an index
|
|
36
|
+
// signature of `unknown`. Reads of `title`, `tags`, `links`, and
|
|
37
|
+
// `date` therefore narrow with explicit casts at the call site —
|
|
38
|
+
// see the inline `as` annotations below.
|
|
39
|
+
|
|
40
|
+
import type { BrainContentDetails, BrainLinkRecord } from "../../plugins/emitters/contentIndex"
|
|
41
|
+
import {
|
|
42
|
+
SimulationNodeDatum,
|
|
43
|
+
SimulationLinkDatum,
|
|
44
|
+
Simulation,
|
|
45
|
+
forceSimulation,
|
|
46
|
+
forceManyBody,
|
|
47
|
+
forceCenter,
|
|
48
|
+
forceLink,
|
|
49
|
+
forceCollide,
|
|
50
|
+
forceRadial,
|
|
51
|
+
zoomIdentity,
|
|
52
|
+
select,
|
|
53
|
+
drag,
|
|
54
|
+
zoom,
|
|
55
|
+
} from "d3"
|
|
56
|
+
import { Text, Graphics, Application, Container, Circle } from "pixi.js"
|
|
57
|
+
import { Group as TweenGroup, Tween as Tweened } from "@tweenjs/tween.js"
|
|
58
|
+
import { registerEscapeHandler, removeAllChildren } from "./util"
|
|
59
|
+
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
|
|
60
|
+
import { D3Config } from "../Graph"
|
|
61
|
+
|
|
62
|
+
type GraphicsInfo = {
|
|
63
|
+
color: string
|
|
64
|
+
gfx: Graphics
|
|
65
|
+
alpha: number
|
|
66
|
+
active: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// brain: extend NodeData with `tier`, `source`, `mtime` so the renderer can resolve
|
|
70
|
+
// color (tier > source > gray) and apply recency sizing without re-reading the
|
|
71
|
+
// `data` Map on every tick.
|
|
72
|
+
type NodeData = {
|
|
73
|
+
id: SimpleSlug
|
|
74
|
+
text: string
|
|
75
|
+
tags: string[]
|
|
76
|
+
// brain-extension: frontmatter `tier` from the contentIndex entry (e.g. "vault" /
|
|
77
|
+
// "ingested"). Absent for older artifacts; falls through to source / gray.
|
|
78
|
+
tier?: string
|
|
79
|
+
// brain-extension: frontmatter `source` (e.g. "krisp" / "slack" / "gmail" /
|
|
80
|
+
// "manual").
|
|
81
|
+
source?: string
|
|
82
|
+
// brain-extension: epoch millis of the doc's `date` field. `null` when the
|
|
83
|
+
// contentIndex entry has no parseable date — recency sizing skips it.
|
|
84
|
+
mtime?: number | null
|
|
85
|
+
} & SimulationNodeDatum
|
|
86
|
+
|
|
87
|
+
// brain: extend SimpleLinkData with `kind` / `rule` / `weight` so the link-render
|
|
88
|
+
// branch can pick the dashed-edge style and the hover tooltip can surface metadata.
|
|
89
|
+
type SimpleLinkData = {
|
|
90
|
+
source: SimpleSlug
|
|
91
|
+
target: SimpleSlug
|
|
92
|
+
// brain-extension: "wiki" for authored Markdown links, "derived" for Phase D
|
|
93
|
+
// fence output. Falls back to "wiki" when the contentIndex entry only carries the
|
|
94
|
+
// legacy flat `links: SimpleSlug[]` shape (e.g. stock-Quartz build).
|
|
95
|
+
kind: "wiki" | "derived"
|
|
96
|
+
// brain-extension: derived-only metadata, populated by the contentIndex emitter.
|
|
97
|
+
rule?: string
|
|
98
|
+
weight?: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type LinkData = {
|
|
102
|
+
source: NodeData
|
|
103
|
+
target: NodeData
|
|
104
|
+
// brain-extension: see SimpleLinkData.kind / rule / weight.
|
|
105
|
+
kind: "wiki" | "derived"
|
|
106
|
+
rule?: string
|
|
107
|
+
weight?: number
|
|
108
|
+
} & SimulationLinkDatum<NodeData>
|
|
109
|
+
|
|
110
|
+
type LinkRenderData = GraphicsInfo & {
|
|
111
|
+
simulationData: LinkData
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type NodeRenderData = GraphicsInfo & {
|
|
115
|
+
simulationData: NodeData
|
|
116
|
+
label: Text
|
|
117
|
+
radius: number
|
|
118
|
+
// brain-extension: cached at label-creation time so the per-frame
|
|
119
|
+
// label-opacity loop can branch on hub-vs-leaf without recomputing
|
|
120
|
+
// the link count every frame.
|
|
121
|
+
isHub: boolean
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type GraphRenderMode = "standard" | "workbench"
|
|
125
|
+
type GraphWorkbenchMode = "overview" | "incoming" | "outgoing" | "missing" | "suspicious" | "evidence"
|
|
126
|
+
|
|
127
|
+
type NodeDiagnostic = {
|
|
128
|
+
incomingCount: number
|
|
129
|
+
outgoingCount: number
|
|
130
|
+
derivedCount: number
|
|
131
|
+
authoredCount: number
|
|
132
|
+
issueCount: number
|
|
133
|
+
issueLabels: string[]
|
|
134
|
+
strongRelations: SimpleSlug[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const localStorageKey = "graph-visited"
|
|
138
|
+
function getVisited(): Set<SimpleSlug> {
|
|
139
|
+
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function addToVisited(slug: SimpleSlug) {
|
|
143
|
+
const visited = getVisited()
|
|
144
|
+
visited.add(slug)
|
|
145
|
+
localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type TweenNode = {
|
|
149
|
+
update: (time: number) => void
|
|
150
|
+
stop: () => void
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// brain-extension: collapse long node titles to a glance-readable stub by
|
|
154
|
+
// default — the dense fullscreen modal otherwise renders as an unreadable
|
|
155
|
+
// wall of overlapping text (esp. with NFPA-style "Chapter X: ..." titles).
|
|
156
|
+
// The full title is restored on pointerover. The cap is per-render-context
|
|
157
|
+
// (cfg.labelMaxLength) so the dense modals can stay tight while the
|
|
158
|
+
// sparser sidebar panel keeps more characters visible.
|
|
159
|
+
const DEFAULT_LABEL_MAX = 10
|
|
160
|
+
function shortLabel(text: string, max: number): string {
|
|
161
|
+
if (text.length <= max) return text
|
|
162
|
+
return text.slice(0, max - 1).trimEnd() + "…"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// brain-extension: default minimum incident-link count to treat a node
|
|
166
|
+
// as a hub and bypass label truncation. Tuned for depth=1 contexts
|
|
167
|
+
// (sidebar / local-fullscreen) where ~6 connections genuinely mark a
|
|
168
|
+
// page as central. The global graph (depth=-1, ~1000 nodes) overrides
|
|
169
|
+
// this to a higher value via cfg.hubLabelThreshold so the central
|
|
170
|
+
// cluster doesn't wall-of-text.
|
|
171
|
+
const DEFAULT_HUB_LABEL_THRESHOLD = 6
|
|
172
|
+
|
|
173
|
+
// brain-extension: graph labels should read as attached to their node,
|
|
174
|
+
// Obsidian-style. Small labels sit directly under the circle; structurally
|
|
175
|
+
// important hub labels can sit centered inside the circle when the mark is big
|
|
176
|
+
// enough to carry the text.
|
|
177
|
+
const LABEL_NODE_GAP = 3
|
|
178
|
+
|
|
179
|
+
// brain-extension: ref pattern that lets a chip-driven rerender swap the live
|
|
180
|
+
// cleanup in place without dropping the cleanup-array entry the nav handler
|
|
181
|
+
// holds. The nav handler pushes `() => ref.current()` into the global cleanup
|
|
182
|
+
// arrays; renderGraph writes the latest cleanup into `ref.current` on every
|
|
183
|
+
// (re-)render. That way "dispose this graph" always means "run the most
|
|
184
|
+
// recent cleanup", whether that's the original render or any number of
|
|
185
|
+
// chip-toggle rebuilds since.
|
|
186
|
+
type CleanupRef = { current: () => void }
|
|
187
|
+
|
|
188
|
+
// brain-extension: chip vocabularies are pinned to the values brain emits
|
|
189
|
+
// (`tier: vault|ingested`, `source: krisp|slack|gmail|manual`). Hardcoding
|
|
190
|
+
// instead of deriving from `data` keeps the chip row stable when the loaded
|
|
191
|
+
// corpus happens to be missing one of the sources — the chip is still there
|
|
192
|
+
// to click later, and the order is deterministic.
|
|
193
|
+
//
|
|
194
|
+
// brain: known coordination point — if a new `source` value is ever added
|
|
195
|
+
// (e.g. brain grows a `notion` ingest extractor), it must be added BOTH
|
|
196
|
+
// here AND in `Graph.tsx`'s `defaultSourceColors` AND in `graph.scss`'s
|
|
197
|
+
// `--brain-source-*` palette. Missing any of those leaves the chip
|
|
198
|
+
// selectable but visually indistinguishable from the gray fallback.
|
|
199
|
+
const chipVocabularies = {
|
|
200
|
+
tier: ["vault", "ingested"] as const,
|
|
201
|
+
source: ["krisp", "slack", "gmail", "manual"] as const,
|
|
202
|
+
} as const
|
|
203
|
+
|
|
204
|
+
// brain-extension: chip filter state at module scope so a user's selection
|
|
205
|
+
// survives SPA navigation. Default = full vocabulary (everything visible).
|
|
206
|
+
//
|
|
207
|
+
// brain: empty-set-as-wildcard is an INTENTIONAL UX choice, not a bug. The
|
|
208
|
+
// acceptance text is literal: "flipping all chips off shows everything",
|
|
209
|
+
// which only makes sense if a fully-empty filter set is treated as
|
|
210
|
+
// "no filter on this dimension". A future maintainer might be tempted to
|
|
211
|
+
// "fix" this to mean "show nothing" (matching set-membership semantics
|
|
212
|
+
// strictly) — please don't, the acceptance test will then fail.
|
|
213
|
+
const activeChipFilters: { tier: Set<string>; source: Set<string> } = {
|
|
214
|
+
tier: new Set<string>(chipVocabularies.tier),
|
|
215
|
+
source: new Set<string>(chipVocabularies.source),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// brain-extension: persistent across SPA navigation. When true, the
|
|
219
|
+
// "show unconnected" chip is on and orphans (degree-0 nodes after the
|
|
220
|
+
// chip + tag + frontmatter passes) are NOT hidden even when the
|
|
221
|
+
// layout's `hideOrphans` cfg is true. Lives at module scope so a
|
|
222
|
+
// user's preference carries across hover-driven and chip-driven
|
|
223
|
+
// rerenders within a session.
|
|
224
|
+
let hideOrphansToggleOn = false
|
|
225
|
+
|
|
226
|
+
// brain-extension: live search query at module scope. Persists across
|
|
227
|
+
// chip-driven rerenders (so toggling a chip mid-search doesn't blow the
|
|
228
|
+
// query away) but is reset on SPA nav by the `nav` event handler. The
|
|
229
|
+
// initial-search-highlight branch in renderGraph reads this on mount.
|
|
230
|
+
let currentSearchQuery = ""
|
|
231
|
+
|
|
232
|
+
// brain-extension: module-level chip-rerender guard so a chip click that
|
|
233
|
+
// fires mid-rebuild (e.g. clicking an inner-render chip while the outer
|
|
234
|
+
// renderGraph is still awaiting `app.init`) doesn't double-tear-down. A
|
|
235
|
+
// per-instance flag wouldn't catch this case because the outer and inner
|
|
236
|
+
// renders each have their own. Module scope makes "is any rerender in
|
|
237
|
+
// flight" a single source of truth.
|
|
238
|
+
let chipRerenderBusy = false
|
|
239
|
+
|
|
240
|
+
// brain-extension: linear-decay multiplier for recency sizing. Notes touched today
|
|
241
|
+
// get 2.0×; notes >365 days old get 1.0×. Window pinned to one year so day-old vs
|
|
242
|
+
// week-old reads as a meaningful size delta. The radius clamp at the call site
|
|
243
|
+
// keeps catastrophic values bounded; tweak the constant here if it ever feels too
|
|
244
|
+
// aggressive.
|
|
245
|
+
function recencyMultiplier(mtime: number): number {
|
|
246
|
+
const days = (Date.now() - mtime) / 86_400_000
|
|
247
|
+
// Clamp on both ends — negative `days` from a future-dated note (e.g. a
|
|
248
|
+
// draft with frontmatter `date: 2027-01-01`) would otherwise yield a
|
|
249
|
+
// multiplier above 2.0 and break the [1.0, 2.0] contract the docstring
|
|
250
|
+
// promises. The radius clamp at the call site is a safety net, not a
|
|
251
|
+
// substitute for honest bounds here.
|
|
252
|
+
const decay = Math.min(1, Math.max(0, 1 - days / 365))
|
|
253
|
+
return 1 + decay
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// brain-extension: parse a contentIndex entry's `date` into epoch millis. Tolerates
|
|
257
|
+
// ISO strings (Quartz default), numeric epochs, and missing values.
|
|
258
|
+
function parseMtime(d: unknown): number | null {
|
|
259
|
+
// `typeof NaN === "number"` is true, so a bare type check would let NaN
|
|
260
|
+
// through and propagate into recencyMultiplier → nodeRadius → a PixiJS
|
|
261
|
+
// NaN-radius render. Number.isFinite rejects NaN and ±Infinity together.
|
|
262
|
+
if (typeof d === "number") return Number.isFinite(d) ? d : null
|
|
263
|
+
if (typeof d === "string") {
|
|
264
|
+
const parsed = Date.parse(d)
|
|
265
|
+
return Number.isNaN(parsed) ? null : parsed
|
|
266
|
+
}
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// brain-extension: HTML-escape a tooltip string. Frontmatter rule names and
|
|
271
|
+
// document titles are both untrusted-ish (authored content) — the tooltip uses
|
|
272
|
+
// innerHTML for line breaks so escaping prevents accidental markup injection.
|
|
273
|
+
function escapeHtml(s: string): string {
|
|
274
|
+
return s
|
|
275
|
+
.replace(/&/g, "&")
|
|
276
|
+
.replace(/</g, "<")
|
|
277
|
+
.replace(/>/g, ">")
|
|
278
|
+
.replace(/"/g, """)
|
|
279
|
+
.replace(/'/g, "'")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// brain-extension: format derived-edge metadata for the hover tooltip.
|
|
283
|
+
//
|
|
284
|
+
// Spec deviation: the original design said "show rule + evidence" but the
|
|
285
|
+
// contentIndex emitter's BrainLinkRecord carries `rule` and `weight` (no evidence
|
|
286
|
+
// — that lives one level deeper, per-rule). We surface rule + weight here. If a
|
|
287
|
+
// future iteration threads evidence into BrainLinkRecord, this is the spot to
|
|
288
|
+
// extend.
|
|
289
|
+
function formatDerivedMeta(rule: string | undefined, weight: number | undefined): string {
|
|
290
|
+
const parts: string[] = []
|
|
291
|
+
if (rule) parts.push(`rule: ${rule}`)
|
|
292
|
+
if (typeof weight === "number") parts.push(`weight ${weight.toFixed(2)}`)
|
|
293
|
+
return parts.join(" · ")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// brain-extension: draw a dashed line between two points. PixiJS Graphics has no
|
|
297
|
+
// native dash support, so we manually emit segments. The on/off pair maps to "draw
|
|
298
|
+
// `on` units, skip `off` units" along the line. Falls back to a solid stroke when
|
|
299
|
+
// `period <= 0` to avoid an infinite loop on a misconfigured dash pattern.
|
|
300
|
+
function drawDashedLine(
|
|
301
|
+
gfx: Graphics,
|
|
302
|
+
x1: number,
|
|
303
|
+
y1: number,
|
|
304
|
+
x2: number,
|
|
305
|
+
y2: number,
|
|
306
|
+
dash: [number, number],
|
|
307
|
+
width: number,
|
|
308
|
+
color: string,
|
|
309
|
+
alpha: number,
|
|
310
|
+
): void {
|
|
311
|
+
const dx = x2 - x1
|
|
312
|
+
const dy = y2 - y1
|
|
313
|
+
const length = Math.hypot(dx, dy)
|
|
314
|
+
if (length === 0) return
|
|
315
|
+
const ux = dx / length
|
|
316
|
+
const uy = dy / length
|
|
317
|
+
const [on, off] = dash
|
|
318
|
+
const period = on + off
|
|
319
|
+
if (period <= 0) {
|
|
320
|
+
gfx.moveTo(x1, y1).lineTo(x2, y2).stroke({ alpha, width, color })
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
let traveled = 0
|
|
324
|
+
while (traveled < length) {
|
|
325
|
+
const segStart = traveled
|
|
326
|
+
const segEnd = Math.min(traveled + on, length)
|
|
327
|
+
gfx.moveTo(x1 + ux * segStart, y1 + uy * segStart)
|
|
328
|
+
gfx.lineTo(x1 + ux * segEnd, y1 + uy * segEnd)
|
|
329
|
+
traveled += period
|
|
330
|
+
}
|
|
331
|
+
gfx.stroke({ alpha, width, color })
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function computeGraphDiagnostics(
|
|
335
|
+
nodes: NodeData[],
|
|
336
|
+
links: LinkData[],
|
|
337
|
+
): Map<SimpleSlug, NodeDiagnostic> {
|
|
338
|
+
const diagnostics = new Map<SimpleSlug, NodeDiagnostic>()
|
|
339
|
+
for (const node of nodes) {
|
|
340
|
+
diagnostics.set(node.id, {
|
|
341
|
+
incomingCount: 0,
|
|
342
|
+
outgoingCount: 0,
|
|
343
|
+
derivedCount: 0,
|
|
344
|
+
authoredCount: 0,
|
|
345
|
+
issueCount: 0,
|
|
346
|
+
issueLabels: [],
|
|
347
|
+
strongRelations: [],
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const link of links) {
|
|
352
|
+
const source = diagnostics.get(link.source.id)
|
|
353
|
+
const target = diagnostics.get(link.target.id)
|
|
354
|
+
if (source) {
|
|
355
|
+
source.outgoingCount += 1
|
|
356
|
+
source.strongRelations.push(link.target.id)
|
|
357
|
+
if (link.kind === "derived") source.derivedCount += 1
|
|
358
|
+
else source.authoredCount += 1
|
|
359
|
+
if (link.kind === "derived" && typeof link.weight === "number" && link.weight < 0.5) {
|
|
360
|
+
source.issueLabels.push("low-confidence derived relation")
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (target) {
|
|
364
|
+
target.incomingCount += 1
|
|
365
|
+
target.strongRelations.push(link.source.id)
|
|
366
|
+
if (link.kind === "derived") target.derivedCount += 1
|
|
367
|
+
else target.authoredCount += 1
|
|
368
|
+
if (link.kind === "derived" && typeof link.weight === "number" && link.weight < 0.5) {
|
|
369
|
+
target.issueLabels.push("low-confidence derived relation")
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const [id, diagnostic] of diagnostics) {
|
|
375
|
+
if (diagnostic.incomingCount === 0) diagnostic.issueLabels.push("no backlinks")
|
|
376
|
+
if (diagnostic.outgoingCount === 0 && !id.startsWith("tags/")) {
|
|
377
|
+
diagnostic.issueLabels.push("no outgoing links")
|
|
378
|
+
}
|
|
379
|
+
diagnostic.issueLabels = [...new Set(diagnostic.issueLabels)]
|
|
380
|
+
diagnostic.issueCount = diagnostic.issueLabels.length
|
|
381
|
+
diagnostic.strongRelations = [...new Set(diagnostic.strongRelations)].slice(0, 8)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return diagnostics
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function renderGraph(
|
|
388
|
+
graph: HTMLElement,
|
|
389
|
+
fullSlug: FullSlug,
|
|
390
|
+
cleanupRef: CleanupRef,
|
|
391
|
+
// brain-extension: when true, suppress every renderer extension (tier/source
|
|
392
|
+
// colors, derived-edge styling, recency sizing, search input, filter chips,
|
|
393
|
+
// orphan/tag-node hiding) so the graph renders with stock-Quartz visual
|
|
394
|
+
// semantics. Wired to the `brain-stock-graph-icon` button; the standard
|
|
395
|
+
// `global-graph-icon` and the local-graph render path leave this at false.
|
|
396
|
+
stockMode = false,
|
|
397
|
+
) {
|
|
398
|
+
// brain: hold the rebuild guard for the WHOLE render, not just the
|
|
399
|
+
// chip-click trampoline. The trampoline already sets the flag for chip-
|
|
400
|
+
// triggered rebuilds, but the INITIAL render (called directly from the
|
|
401
|
+
// nav handler with no trampoline) wasn't covered. A chip click during
|
|
402
|
+
// the initial render's await window (fetchData / app.init) would
|
|
403
|
+
// otherwise spawn a second renderGraph that races the outer one and
|
|
404
|
+
// leaks pixi state when the outer resumes. The trampoline sets the flag
|
|
405
|
+
// too — re-setting here is a benign no-op in that path; both end up
|
|
406
|
+
// false via either finally. Body is wrapped in try/finally below; the
|
|
407
|
+
// body itself is left at its original indentation to keep the diff
|
|
408
|
+
// small, since this is a structural wrap of an already-tested 1000-line
|
|
409
|
+
// function.
|
|
410
|
+
chipRerenderBusy = true
|
|
411
|
+
try {
|
|
412
|
+
const slug = simplifySlug(fullSlug)
|
|
413
|
+
const visited = getVisited()
|
|
414
|
+
removeAllChildren(graph)
|
|
415
|
+
|
|
416
|
+
// brain-extension: chip-rebuild trampoline. Reads the module-level
|
|
417
|
+
// `chipRerenderBusy` so an inner-render chip click that fires while an
|
|
418
|
+
// outer render is still awaiting `app.init` is dropped on the floor —
|
|
419
|
+
// double-tearing-down the same Pixi app would throw on the second
|
|
420
|
+
// `app.destroy()` call.
|
|
421
|
+
async function rerenderForChipChange() {
|
|
422
|
+
if (chipRerenderBusy) return
|
|
423
|
+
chipRerenderBusy = true
|
|
424
|
+
try {
|
|
425
|
+
cleanupRef.current()
|
|
426
|
+
await renderGraph(graph, fullSlug, cleanupRef, stockMode)
|
|
427
|
+
} finally {
|
|
428
|
+
chipRerenderBusy = false
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let {
|
|
433
|
+
drag: enableDrag,
|
|
434
|
+
zoom: enableZoom,
|
|
435
|
+
depth,
|
|
436
|
+
scale,
|
|
437
|
+
repelForce,
|
|
438
|
+
centerForce,
|
|
439
|
+
linkDistance,
|
|
440
|
+
fontSize,
|
|
441
|
+
opacityScale,
|
|
442
|
+
removeTags,
|
|
443
|
+
showTags,
|
|
444
|
+
focusOnHover,
|
|
445
|
+
enableRadial,
|
|
446
|
+
// brain-extension: brain config knobs. All optional; absent values fall through
|
|
447
|
+
// to upstream-equivalent behavior.
|
|
448
|
+
tierColors,
|
|
449
|
+
sourceColors,
|
|
450
|
+
hideOrphans,
|
|
451
|
+
hideTagNodes,
|
|
452
|
+
hideByFrontmatter,
|
|
453
|
+
derivedEdgeStyle,
|
|
454
|
+
recencySizing,
|
|
455
|
+
// brain-extension: when true, render an in-graph search input above the canvas.
|
|
456
|
+
// Wired below; runtime debouncer + SPA-nav-on-Enter live in the controls block.
|
|
457
|
+
searchEnabled,
|
|
458
|
+
// brain-extension: which chip rows to render. Each named dimension renders one
|
|
459
|
+
// row of chips ("All <dim>" plus one chip per value in `chipVocabularies[dim]`).
|
|
460
|
+
filterChips,
|
|
461
|
+
// brain-extension: scalar multiplier applied to nodeRadius() output and clamp
|
|
462
|
+
// bounds. Falls back to 1 when absent / non-finite so existing render contexts
|
|
463
|
+
// are unaffected. Resolved into `radiusMul` AFTER the stockMode override
|
|
464
|
+
// block below so the dot-grid view stays at the stock-Quartz radius.
|
|
465
|
+
nodeRadiusMultiplier,
|
|
466
|
+
// brain-extension: per-context label truncation cap. Long titles are
|
|
467
|
+
// truncated to `labelMaxLength - 1` chars + `…` and expanded on hover.
|
|
468
|
+
// Defaults to DEFAULT_LABEL_MAX (10) when absent / non-finite — sidebar
|
|
469
|
+
// panel overrides to a higher value so its sparse labels stay legible.
|
|
470
|
+
labelMaxLength,
|
|
471
|
+
// brain-extension: see D3Config docstrings. Both default to gentle
|
|
472
|
+
// curves so existing render contexts are unaffected.
|
|
473
|
+
nodeRadiusGrowthExponent,
|
|
474
|
+
wikiEdgeBaseAlpha,
|
|
475
|
+
hubLabelThreshold,
|
|
476
|
+
nodeRadiusCeiling,
|
|
477
|
+
hubLabelFontMultiplier,
|
|
478
|
+
diagnosticWorkbench,
|
|
479
|
+
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
|
|
480
|
+
const labelMax =
|
|
481
|
+
typeof labelMaxLength === "number" &&
|
|
482
|
+
Number.isFinite(labelMaxLength) &&
|
|
483
|
+
labelMaxLength > 0
|
|
484
|
+
? Math.floor(labelMaxLength)
|
|
485
|
+
: DEFAULT_LABEL_MAX
|
|
486
|
+
const radiusExponent =
|
|
487
|
+
typeof nodeRadiusGrowthExponent === "number" &&
|
|
488
|
+
Number.isFinite(nodeRadiusGrowthExponent) &&
|
|
489
|
+
nodeRadiusGrowthExponent > 0
|
|
490
|
+
? nodeRadiusGrowthExponent
|
|
491
|
+
: 0.5
|
|
492
|
+
const wikiEdgeAlpha =
|
|
493
|
+
typeof wikiEdgeBaseAlpha === "number" &&
|
|
494
|
+
Number.isFinite(wikiEdgeBaseAlpha) &&
|
|
495
|
+
wikiEdgeBaseAlpha >= 0 &&
|
|
496
|
+
wikiEdgeBaseAlpha <= 1
|
|
497
|
+
? wikiEdgeBaseAlpha
|
|
498
|
+
: 1
|
|
499
|
+
const hubThreshold =
|
|
500
|
+
typeof hubLabelThreshold === "number" &&
|
|
501
|
+
Number.isFinite(hubLabelThreshold) &&
|
|
502
|
+
hubLabelThreshold > 0
|
|
503
|
+
? Math.floor(hubLabelThreshold)
|
|
504
|
+
: DEFAULT_HUB_LABEL_THRESHOLD
|
|
505
|
+
const radiusCeiling =
|
|
506
|
+
typeof nodeRadiusCeiling === "number" &&
|
|
507
|
+
Number.isFinite(nodeRadiusCeiling) &&
|
|
508
|
+
nodeRadiusCeiling > 0
|
|
509
|
+
? nodeRadiusCeiling
|
|
510
|
+
: 10
|
|
511
|
+
const hubFontMul =
|
|
512
|
+
typeof hubLabelFontMultiplier === "number" &&
|
|
513
|
+
Number.isFinite(hubLabelFontMultiplier) &&
|
|
514
|
+
hubLabelFontMultiplier > 0
|
|
515
|
+
? hubLabelFontMultiplier
|
|
516
|
+
: 1
|
|
517
|
+
|
|
518
|
+
// brain-extension: stock-mode override. When the user opened the global graph
|
|
519
|
+
// via the dot-grid `brain-stock-graph-icon` button, every brain-extension
|
|
520
|
+
// renderer knob is forced off so the graph renders with stock-Quartz visual
|
|
521
|
+
// semantics (current/visited/gray colors, plain stroke for every edge,
|
|
522
|
+
// fixed-radius nodes, no in-graph search input, no chip rows, no orphan
|
|
523
|
+
// hiding). The chip-filter pass and search-highlight branches further down
|
|
524
|
+
// also short-circuit on stockMode so any persisted module-state from a
|
|
525
|
+
// local-graph interaction can't leak into the stock view.
|
|
526
|
+
if (stockMode) {
|
|
527
|
+
tierColors = undefined
|
|
528
|
+
sourceColors = undefined
|
|
529
|
+
hideOrphans = false
|
|
530
|
+
hideTagNodes = false
|
|
531
|
+
hideByFrontmatter = []
|
|
532
|
+
derivedEdgeStyle = undefined
|
|
533
|
+
recencySizing = false
|
|
534
|
+
searchEnabled = false
|
|
535
|
+
filterChips = []
|
|
536
|
+
nodeRadiusMultiplier = undefined
|
|
537
|
+
nodeRadiusGrowthExponent = undefined
|
|
538
|
+
wikiEdgeBaseAlpha = undefined
|
|
539
|
+
hubLabelThreshold = undefined
|
|
540
|
+
nodeRadiusCeiling = undefined
|
|
541
|
+
hubLabelFontMultiplier = undefined
|
|
542
|
+
diagnosticWorkbench = false
|
|
543
|
+
}
|
|
544
|
+
// brain-extension: resolve the radius multiplier AFTER stockMode so the
|
|
545
|
+
// dot-grid view stays at the stock-Quartz radius even when the layout
|
|
546
|
+
// sets a non-1 multiplier on the underlying globalGraph cfg.
|
|
547
|
+
const radiusMul =
|
|
548
|
+
typeof nodeRadiusMultiplier === "number" && Number.isFinite(nodeRadiusMultiplier)
|
|
549
|
+
? nodeRadiusMultiplier
|
|
550
|
+
: 1
|
|
551
|
+
|
|
552
|
+
// brain-extension: build the search/chip controls UI when configured. The
|
|
553
|
+
// elements are appended above the PixiJS canvas (which lands further down)
|
|
554
|
+
// so they read top-to-bottom: search input, then chip rows, then graph.
|
|
555
|
+
// Chip click handlers are wired here because they only need module-level
|
|
556
|
+
// chip state + cleanupRef; the search input's listeners are deferred until
|
|
557
|
+
// `applySearchHighlight` is in scope further down.
|
|
558
|
+
const filterChipsList: ("tier" | "source")[] = Array.isArray(filterChips)
|
|
559
|
+
? (filterChips as ("tier" | "source")[]).filter(
|
|
560
|
+
(d): d is "tier" | "source" => d === "tier" || d === "source",
|
|
561
|
+
)
|
|
562
|
+
: []
|
|
563
|
+
let controlsEl: HTMLDivElement | null = null
|
|
564
|
+
let searchInputEl: HTMLInputElement | null = null
|
|
565
|
+
// brain-extension: render the "Show unconnected" chip only on graphs
|
|
566
|
+
// whose layout actively hides orphans — otherwise the toggle has
|
|
567
|
+
// nothing to flip. Keeps the sidebar (hideOrphans:false) chip-free.
|
|
568
|
+
const showOrphanToggleChip = hideOrphans === true
|
|
569
|
+
if (searchEnabled || filterChipsList.length > 0 || showOrphanToggleChip) {
|
|
570
|
+
controlsEl = document.createElement("div")
|
|
571
|
+
controlsEl.className = diagnosticWorkbench
|
|
572
|
+
? "brain-graph-controls brain-graph-toolbar"
|
|
573
|
+
: "brain-graph-controls"
|
|
574
|
+
graph.appendChild(controlsEl)
|
|
575
|
+
if (searchEnabled) {
|
|
576
|
+
const input = document.createElement("input")
|
|
577
|
+
input.type = "search"
|
|
578
|
+
input.className = "brain-graph-search"
|
|
579
|
+
input.placeholder = "Search graph..."
|
|
580
|
+
input.value = currentSearchQuery
|
|
581
|
+
// brain-extension: spellcheck/autocomplete add nothing here and would
|
|
582
|
+
// surface red squiggles under partial slugs — turn them off explicitly.
|
|
583
|
+
input.spellcheck = false
|
|
584
|
+
input.autocomplete = "off"
|
|
585
|
+
controlsEl.appendChild(input)
|
|
586
|
+
searchInputEl = input
|
|
587
|
+
}
|
|
588
|
+
for (const dimension of filterChipsList) {
|
|
589
|
+
const row = document.createElement("div")
|
|
590
|
+
row.className = "brain-graph-chip-row"
|
|
591
|
+
row.dataset["dimension"] = dimension
|
|
592
|
+
const label = document.createElement("span")
|
|
593
|
+
label.className = "brain-graph-chip-label"
|
|
594
|
+
// Capitalize the dimension name for the row label ("tier" → "Tier:").
|
|
595
|
+
label.textContent = `${dimension[0]!.toUpperCase()}${dimension.slice(1)}:`
|
|
596
|
+
row.appendChild(label)
|
|
597
|
+
const allChip = document.createElement("button")
|
|
598
|
+
allChip.type = "button"
|
|
599
|
+
allChip.className = "brain-graph-chip brain-graph-chip-all"
|
|
600
|
+
allChip.textContent = "all"
|
|
601
|
+
// The "all" chip reads as active iff every value in the vocabulary is
|
|
602
|
+
// selected — i.e. the dimension is currently unfiltered.
|
|
603
|
+
if (
|
|
604
|
+
activeChipFilters[dimension].size === chipVocabularies[dimension].length
|
|
605
|
+
) {
|
|
606
|
+
allChip.classList.add("active")
|
|
607
|
+
}
|
|
608
|
+
allChip.addEventListener("click", () => {
|
|
609
|
+
activeChipFilters[dimension] = new Set<string>(
|
|
610
|
+
chipVocabularies[dimension],
|
|
611
|
+
)
|
|
612
|
+
void rerenderForChipChange()
|
|
613
|
+
})
|
|
614
|
+
row.appendChild(allChip)
|
|
615
|
+
for (const value of chipVocabularies[dimension]) {
|
|
616
|
+
const chip = document.createElement("button")
|
|
617
|
+
chip.type = "button"
|
|
618
|
+
chip.className = "brain-graph-chip"
|
|
619
|
+
chip.textContent = value
|
|
620
|
+
if (activeChipFilters[dimension].has(value)) chip.classList.add("active")
|
|
621
|
+
chip.addEventListener("click", () => {
|
|
622
|
+
const set = activeChipFilters[dimension]
|
|
623
|
+
if (set.has(value)) {
|
|
624
|
+
set.delete(value)
|
|
625
|
+
} else {
|
|
626
|
+
set.add(value)
|
|
627
|
+
}
|
|
628
|
+
void rerenderForChipChange()
|
|
629
|
+
})
|
|
630
|
+
row.appendChild(chip)
|
|
631
|
+
}
|
|
632
|
+
controlsEl.appendChild(row)
|
|
633
|
+
}
|
|
634
|
+
if (showOrphanToggleChip) {
|
|
635
|
+
// brain-extension: standalone toggle chip — no "all" reset, no
|
|
636
|
+
// dimension label. Reads as ON when orphans are currently
|
|
637
|
+
// visible (i.e. the toggle has been flipped to override the
|
|
638
|
+
// layout's hide-orphans default).
|
|
639
|
+
const row = document.createElement("div")
|
|
640
|
+
row.className = "brain-graph-chip-row"
|
|
641
|
+
row.dataset["dimension"] = "orphans"
|
|
642
|
+
const chip = document.createElement("button")
|
|
643
|
+
chip.type = "button"
|
|
644
|
+
chip.className = "brain-graph-chip"
|
|
645
|
+
chip.textContent = "Show unconnected"
|
|
646
|
+
if (hideOrphansToggleOn) chip.classList.add("active")
|
|
647
|
+
chip.addEventListener("click", () => {
|
|
648
|
+
hideOrphansToggleOn = !hideOrphansToggleOn
|
|
649
|
+
void rerenderForChipChange()
|
|
650
|
+
})
|
|
651
|
+
row.appendChild(chip)
|
|
652
|
+
controlsEl.appendChild(row)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const data: Map<SimpleSlug, BrainContentDetails> = new Map(
|
|
657
|
+
Object.entries<BrainContentDetails>(await fetchData).map(([k, v]) => [
|
|
658
|
+
simplifySlug(k as FullSlug),
|
|
659
|
+
v,
|
|
660
|
+
]),
|
|
661
|
+
)
|
|
662
|
+
const links: SimpleLinkData[] = []
|
|
663
|
+
const tags: SimpleSlug[] = []
|
|
664
|
+
const validLinks = new Set(data.keys())
|
|
665
|
+
|
|
666
|
+
const tweens = new Map<string, TweenNode>()
|
|
667
|
+
for (const [source, details] of data.entries()) {
|
|
668
|
+
// brain: prefer `linkRecords` (the contentIndex emitter's brain output) for
|
|
669
|
+
// kind/rule/weight; fall back to flat `links: SimpleSlug[]` from a
|
|
670
|
+
// stock-Quartz contentIndex.json (e.g. when the overlay was bypassed).
|
|
671
|
+
// Array.isArray narrows the index-signature `unknown` typing on
|
|
672
|
+
// BrainContentDetails.
|
|
673
|
+
let outgoingRecords: BrainLinkRecord[] = []
|
|
674
|
+
if (Array.isArray(details.linkRecords)) {
|
|
675
|
+
outgoingRecords = details.linkRecords as BrainLinkRecord[]
|
|
676
|
+
} else if (Array.isArray(details.links)) {
|
|
677
|
+
outgoingRecords = (details.links as SimpleSlug[]).map((target) => ({
|
|
678
|
+
target,
|
|
679
|
+
kind: "wiki" as const,
|
|
680
|
+
}))
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
for (const r of outgoingRecords) {
|
|
684
|
+
if (validLinks.has(r.target)) {
|
|
685
|
+
// brain: carry kind/rule/weight onto SimpleLinkData so the render branch
|
|
686
|
+
// and the tooltip can read them without re-resolving via data.get().
|
|
687
|
+
links.push({
|
|
688
|
+
source,
|
|
689
|
+
target: r.target,
|
|
690
|
+
kind: r.kind,
|
|
691
|
+
rule: r.rule,
|
|
692
|
+
weight: r.weight,
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (showTags) {
|
|
698
|
+
const detailsTags = (details.tags as string[] | undefined) ?? []
|
|
699
|
+
const localTags = detailsTags
|
|
700
|
+
.filter((tag) => !removeTags.includes(tag))
|
|
701
|
+
.map((tag) => simplifySlug(("tags/" + tag) as FullSlug))
|
|
702
|
+
|
|
703
|
+
tags.push(...localTags.filter((tag) => !tags.includes(tag)))
|
|
704
|
+
|
|
705
|
+
for (const tag of localTags) {
|
|
706
|
+
// brain: tag-edges are conceptually wiki — they reflect the doc's authored
|
|
707
|
+
// tag list, not derived inference.
|
|
708
|
+
links.push({ source, target: tag, kind: "wiki" })
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const neighbourhood = new Set<SimpleSlug>()
|
|
714
|
+
const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
|
|
715
|
+
if (depth >= 0) {
|
|
716
|
+
while (depth >= 0 && wl.length > 0) {
|
|
717
|
+
// compute neighbours
|
|
718
|
+
const cur = wl.shift()!
|
|
719
|
+
if (cur === "__SENTINEL") {
|
|
720
|
+
depth--
|
|
721
|
+
wl.push("__SENTINEL")
|
|
722
|
+
} else {
|
|
723
|
+
neighbourhood.add(cur)
|
|
724
|
+
const outgoing = links.filter((l) => l.source === cur)
|
|
725
|
+
const incoming = links.filter((l) => l.target === cur)
|
|
726
|
+
wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
validLinks.forEach((id) => neighbourhood.add(id))
|
|
731
|
+
if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// brain-extension: filter pass before simulation. Drop tag nodes (stricter than
|
|
735
|
+
// showTags=false; that only de-renders tag nodes, this removes them from the
|
|
736
|
+
// simulation entirely), frontmatter-flagged nodes, and orphans. Order matters —
|
|
737
|
+
// orphans is computed last because the prior two filters can demote a node to
|
|
738
|
+
// degree 0. This intentionally diverges from the plan's listing order
|
|
739
|
+
// (orphans-first); doing orphans last makes `hideOrphans + hideTagNodes` cut
|
|
740
|
+
// notes that were only linked through a tag node, which matches the
|
|
741
|
+
// user-facing intent ("hide stubs whose only connection was a tag hub")
|
|
742
|
+
// rather than the literal "drop nodes orphaned in the original graph". With
|
|
743
|
+
// the current contentIndex emitter, hideByFrontmatter only catches keys the
|
|
744
|
+
// emitter surfaces (`tier` / `source`); expanding to other keys (e.g.
|
|
745
|
+
// `index`, `moc`) requires the emitter to widen its frontmatter passthrough.
|
|
746
|
+
const filtered = new Set(neighbourhood)
|
|
747
|
+
// brain-extension: chip filter runs before the tag/frontmatter/orphan
|
|
748
|
+
// passes so subsequent passes only see nodes that survive the user's
|
|
749
|
+
// tier/source selection — orphan computation in particular has to be
|
|
750
|
+
// post-chip, otherwise hiding (say) every krisp node could leave nodes
|
|
751
|
+
// that were only linked through krisp pinned in place as "non-orphans".
|
|
752
|
+
// Empty chipFilter set is treated as a wildcard ("flipping all chips off
|
|
753
|
+
// shows everything", per acceptance), so deselecting every chip in a row
|
|
754
|
+
// disables that dimension's filter entirely.
|
|
755
|
+
//
|
|
756
|
+
// brain: forgiving rule — a node missing the relevant frontmatter field
|
|
757
|
+
// ALWAYS passes (we don't filter what we can't measure). Brain has
|
|
758
|
+
// legacy notes with patchy frontmatter; a strict filter would yank them
|
|
759
|
+
// from the graph the moment any chip is on, even though the user's
|
|
760
|
+
// selection has nothing to say about them. Don't tighten this without
|
|
761
|
+
// backfilling the corpus first.
|
|
762
|
+
//
|
|
763
|
+
// Tag aggregates also bypass — they're synthesized hubs, not docs.
|
|
764
|
+
for (const id of [...filtered]) {
|
|
765
|
+
if (id.startsWith("tags/")) continue
|
|
766
|
+
const details = data.get(id)
|
|
767
|
+
if (!details) continue
|
|
768
|
+
const nodeTier = typeof details.tier === "string" ? details.tier : undefined
|
|
769
|
+
const nodeSource =
|
|
770
|
+
typeof details.source === "string" ? details.source : undefined
|
|
771
|
+
// brain-extension: the persisted module-level chip filter is intentionally
|
|
772
|
+
// ignored in stockMode — the user clicked the dot-grid affordance to escape
|
|
773
|
+
// brain semantics, including any chip selection they had left active on
|
|
774
|
+
// the local graph.
|
|
775
|
+
if (
|
|
776
|
+
!stockMode &&
|
|
777
|
+
nodeTier &&
|
|
778
|
+
activeChipFilters.tier.size > 0 &&
|
|
779
|
+
!activeChipFilters.tier.has(nodeTier)
|
|
780
|
+
) {
|
|
781
|
+
filtered.delete(id)
|
|
782
|
+
continue
|
|
783
|
+
}
|
|
784
|
+
if (
|
|
785
|
+
!stockMode &&
|
|
786
|
+
nodeSource &&
|
|
787
|
+
activeChipFilters.source.size > 0 &&
|
|
788
|
+
!activeChipFilters.source.has(nodeSource)
|
|
789
|
+
) {
|
|
790
|
+
filtered.delete(id)
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (hideTagNodes) {
|
|
794
|
+
for (const id of [...filtered]) {
|
|
795
|
+
if (id.startsWith("tags/")) filtered.delete(id)
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (hideByFrontmatter && hideByFrontmatter.length > 0) {
|
|
799
|
+
for (const id of [...filtered]) {
|
|
800
|
+
const details = data.get(id) as Record<string, unknown> | undefined
|
|
801
|
+
if (!details) continue
|
|
802
|
+
for (const key of hideByFrontmatter) {
|
|
803
|
+
if (details[key]) {
|
|
804
|
+
filtered.delete(id)
|
|
805
|
+
break
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// Recompute the link list against the surviving node set first so degree-0
|
|
811
|
+
// computation reflects the post-filter graph.
|
|
812
|
+
const survivingLinks = links.filter(
|
|
813
|
+
(l) => filtered.has(l.source) && filtered.has(l.target),
|
|
814
|
+
)
|
|
815
|
+
// brain-extension: the "Show unconnected" chip toggles the orphan
|
|
816
|
+
// filter at runtime. When ON, orphans are kept regardless of the
|
|
817
|
+
// layout's `hideOrphans` cfg.
|
|
818
|
+
const effectiveHideOrphans = hideOrphans && !hideOrphansToggleOn
|
|
819
|
+
if (effectiveHideOrphans) {
|
|
820
|
+
const degree = new Map<SimpleSlug, number>()
|
|
821
|
+
for (const id of filtered) degree.set(id, 0)
|
|
822
|
+
for (const l of survivingLinks) {
|
|
823
|
+
degree.set(l.source, (degree.get(l.source) ?? 0) + 1)
|
|
824
|
+
degree.set(l.target, (degree.get(l.target) ?? 0) + 1)
|
|
825
|
+
}
|
|
826
|
+
// brain: never drop the current page even if it's an orphan in the filtered
|
|
827
|
+
// view — a graph rendered for page X with X missing from it would be
|
|
828
|
+
// confusing.
|
|
829
|
+
for (const [id, d] of degree) {
|
|
830
|
+
if (d === 0 && id !== slug) filtered.delete(id)
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const nodes = [...filtered].map((url) => {
|
|
835
|
+
const details = data.get(url)
|
|
836
|
+
const text = url.startsWith("tags/")
|
|
837
|
+
? "#" + url.substring(5)
|
|
838
|
+
: ((details?.title as string | undefined) ?? url)
|
|
839
|
+
return {
|
|
840
|
+
id: url,
|
|
841
|
+
text,
|
|
842
|
+
tags: (details?.tags as string[] | undefined) ?? [],
|
|
843
|
+
// brain-extension: pull tier/source/mtime onto NodeData for color and
|
|
844
|
+
// recency. `tier` and `source` are typed `string | undefined` on
|
|
845
|
+
// BrainContentDetails; runtime-narrow defensively in case the on-disk JSON
|
|
846
|
+
// carries something unexpected.
|
|
847
|
+
tier: typeof details?.tier === "string" ? details.tier : undefined,
|
|
848
|
+
source: typeof details?.source === "string" ? details.source : undefined,
|
|
849
|
+
mtime: parseMtime(details?.date),
|
|
850
|
+
} as NodeData
|
|
851
|
+
})
|
|
852
|
+
const graphData: { nodes: NodeData[]; links: LinkData[] } = {
|
|
853
|
+
nodes,
|
|
854
|
+
// brain: use the post-filter `survivingLinks` (not the unfiltered `links`)
|
|
855
|
+
// so dropped nodes don't reappear via their edges.
|
|
856
|
+
links: survivingLinks
|
|
857
|
+
.filter((l) => filtered.has(l.source) && filtered.has(l.target))
|
|
858
|
+
.map((l) => ({
|
|
859
|
+
source: nodes.find((n) => n.id === l.source)!,
|
|
860
|
+
target: nodes.find((n) => n.id === l.target)!,
|
|
861
|
+
// brain: propagate kind/rule/weight from SimpleLinkData onto LinkData.
|
|
862
|
+
kind: l.kind,
|
|
863
|
+
rule: l.rule,
|
|
864
|
+
weight: l.weight,
|
|
865
|
+
})),
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const renderMode: GraphRenderMode = diagnosticWorkbench ? "workbench" : "standard"
|
|
869
|
+
let selectedNodeId: SimpleSlug | null = slug
|
|
870
|
+
let workbenchMode: GraphWorkbenchMode = "overview"
|
|
871
|
+
const diagnostics = computeGraphDiagnostics(graphData.nodes, graphData.links)
|
|
872
|
+
let workbenchCanvasPane: HTMLDivElement | null = null
|
|
873
|
+
let workbenchInspector: HTMLElement | null = null
|
|
874
|
+
let workbenchRail: HTMLElement | null = null
|
|
875
|
+
|
|
876
|
+
function titleFor(id: SimpleSlug): string {
|
|
877
|
+
return ((data.get(id)?.title as string | undefined) ?? id).toString()
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function renderWorkbenchShell(): HTMLElement | null {
|
|
881
|
+
if (renderMode !== "workbench") return null
|
|
882
|
+
graph.classList.add("brain-graph-workbench-shell")
|
|
883
|
+
|
|
884
|
+
const rail = document.createElement("div")
|
|
885
|
+
rail.className = "brain-graph-mode-rail"
|
|
886
|
+
const modes: [GraphWorkbenchMode, string, string][] = [
|
|
887
|
+
["overview", "Overview", "All useful relations"],
|
|
888
|
+
["incoming", "Incoming", "Who points here"],
|
|
889
|
+
["outgoing", "Outgoing", "What this references"],
|
|
890
|
+
["missing", "Missing", "Thin or unlinked nodes"],
|
|
891
|
+
["suspicious", "Suspicious", "Low-confidence edges"],
|
|
892
|
+
["evidence", "Evidence", "Derived relations"],
|
|
893
|
+
]
|
|
894
|
+
for (const [mode, label, hint] of modes) {
|
|
895
|
+
const button = document.createElement("button")
|
|
896
|
+
button.type = "button"
|
|
897
|
+
button.className = `brain-graph-mode${mode === workbenchMode ? " active" : ""}`
|
|
898
|
+
button.innerHTML = `<span>${escapeHtml(label)}</span><small>${escapeHtml(hint)}</small>`
|
|
899
|
+
button.addEventListener("click", () => {
|
|
900
|
+
workbenchMode = mode
|
|
901
|
+
for (const child of rail.querySelectorAll(".brain-graph-mode")) {
|
|
902
|
+
child.classList.toggle("active", child === button)
|
|
903
|
+
}
|
|
904
|
+
applyWorkbenchMode()
|
|
905
|
+
updateWorkbenchInspector(selectedNodeId)
|
|
906
|
+
renderPixiFromD3()
|
|
907
|
+
})
|
|
908
|
+
rail.appendChild(button)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const inspector = document.createElement("aside")
|
|
912
|
+
inspector.className = "brain-graph-inspector"
|
|
913
|
+
const canvasPane = document.createElement("div")
|
|
914
|
+
canvasPane.className = "brain-graph-canvas-pane"
|
|
915
|
+
graph.prepend(rail)
|
|
916
|
+
graph.appendChild(canvasPane)
|
|
917
|
+
graph.appendChild(inspector)
|
|
918
|
+
workbenchCanvasPane = canvasPane
|
|
919
|
+
workbenchRail = rail
|
|
920
|
+
workbenchInspector = inspector
|
|
921
|
+
return inspector
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function updateWorkbenchInspector(nodeId: SimpleSlug | null) {
|
|
925
|
+
if (!workbenchInspector) return
|
|
926
|
+
if (!nodeId) {
|
|
927
|
+
workbenchInspector.innerHTML = "<h3>Graph Inspector</h3><p>Select a node to inspect relations.</p>"
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const node = graphData.nodes.find((n) => n.id === nodeId)
|
|
932
|
+
const diagnostic = diagnostics.get(nodeId)
|
|
933
|
+
if (!node || !diagnostic) {
|
|
934
|
+
workbenchInspector.innerHTML = "<h3>Graph Inspector</h3><p>No details available.</p>"
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const relationItems = diagnostic.strongRelations
|
|
939
|
+
.slice(0, 5)
|
|
940
|
+
.map((id) => `<li>${escapeHtml(titleFor(id))}</li>`)
|
|
941
|
+
.join("")
|
|
942
|
+
const issueItems = diagnostic.issueLabels.length
|
|
943
|
+
? diagnostic.issueLabels.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")
|
|
944
|
+
: "<li>No obvious graph issues from available data.</li>"
|
|
945
|
+
const sourceText = node.source ? ` / ${escapeHtml(node.source)}` : ""
|
|
946
|
+
|
|
947
|
+
workbenchInspector.innerHTML = `
|
|
948
|
+
<h3>${escapeHtml(node.text)}</h3>
|
|
949
|
+
<p class="brain-graph-inspector-meta">${escapeHtml(node.tier ?? "unknown")}${sourceText}</p>
|
|
950
|
+
<div class="brain-graph-metrics">
|
|
951
|
+
<div><strong>${diagnostic.incomingCount}</strong><span>incoming</span></div>
|
|
952
|
+
<div><strong>${diagnostic.outgoingCount}</strong><span>outgoing</span></div>
|
|
953
|
+
<div><strong>${diagnostic.derivedCount}</strong><span>derived</span></div>
|
|
954
|
+
<div><strong>${diagnostic.issueCount}</strong><span>issues</span></div>
|
|
955
|
+
</div>
|
|
956
|
+
<h4>Needs Review</h4>
|
|
957
|
+
<ul>${issueItems}</ul>
|
|
958
|
+
<h4>Strong Relations</h4>
|
|
959
|
+
<ul>${relationItems || "<li>No relations in this filtered view.</li>"}</ul>
|
|
960
|
+
`
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function applyWorkbenchMode() {
|
|
964
|
+
if (renderMode !== "workbench") return
|
|
965
|
+
for (const n of nodeRenderData) {
|
|
966
|
+
const diagnostic = diagnostics.get(n.simulationData.id)
|
|
967
|
+
if (!diagnostic) {
|
|
968
|
+
n.active = false
|
|
969
|
+
continue
|
|
970
|
+
}
|
|
971
|
+
if (workbenchMode === "missing") {
|
|
972
|
+
n.active = diagnostic.incomingCount === 0 || diagnostic.outgoingCount === 0
|
|
973
|
+
} else if (workbenchMode === "suspicious") {
|
|
974
|
+
n.active = diagnostic.issueLabels.some((label) => label.includes("low-confidence"))
|
|
975
|
+
} else if (workbenchMode === "evidence") {
|
|
976
|
+
n.active = diagnostic.derivedCount > 0
|
|
977
|
+
} else if (workbenchMode === "incoming" && selectedNodeId) {
|
|
978
|
+
n.active = graphData.links.some(
|
|
979
|
+
(l) => l.target.id === selectedNodeId && l.source.id === n.simulationData.id,
|
|
980
|
+
)
|
|
981
|
+
} else if (workbenchMode === "outgoing" && selectedNodeId) {
|
|
982
|
+
n.active = graphData.links.some(
|
|
983
|
+
(l) => l.source.id === selectedNodeId && l.target.id === n.simulationData.id,
|
|
984
|
+
)
|
|
985
|
+
} else {
|
|
986
|
+
n.active =
|
|
987
|
+
selectedNodeId === null ||
|
|
988
|
+
n.simulationData.id === selectedNodeId ||
|
|
989
|
+
hoveredNeighbours.has(n.simulationData.id)
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
for (const l of linkRenderData) {
|
|
993
|
+
if (workbenchMode === "evidence") l.active = l.simulationData.kind === "derived"
|
|
994
|
+
else if (selectedNodeId) {
|
|
995
|
+
l.active =
|
|
996
|
+
l.simulationData.source.id === selectedNodeId ||
|
|
997
|
+
l.simulationData.target.id === selectedNodeId
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// brain-extension: size the canvas to fill the panel below the
|
|
1003
|
+
// controls. `graph.offsetHeight` (the inner `.graph-container`) lags
|
|
1004
|
+
// layout when the canvas hasn't been appended yet — at init time it
|
|
1005
|
+
// can report just the controls' height, which would lock the canvas
|
|
1006
|
+
// to a tiny size for the lifetime of the page. Read the parent
|
|
1007
|
+
// `.graph-outer` instead: that's the 250px panel upstream sizes
|
|
1008
|
+
// explicitly via CSS, so it's the authoritative measure. Subtract
|
|
1009
|
+
// the controls' offsetHeight + margin-bottom (offsetHeight excludes
|
|
1010
|
+
// margin) to get the canvas allowance, with a small floor so a
|
|
1011
|
+
// pathological narrow sidebar can't produce a zero-height canvas
|
|
1012
|
+
// (PixiJS rejects 0).
|
|
1013
|
+
const outerEl = graph.closest(
|
|
1014
|
+
".graph-outer, .global-graph-container, .local-graph-container, .brain-graph-workbench-container",
|
|
1015
|
+
) as HTMLElement | null
|
|
1016
|
+
renderWorkbenchShell()
|
|
1017
|
+
updateWorkbenchInspector(selectedNodeId)
|
|
1018
|
+
|
|
1019
|
+
const controlsHeight = controlsEl
|
|
1020
|
+
? controlsEl.offsetHeight + parseFloat(getComputedStyle(controlsEl).marginBottom || "0")
|
|
1021
|
+
: 0
|
|
1022
|
+
const canvasHost = workbenchCanvasPane ?? graph
|
|
1023
|
+
const width = Math.max(canvasHost.clientWidth || graph.offsetWidth, 60)
|
|
1024
|
+
const panelHeight =
|
|
1025
|
+
renderMode === "workbench"
|
|
1026
|
+
? canvasHost.clientHeight
|
|
1027
|
+
: outerEl
|
|
1028
|
+
? outerEl.clientHeight
|
|
1029
|
+
: graph.offsetHeight
|
|
1030
|
+
const height = Math.max(
|
|
1031
|
+
renderMode === "workbench" ? panelHeight : panelHeight - controlsHeight,
|
|
1032
|
+
60,
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
// we virtualize the simulation and use pixi to actually render it
|
|
1036
|
+
const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
|
|
1037
|
+
.force("charge", forceManyBody().strength(-100 * repelForce))
|
|
1038
|
+
.force("center", forceCenter().strength(centerForce))
|
|
1039
|
+
.force("link", forceLink(graphData.links).distance(linkDistance))
|
|
1040
|
+
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
|
|
1041
|
+
|
|
1042
|
+
const radius = (Math.min(width, height) / 2) * 0.8
|
|
1043
|
+
if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2))
|
|
1044
|
+
|
|
1045
|
+
// brain-extension: pre-tick the simulation so the first frame renders a
|
|
1046
|
+
// converged layout instead of d3-force's default phyllotaxis spiral
|
|
1047
|
+
// (which initializes nodes in the lower-right quadrant of the canvas
|
|
1048
|
+
// and looks like "all my nodes loaded in the top-right corner" until
|
|
1049
|
+
// the user drags one and the simulation re-energizes). 300 ticks is
|
|
1050
|
+
// enough for both the local graph (≤ a few dozen nodes) and the
|
|
1051
|
+
// global graph (~1300 nodes at current corpus size) to settle into
|
|
1052
|
+
// a converged cluster; runtime cost is ~50-100ms on init, paid once
|
|
1053
|
+
// before first paint.
|
|
1054
|
+
simulation.tick(300)
|
|
1055
|
+
|
|
1056
|
+
// brain-extension: explicitly recenter the bounding box of all settled
|
|
1057
|
+
// nodes around (0, 0). `forceCenter` only equalizes the *mean* of node
|
|
1058
|
+
// positions — for asymmetric graphs (a dense central cluster plus a
|
|
1059
|
+
// handful of outlier leaves pulled outward by `forceManyBody`) the
|
|
1060
|
+
// mean and the bounding-box center diverge by tens of pixels. The
|
|
1061
|
+
// user's eye reads the bounding-box center against the panel's visual
|
|
1062
|
+
// center, so without this pass the cluster appears pushed to one side
|
|
1063
|
+
// even after the simulation has converged. This is layout-independent
|
|
1064
|
+
// and provably symmetric: maxX-minX horizontal extent / 2 and same
|
|
1065
|
+
// for vertical, then shift every node by the negation. Skipped on
|
|
1066
|
+
// empty graphs (no nodes → nothing to recenter).
|
|
1067
|
+
if (graphData.nodes.length > 0) {
|
|
1068
|
+
let minX = Infinity
|
|
1069
|
+
let maxX = -Infinity
|
|
1070
|
+
let minY = Infinity
|
|
1071
|
+
let maxY = -Infinity
|
|
1072
|
+
for (const n of graphData.nodes) {
|
|
1073
|
+
if (n.x === undefined || n.y === undefined) continue
|
|
1074
|
+
if (n.x < minX) minX = n.x
|
|
1075
|
+
if (n.x > maxX) maxX = n.x
|
|
1076
|
+
if (n.y < minY) minY = n.y
|
|
1077
|
+
if (n.y > maxY) maxY = n.y
|
|
1078
|
+
}
|
|
1079
|
+
if (Number.isFinite(minX) && Number.isFinite(minY)) {
|
|
1080
|
+
const cx = (minX + maxX) / 2
|
|
1081
|
+
const cy = (minY + maxY) / 2
|
|
1082
|
+
for (const n of graphData.nodes) {
|
|
1083
|
+
if (n.x !== undefined) n.x -= cx
|
|
1084
|
+
if (n.y !== undefined) n.y -= cy
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// brain-extension: freeze the simulation after the recenter pass.
|
|
1090
|
+
// `forceSimulation` keeps an internal d3-timer running even after
|
|
1091
|
+
// `tick(N)` returns — and on each tick `forceCenter` re-equalizes
|
|
1092
|
+
// the *mean* of positions to (0, 0), which immediately undoes the
|
|
1093
|
+
// bounding-box recenter above (mean and bbox center diverge for
|
|
1094
|
+
// asymmetric graphs). Stopping the timer pins the layout to the
|
|
1095
|
+
// recentered state. The drag handler below calls
|
|
1096
|
+
// `simulation.alphaTarget(1).restart()` on pointerdown, so user
|
|
1097
|
+
// interaction still re-energizes the simulation as before — only
|
|
1098
|
+
// the idle drift between renders is suppressed.
|
|
1099
|
+
simulation.stop()
|
|
1100
|
+
|
|
1101
|
+
// precompute style prop strings as pixi doesn't support css variables
|
|
1102
|
+
// brain: extend the precompute list with the brain palette CSS vars so color()
|
|
1103
|
+
// can resolve them without re-reading getComputedStyle on every frame. Variable
|
|
1104
|
+
// names must match `quartz/styles/graph.scss`; renaming there requires updating
|
|
1105
|
+
// here.
|
|
1106
|
+
const cssVars = [
|
|
1107
|
+
"--secondary",
|
|
1108
|
+
"--tertiary",
|
|
1109
|
+
"--gray",
|
|
1110
|
+
"--light",
|
|
1111
|
+
"--lightgray",
|
|
1112
|
+
"--dark",
|
|
1113
|
+
"--darkgray",
|
|
1114
|
+
"--bodyFont",
|
|
1115
|
+
// brain-extension: brain palette.
|
|
1116
|
+
"--brain-tier-vault",
|
|
1117
|
+
"--brain-tier-ingested",
|
|
1118
|
+
"--brain-source-krisp",
|
|
1119
|
+
"--brain-source-slack",
|
|
1120
|
+
"--brain-source-gmail",
|
|
1121
|
+
"--brain-source-manual",
|
|
1122
|
+
] as const
|
|
1123
|
+
const computedStyleMap = cssVars.reduce(
|
|
1124
|
+
(acc, key) => {
|
|
1125
|
+
acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
|
|
1126
|
+
return acc
|
|
1127
|
+
},
|
|
1128
|
+
{} as Record<(typeof cssVars)[number], string>,
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
// brain-extension: resolve a CSS variable name (with leading "--") to its
|
|
1132
|
+
// computed value. Falls back to runtime getComputedStyle when the var wasn't
|
|
1133
|
+
// precomputed (e.g. user-supplied tierColors pointing at a non-default var).
|
|
1134
|
+
const cssVar = (name: string): string => {
|
|
1135
|
+
const known = (computedStyleMap as Record<string, string>)[name]
|
|
1136
|
+
if (known) return known
|
|
1137
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name) || ""
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// brain: replace upstream's 3-way visited/current/gray heuristic with a
|
|
1141
|
+
// tier > source > gray fallback chain. The current page still gets the secondary
|
|
1142
|
+
// color (consistent with Quartz's "you are here" cue) and tag nodes still bias
|
|
1143
|
+
// to tertiary (consistent with their bordered-circle treatment further below).
|
|
1144
|
+
// Visited stays as the last fallback before --gray so the navigation memory
|
|
1145
|
+
// upstream provides isn't lost. This intentionally extends the plan's literal
|
|
1146
|
+
// `tier ?? source ?? gray` formula by inserting `visited` before the gray
|
|
1147
|
+
// fallback — losing upstream's navigation cue would be a regression for users
|
|
1148
|
+
// who rely on it to track where they've been in the graph.
|
|
1149
|
+
const color = (d: NodeData) => {
|
|
1150
|
+
const isCurrent = d.id === slug
|
|
1151
|
+
if (isCurrent) {
|
|
1152
|
+
return computedStyleMap["--secondary"]
|
|
1153
|
+
}
|
|
1154
|
+
if (d.id.startsWith("tags/")) {
|
|
1155
|
+
return computedStyleMap["--tertiary"]
|
|
1156
|
+
}
|
|
1157
|
+
// brain-extension: tier wins over source. Both fall back to visited / gray.
|
|
1158
|
+
if (d.tier && tierColors && tierColors[d.tier]) {
|
|
1159
|
+
const resolved = cssVar(tierColors[d.tier])
|
|
1160
|
+
if (resolved) return resolved
|
|
1161
|
+
}
|
|
1162
|
+
if (d.source && sourceColors && sourceColors[d.source]) {
|
|
1163
|
+
const resolved = cssVar(sourceColors[d.source])
|
|
1164
|
+
if (resolved) return resolved
|
|
1165
|
+
}
|
|
1166
|
+
if (visited.has(d.id)) {
|
|
1167
|
+
return computedStyleMap["--tertiary"]
|
|
1168
|
+
}
|
|
1169
|
+
return computedStyleMap["--gray"]
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function nodeRadius(d: NodeData) {
|
|
1173
|
+
const numLinks = graphData.links.filter(
|
|
1174
|
+
(l) => l.source.id === d.id || l.target.id === d.id,
|
|
1175
|
+
).length
|
|
1176
|
+
// brain-extension: configurable degree → radius curve. The default
|
|
1177
|
+
// exponent (0.5 = sqrt) gives a gentle hub/leaf ratio; bumping the
|
|
1178
|
+
// exponent toward 1 widens the spread so hubs visibly dominate
|
|
1179
|
+
// (matches Obsidian's "Settings" being ~5× a leaf node).
|
|
1180
|
+
const base = 2 + Math.pow(numLinks, radiusExponent)
|
|
1181
|
+
// brain-extension: scale by recency when enabled; clamp final radius to
|
|
1182
|
+
// [2, 10]. The previous [1, 4] clamp was too tight — any node with ≥ 4
|
|
1183
|
+
// links pinned to the ceiling, and any node touched today (recency
|
|
1184
|
+
// multiplier 2×) hit the ceiling at base 2, so the global graph rendered
|
|
1185
|
+
// every node at the same size. Lifting the ceiling to 10 lets degree
|
|
1186
|
+
// differences read visually (e.g. a hub with 25 links is ~3× larger
|
|
1187
|
+
// than a leaf with 1 link).
|
|
1188
|
+
const multiplier =
|
|
1189
|
+
recencySizing && d.mtime !== null && d.mtime !== undefined
|
|
1190
|
+
? recencyMultiplier(d.mtime)
|
|
1191
|
+
: 1
|
|
1192
|
+
// brain-extension: `radiusMul` (from the `nodeRadiusMultiplier` cfg knob)
|
|
1193
|
+
// scales both the radius value AND the clamp bounds, so a 2× multiplier
|
|
1194
|
+
// produces dots that are uniformly twice as big without compressing
|
|
1195
|
+
// hub/leaf size differences against the ceiling.
|
|
1196
|
+
return Math.min(
|
|
1197
|
+
radiusCeiling * radiusMul,
|
|
1198
|
+
Math.max(2 * radiusMul, base * multiplier * radiusMul),
|
|
1199
|
+
)
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function unscaledLabelSize(label: Text): { width: number; height: number } {
|
|
1203
|
+
const scaleX = Math.abs(label.scale.x)
|
|
1204
|
+
const scaleY = Math.abs(label.scale.y)
|
|
1205
|
+
const width =
|
|
1206
|
+
scaleX === 0 || !Number.isFinite(scaleX) ? label.width : label.width / scaleX
|
|
1207
|
+
const height =
|
|
1208
|
+
scaleY === 0 || !Number.isFinite(scaleY) ? label.height : label.height / scaleY
|
|
1209
|
+
return { width, height }
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function placeNodeLabel(
|
|
1213
|
+
label: Text,
|
|
1214
|
+
node: NodeRenderData,
|
|
1215
|
+
x: number,
|
|
1216
|
+
y: number,
|
|
1217
|
+
) {
|
|
1218
|
+
const labelSize = unscaledLabelSize(label)
|
|
1219
|
+
const diameter = node.radius * 2
|
|
1220
|
+
if (
|
|
1221
|
+
node.isHub &&
|
|
1222
|
+
labelSize.height <= diameter &&
|
|
1223
|
+
labelSize.width <= diameter
|
|
1224
|
+
) {
|
|
1225
|
+
label.anchor.set(0.5, 0.5)
|
|
1226
|
+
label.position.set(x, y)
|
|
1227
|
+
return
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
label.anchor.set(0.5, 0)
|
|
1231
|
+
label.position.set(x, y + node.radius + LABEL_NODE_GAP)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
let hoveredNodeId: string | null = null
|
|
1235
|
+
let hoveredNeighbours: Set<string> = new Set()
|
|
1236
|
+
// brain-extension: latest zoom-driven label opacity. The d3-zoom callback
|
|
1237
|
+
// writes here on every zoom event; `renderLabels` reads it to compute the
|
|
1238
|
+
// baseline alpha for non-active labels during hover (so they fade to a
|
|
1239
|
+
// small fraction of their natural opacity rather than disappearing /
|
|
1240
|
+
// staying full-bright).
|
|
1241
|
+
let currentScaleOpacity = 0
|
|
1242
|
+
const linkRenderData: LinkRenderData[] = []
|
|
1243
|
+
const nodeRenderData: NodeRenderData[] = []
|
|
1244
|
+
// brain-extension: search highlight state. When true, the renderers dim
|
|
1245
|
+
// non-active nodes/links the same way `focusOnHover` does — `n.active` is
|
|
1246
|
+
// populated by `applySearchHighlight` (defined below) from the search
|
|
1247
|
+
// input's debounced match pass. Hover and search both write to the same
|
|
1248
|
+
// `n.active` field; whichever fires last wins for that node, which matches
|
|
1249
|
+
// user intent (a hover after typing reveals the hovered node's neighbours,
|
|
1250
|
+
// not the search hits).
|
|
1251
|
+
let searchActive = false
|
|
1252
|
+
// brain-extension: debounce timer handle for the search input. Cleared on
|
|
1253
|
+
// cleanup so a pending fire after teardown doesn't reach a destroyed app.
|
|
1254
|
+
let searchDebounceTimer: number | null = null
|
|
1255
|
+
function updateHoverInfo(newHoveredId: string | null) {
|
|
1256
|
+
hoveredNodeId = newHoveredId
|
|
1257
|
+
|
|
1258
|
+
if (newHoveredId === null) {
|
|
1259
|
+
hoveredNeighbours = new Set()
|
|
1260
|
+
// brain: when a search highlight is active, mouseleave shouldn't
|
|
1261
|
+
// blow the matched-set away. Without this re-apply, hovering a node
|
|
1262
|
+
// and then leaving clears every n.active and dims the WHOLE graph
|
|
1263
|
+
// until the next keystroke. Re-applying the search rebuilds the
|
|
1264
|
+
// matched-set state in place; the pointerleave handler will
|
|
1265
|
+
// renderPixiFromD3 right after returning, which makes the (very
|
|
1266
|
+
// slight) double-render harmless — both calls render the same final
|
|
1267
|
+
// alpha state.
|
|
1268
|
+
if (searchActive) {
|
|
1269
|
+
applySearchHighlight(currentSearchQuery)
|
|
1270
|
+
return
|
|
1271
|
+
}
|
|
1272
|
+
for (const n of nodeRenderData) {
|
|
1273
|
+
n.active = false
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
for (const l of linkRenderData) {
|
|
1277
|
+
l.active = false
|
|
1278
|
+
}
|
|
1279
|
+
} else {
|
|
1280
|
+
hoveredNeighbours = new Set()
|
|
1281
|
+
for (const l of linkRenderData) {
|
|
1282
|
+
const linkData = l.simulationData
|
|
1283
|
+
if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
|
|
1284
|
+
hoveredNeighbours.add(linkData.source.id)
|
|
1285
|
+
hoveredNeighbours.add(linkData.target.id)
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
for (const n of nodeRenderData) {
|
|
1292
|
+
n.active = hoveredNeighbours.has(n.simulationData.id)
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (renderMode === "workbench") {
|
|
1296
|
+
applyWorkbenchMode()
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// brain-extension: tooltip element for derived-edge metadata.
|
|
1301
|
+
//
|
|
1302
|
+
// Spec deviation: the original design said "show rule + evidence on
|
|
1303
|
+
// derived-edge hover", but PixiJS edges have no native hit-test for thin lines,
|
|
1304
|
+
// so we surface the metadata on NODE hover instead — when a node has any
|
|
1305
|
+
// incident derived edges, the tooltip lists each edge's `rule` and `weight`.
|
|
1306
|
+
// The user gets the same info without the cost of per-frame polygon hit areas.
|
|
1307
|
+
// Documented here so a future maintainer revisiting the UX knows why the
|
|
1308
|
+
// tooltip is on nodes rather than edges. (And per the formatDerivedMeta
|
|
1309
|
+
// comment: design said "evidence" but the link record carries "weight" —
|
|
1310
|
+
// that's a separate spec/contentIndex-emitter reconciliation.)
|
|
1311
|
+
const tooltip = document.createElement("div")
|
|
1312
|
+
tooltip.className = "brain-graph-tooltip"
|
|
1313
|
+
tooltip.style.position = "absolute"
|
|
1314
|
+
tooltip.style.display = "none"
|
|
1315
|
+
tooltip.style.pointerEvents = "none"
|
|
1316
|
+
tooltip.style.background = computedStyleMap["--light"] || "#fff"
|
|
1317
|
+
tooltip.style.color = computedStyleMap["--dark"] || "#000"
|
|
1318
|
+
tooltip.style.border = `1px solid ${computedStyleMap["--lightgray"] || "#ccc"}`
|
|
1319
|
+
tooltip.style.borderRadius = "4px"
|
|
1320
|
+
tooltip.style.padding = "4px 8px"
|
|
1321
|
+
tooltip.style.fontSize = "0.75rem"
|
|
1322
|
+
tooltip.style.lineHeight = "1.3"
|
|
1323
|
+
tooltip.style.maxWidth = "260px"
|
|
1324
|
+
tooltip.style.zIndex = "10"
|
|
1325
|
+
if (!graph.style.position) graph.style.position = "relative"
|
|
1326
|
+
graph.appendChild(tooltip)
|
|
1327
|
+
|
|
1328
|
+
function showTooltipForNode(nodeId: string, clientX: number, clientY: number) {
|
|
1329
|
+
const derivedHere = graphData.links.filter(
|
|
1330
|
+
(l) =>
|
|
1331
|
+
l.kind === "derived" && (l.source.id === nodeId || l.target.id === nodeId),
|
|
1332
|
+
)
|
|
1333
|
+
if (derivedHere.length === 0) {
|
|
1334
|
+
tooltip.style.display = "none"
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
const lines = derivedHere.map((l) => {
|
|
1338
|
+
const otherId = l.source.id === nodeId ? l.target.id : l.source.id
|
|
1339
|
+
const otherText =
|
|
1340
|
+
((data.get(otherId)?.title as string | undefined) ?? otherId).toString()
|
|
1341
|
+
const meta = formatDerivedMeta(l.rule, l.weight)
|
|
1342
|
+
return meta ? `${otherText} — ${meta}` : otherText
|
|
1343
|
+
})
|
|
1344
|
+
tooltip.innerHTML = lines
|
|
1345
|
+
.map((line) => `<div>${escapeHtml(line)}</div>`)
|
|
1346
|
+
.join("")
|
|
1347
|
+
const rect = graph.getBoundingClientRect()
|
|
1348
|
+
tooltip.style.left = `${clientX - rect.left + 12}px`
|
|
1349
|
+
tooltip.style.top = `${clientY - rect.top + 12}px`
|
|
1350
|
+
tooltip.style.display = "block"
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function hideTooltip() {
|
|
1354
|
+
tooltip.style.display = "none"
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
let dragStartTime = 0
|
|
1358
|
+
let dragging = false
|
|
1359
|
+
|
|
1360
|
+
function renderLinks() {
|
|
1361
|
+
tweens.get("link")?.stop()
|
|
1362
|
+
const tweenGroup = new TweenGroup()
|
|
1363
|
+
|
|
1364
|
+
for (const l of linkRenderData) {
|
|
1365
|
+
// brain: derived edges start at the configured (typically <1) base alpha;
|
|
1366
|
+
// wiki edges use `wikiEdgeAlpha` (default 1, lowered on dense fullscreen
|
|
1367
|
+
// views so the graph reads "labels first, structure second"). Hover
|
|
1368
|
+
// dimming layers on top so a hovered-neighbour derived edge doesn't
|
|
1369
|
+
// fade further than its base.
|
|
1370
|
+
const baseAlpha =
|
|
1371
|
+
l.simulationData.kind === "derived" && derivedEdgeStyle
|
|
1372
|
+
? derivedEdgeStyle.alpha
|
|
1373
|
+
: wikiEdgeAlpha
|
|
1374
|
+
let alpha = baseAlpha
|
|
1375
|
+
|
|
1376
|
+
// if we are hovering over a node, we want to highlight the immediate neighbours
|
|
1377
|
+
// with full alpha and the rest with default alpha
|
|
1378
|
+
// brain: extend the dim trigger to cover search-highlight too — when
|
|
1379
|
+
// the user has typed a query, non-matching links fade the same way
|
|
1380
|
+
// hover-non-neighbours do.
|
|
1381
|
+
if (hoveredNodeId || searchActive || renderMode === "workbench") {
|
|
1382
|
+
alpha = l.active ? baseAlpha : baseAlpha * 0.2
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// brain-extension: paint hover-active edges in the brain accent
|
|
1386
|
+
// (`--secondary`, indigo-violet) instead of `--gray` — matches
|
|
1387
|
+
// Obsidian's behavior where selecting a node lights up its
|
|
1388
|
+
// immediate connections in a vivid color so the user can trace
|
|
1389
|
+
// them at a glance against the dimmed-down rest of the graph.
|
|
1390
|
+
// Non-active edges still fade to the very-light gray.
|
|
1391
|
+
l.color = l.active ? computedStyleMap["--secondary"] : computedStyleMap["--lightgray"]
|
|
1392
|
+
tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200))
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
tweenGroup.getAll().forEach((tw) => tw.start())
|
|
1396
|
+
tweens.set("link", {
|
|
1397
|
+
update: tweenGroup.update.bind(tweenGroup),
|
|
1398
|
+
stop() {
|
|
1399
|
+
tweenGroup.getAll().forEach((tw) => tw.stop())
|
|
1400
|
+
},
|
|
1401
|
+
})
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function renderLabels() {
|
|
1405
|
+
tweens.get("label")?.stop()
|
|
1406
|
+
const tweenGroup = new TweenGroup()
|
|
1407
|
+
|
|
1408
|
+
const defaultScale = 1 / scale
|
|
1409
|
+
const activeScale = defaultScale * 1.1
|
|
1410
|
+
// brain-extension: when a hover or search is active, dim every
|
|
1411
|
+
// non-active label to a small fraction of its natural opacity and
|
|
1412
|
+
// bring the active labels (hovered node + its neighbours / search
|
|
1413
|
+
// matches) to full brightness — matches Obsidian's behavior where
|
|
1414
|
+
// selecting a node lights up its neighbourhood and fades the rest
|
|
1415
|
+
// of the canvas. When nothing is active, every label tweens back
|
|
1416
|
+
// toward `currentScaleOpacity` (the zoom-driven baseline) so labels
|
|
1417
|
+
// return to their natural visibility on pointerleave.
|
|
1418
|
+
const hoverOrSearchActive =
|
|
1419
|
+
hoveredNodeId !== null || searchActive || renderMode === "workbench"
|
|
1420
|
+
const dimAlpha = currentScaleOpacity * 0.15
|
|
1421
|
+
for (const n of nodeRenderData) {
|
|
1422
|
+
const nodeId = n.simulationData.id
|
|
1423
|
+
let targetAlpha: number
|
|
1424
|
+
let targetScale: number
|
|
1425
|
+
if (hoveredNodeId === nodeId) {
|
|
1426
|
+
targetAlpha = 1
|
|
1427
|
+
targetScale = activeScale
|
|
1428
|
+
} else if (hoverOrSearchActive && n.active) {
|
|
1429
|
+
targetAlpha = 1
|
|
1430
|
+
targetScale = defaultScale
|
|
1431
|
+
} else if (hoverOrSearchActive) {
|
|
1432
|
+
targetAlpha = dimAlpha
|
|
1433
|
+
targetScale = defaultScale
|
|
1434
|
+
} else if (n.isHub) {
|
|
1435
|
+
// brain-extension: hub labels pinned to full alpha at rest so
|
|
1436
|
+
// they read at the same brightness as a hovered label — the
|
|
1437
|
+
// zoom-driven `currentScaleOpacity` was leaving them at ~0.3–0.5
|
|
1438
|
+
// at fit zoom, which felt washed out compared to stock Quartz.
|
|
1439
|
+
targetAlpha = 1
|
|
1440
|
+
targetScale = defaultScale
|
|
1441
|
+
} else {
|
|
1442
|
+
// brain-extension: non-hub labels dim by default — they reveal
|
|
1443
|
+
// on hover (via the hovered + n.active branches above) but
|
|
1444
|
+
// otherwise stay quiet so the canvas reads as "hubs first,
|
|
1445
|
+
// detail on demand."
|
|
1446
|
+
targetAlpha = dimAlpha
|
|
1447
|
+
targetScale = defaultScale
|
|
1448
|
+
}
|
|
1449
|
+
tweenGroup.add(
|
|
1450
|
+
new Tweened<Text>(n.label).to(
|
|
1451
|
+
{
|
|
1452
|
+
alpha: targetAlpha,
|
|
1453
|
+
scale: { x: targetScale, y: targetScale },
|
|
1454
|
+
},
|
|
1455
|
+
100,
|
|
1456
|
+
),
|
|
1457
|
+
)
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
tweenGroup.getAll().forEach((tw) => tw.start())
|
|
1461
|
+
tweens.set("label", {
|
|
1462
|
+
update: tweenGroup.update.bind(tweenGroup),
|
|
1463
|
+
stop() {
|
|
1464
|
+
tweenGroup.getAll().forEach((tw) => tw.stop())
|
|
1465
|
+
},
|
|
1466
|
+
})
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function renderNodes() {
|
|
1470
|
+
tweens.get("hover")?.stop()
|
|
1471
|
+
|
|
1472
|
+
const tweenGroup = new TweenGroup()
|
|
1473
|
+
for (const n of nodeRenderData) {
|
|
1474
|
+
let alpha = 1
|
|
1475
|
+
|
|
1476
|
+
// if we are hovering over a node, we want to highlight the immediate neighbours
|
|
1477
|
+
// brain: extend the dim trigger to cover search-highlight. Search
|
|
1478
|
+
// bypasses the `focusOnHover` gate intentionally — it's an explicit
|
|
1479
|
+
// user action that should always dim non-matches, regardless of
|
|
1480
|
+
// whether the layout opted into hover focus.
|
|
1481
|
+
if ((hoveredNodeId !== null && focusOnHover) || searchActive || renderMode === "workbench") {
|
|
1482
|
+
alpha = n.active ? 1 : 0.2
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
tweenGroup.getAll().forEach((tw) => tw.start())
|
|
1489
|
+
tweens.set("hover", {
|
|
1490
|
+
update: tweenGroup.update.bind(tweenGroup),
|
|
1491
|
+
stop() {
|
|
1492
|
+
tweenGroup.getAll().forEach((tw) => tw.stop())
|
|
1493
|
+
},
|
|
1494
|
+
})
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function renderPixiFromD3() {
|
|
1498
|
+
renderNodes()
|
|
1499
|
+
renderLinks()
|
|
1500
|
+
renderLabels()
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
tweens.forEach((tween) => tween.stop())
|
|
1504
|
+
tweens.clear()
|
|
1505
|
+
|
|
1506
|
+
const app = new Application()
|
|
1507
|
+
await app.init({
|
|
1508
|
+
width,
|
|
1509
|
+
height,
|
|
1510
|
+
antialias: true,
|
|
1511
|
+
autoStart: false,
|
|
1512
|
+
autoDensity: true,
|
|
1513
|
+
backgroundAlpha: 0,
|
|
1514
|
+
preference: "webgpu",
|
|
1515
|
+
resolution: window.devicePixelRatio,
|
|
1516
|
+
eventMode: "static",
|
|
1517
|
+
})
|
|
1518
|
+
canvasHost.appendChild(app.canvas)
|
|
1519
|
+
let resizeObserver: ResizeObserver | null = null
|
|
1520
|
+
let resizeRerenderTimer: number | null = null
|
|
1521
|
+
let observedCanvasWidth = width
|
|
1522
|
+
let observedCanvasHeight = height
|
|
1523
|
+
if (renderMode === "workbench" && workbenchCanvasPane && "ResizeObserver" in window) {
|
|
1524
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
1525
|
+
const entry = entries[0]
|
|
1526
|
+
if (!entry) return
|
|
1527
|
+
const nextWidth = Math.round(entry.contentRect.width)
|
|
1528
|
+
const nextHeight = Math.round(entry.contentRect.height)
|
|
1529
|
+
if (
|
|
1530
|
+
Math.abs(nextWidth - observedCanvasWidth) < 2 &&
|
|
1531
|
+
Math.abs(nextHeight - observedCanvasHeight) < 2
|
|
1532
|
+
) {
|
|
1533
|
+
return
|
|
1534
|
+
}
|
|
1535
|
+
observedCanvasWidth = nextWidth
|
|
1536
|
+
observedCanvasHeight = nextHeight
|
|
1537
|
+
if (resizeRerenderTimer !== null) window.clearTimeout(resizeRerenderTimer)
|
|
1538
|
+
resizeRerenderTimer = window.setTimeout(() => {
|
|
1539
|
+
resizeRerenderTimer = null
|
|
1540
|
+
void rerenderForChipChange()
|
|
1541
|
+
}, 120)
|
|
1542
|
+
})
|
|
1543
|
+
resizeObserver.observe(workbenchCanvasPane)
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const stage = app.stage
|
|
1547
|
+
stage.interactive = false
|
|
1548
|
+
|
|
1549
|
+
const labelsContainer = new Container<Text>({ zIndex: 3, isRenderGroup: true })
|
|
1550
|
+
const nodesContainer = new Container<Graphics>({ zIndex: 2, isRenderGroup: true })
|
|
1551
|
+
const linkContainer = new Container<Graphics>({ zIndex: 1, isRenderGroup: true })
|
|
1552
|
+
stage.addChild(nodesContainer, labelsContainer, linkContainer)
|
|
1553
|
+
|
|
1554
|
+
for (const n of graphData.nodes) {
|
|
1555
|
+
const nodeId = n.id
|
|
1556
|
+
|
|
1557
|
+
// brain-extension: cache the full + truncated label strings on the
|
|
1558
|
+
// closure so the pointer handlers below can flip between them without
|
|
1559
|
+
// recomputing on every hover. When the title already fits inside
|
|
1560
|
+
// `labelMax`, both strings are identical and the value-equality guard
|
|
1561
|
+
// in the handlers short-circuits the swap (no Pixi atlas churn).
|
|
1562
|
+
const fullText = n.text
|
|
1563
|
+
// brain-extension: any node above HUB_LABEL_THRESHOLD links is treated
|
|
1564
|
+
// as "big enough to deserve its full title" — the layout's truncation
|
|
1565
|
+
// cap is bypassed for these nodes (in every render context, sidebar
|
|
1566
|
+
// and modals alike) so the structural anchors of the graph stay
|
|
1567
|
+
// immediately readable without requiring a hover.
|
|
1568
|
+
const numLinks = graphData.links.filter(
|
|
1569
|
+
(l) => l.source.id === n.id || l.target.id === n.id,
|
|
1570
|
+
).length
|
|
1571
|
+
const isHubNode = numLinks >= hubThreshold
|
|
1572
|
+
const truncatedText = isHubNode ? fullText : shortLabel(fullText, labelMax)
|
|
1573
|
+
// brain-extension: hub nodes get a larger label fontSize (× hubFontMul)
|
|
1574
|
+
// so they read as "this is a structurally important page" at a glance.
|
|
1575
|
+
const labelFontSize = (isHubNode ? fontSize * hubFontMul : fontSize) * 15
|
|
1576
|
+
|
|
1577
|
+
const label = new Text({
|
|
1578
|
+
interactive: false,
|
|
1579
|
+
eventMode: "none",
|
|
1580
|
+
text: truncatedText,
|
|
1581
|
+
alpha: 0,
|
|
1582
|
+
anchor: { x: 0.5, y: 0.5 },
|
|
1583
|
+
style: {
|
|
1584
|
+
fontSize: labelFontSize,
|
|
1585
|
+
fill: computedStyleMap["--dark"],
|
|
1586
|
+
fontFamily: computedStyleMap["--bodyFont"],
|
|
1587
|
+
},
|
|
1588
|
+
resolution: window.devicePixelRatio * 4,
|
|
1589
|
+
})
|
|
1590
|
+
label.scale.set(1 / scale)
|
|
1591
|
+
|
|
1592
|
+
let oldLabelOpacity = 0
|
|
1593
|
+
const isTagNode = nodeId.startsWith("tags/")
|
|
1594
|
+
const radius = nodeRadius(n)
|
|
1595
|
+
const gfx = new Graphics({
|
|
1596
|
+
interactive: true,
|
|
1597
|
+
label: nodeId,
|
|
1598
|
+
eventMode: "static",
|
|
1599
|
+
hitArea: new Circle(0, 0, radius),
|
|
1600
|
+
cursor: "pointer",
|
|
1601
|
+
})
|
|
1602
|
+
.circle(0, 0, radius)
|
|
1603
|
+
.fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
|
|
1604
|
+
.on("pointerover", (e) => {
|
|
1605
|
+
selectedNodeId = e.target.label as SimpleSlug
|
|
1606
|
+
updateWorkbenchInspector(selectedNodeId)
|
|
1607
|
+
updateHoverInfo(e.target.label)
|
|
1608
|
+
oldLabelOpacity = label.alpha
|
|
1609
|
+
// brain-extension: swap to the full title on hover. Pixi's Text
|
|
1610
|
+
// regenerates its glyph atlas on `.text =` mutation, so we only
|
|
1611
|
+
// assign when the value differs (no-op when already full).
|
|
1612
|
+
if (fullText !== truncatedText && label.text !== fullText) {
|
|
1613
|
+
label.text = fullText
|
|
1614
|
+
}
|
|
1615
|
+
// brain-extension: surface derived-edge metadata when this node has any
|
|
1616
|
+
// incident derived edges. Spec deviation rationale documented at the
|
|
1617
|
+
// tooltip-create site above. Pixi pointer events expose clientX/clientY
|
|
1618
|
+
// on FederatedPointerEvent — narrow defensively in case a future Pixi
|
|
1619
|
+
// version reshapes the event.
|
|
1620
|
+
const ev = e as unknown as { clientX?: number; clientY?: number }
|
|
1621
|
+
const cx = typeof ev.clientX === "number" ? ev.clientX : 0
|
|
1622
|
+
const cy = typeof ev.clientY === "number" ? ev.clientY : 0
|
|
1623
|
+
showTooltipForNode(e.target.label, cx, cy)
|
|
1624
|
+
if (!dragging) {
|
|
1625
|
+
renderPixiFromD3()
|
|
1626
|
+
}
|
|
1627
|
+
})
|
|
1628
|
+
.on("pointerleave", () => {
|
|
1629
|
+
updateHoverInfo(null)
|
|
1630
|
+
label.alpha = oldLabelOpacity
|
|
1631
|
+
// brain-extension: restore the truncated label when the cursor leaves.
|
|
1632
|
+
if (fullText !== truncatedText && label.text !== truncatedText) {
|
|
1633
|
+
label.text = truncatedText
|
|
1634
|
+
}
|
|
1635
|
+
// brain-extension: hide the derived-edge tooltip when the cursor leaves.
|
|
1636
|
+
hideTooltip()
|
|
1637
|
+
if (!dragging) {
|
|
1638
|
+
renderPixiFromD3()
|
|
1639
|
+
}
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
if (isTagNode) {
|
|
1643
|
+
gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] })
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
nodesContainer.addChild(gfx)
|
|
1647
|
+
labelsContainer.addChild(label)
|
|
1648
|
+
|
|
1649
|
+
const nodeRenderDatum: NodeRenderData = {
|
|
1650
|
+
simulationData: n,
|
|
1651
|
+
gfx,
|
|
1652
|
+
label,
|
|
1653
|
+
radius,
|
|
1654
|
+
color: color(n),
|
|
1655
|
+
alpha: 1,
|
|
1656
|
+
active: false,
|
|
1657
|
+
isHub: isHubNode,
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
nodeRenderData.push(nodeRenderDatum)
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
for (const l of graphData.links) {
|
|
1664
|
+
const gfx = new Graphics({ interactive: false, eventMode: "none" })
|
|
1665
|
+
linkContainer.addChild(gfx)
|
|
1666
|
+
|
|
1667
|
+
const linkRenderDatum: LinkRenderData = {
|
|
1668
|
+
simulationData: l,
|
|
1669
|
+
gfx,
|
|
1670
|
+
color: computedStyleMap["--lightgray"],
|
|
1671
|
+
alpha: 1,
|
|
1672
|
+
active: false,
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
linkRenderData.push(linkRenderDatum)
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if (renderMode === "workbench") {
|
|
1679
|
+
applyWorkbenchMode()
|
|
1680
|
+
renderPixiFromD3()
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// brain-extension: apply a debounced search query against the loaded
|
|
1684
|
+
// contentIndex. Matches against `title` and `tags` only — we deliberately
|
|
1685
|
+
// skip the full `content` field (Quartz embeds the rendered body there)
|
|
1686
|
+
// because substring-matching every doc body on every keystroke is slow
|
|
1687
|
+
// for personal corpora >1k docs and adds noise to the highlight set.
|
|
1688
|
+
// Active links are only the ones whose endpoints are BOTH matched, which
|
|
1689
|
+
// means a hop between two hits stays bright while pendant hits stand
|
|
1690
|
+
// alone. Empty/whitespace query → clear highlights and let the existing
|
|
1691
|
+
// hover machinery take over.
|
|
1692
|
+
function applySearchHighlight(query: string) {
|
|
1693
|
+
const trimmed = query.trim()
|
|
1694
|
+
if (!trimmed) {
|
|
1695
|
+
searchActive = false
|
|
1696
|
+
for (const n of nodeRenderData) n.active = false
|
|
1697
|
+
for (const l of linkRenderData) l.active = false
|
|
1698
|
+
renderPixiFromD3()
|
|
1699
|
+
return
|
|
1700
|
+
}
|
|
1701
|
+
const q = trimmed.toLowerCase()
|
|
1702
|
+
const matched = new Set<SimpleSlug>()
|
|
1703
|
+
for (const n of nodeRenderData) {
|
|
1704
|
+
const id = n.simulationData.id
|
|
1705
|
+
const details = data.get(id)
|
|
1706
|
+
const titleStr =
|
|
1707
|
+
typeof details?.title === "string" ? details.title.toLowerCase() : ""
|
|
1708
|
+
const rawTags = details?.tags
|
|
1709
|
+
const tagsList: string[] = Array.isArray(rawTags)
|
|
1710
|
+
? (rawTags as unknown[])
|
|
1711
|
+
.filter((t): t is string => typeof t === "string")
|
|
1712
|
+
.map((t) => t.toLowerCase())
|
|
1713
|
+
: []
|
|
1714
|
+
if (titleStr.includes(q) || tagsList.some((t) => t.includes(q))) {
|
|
1715
|
+
matched.add(id)
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
searchActive = true
|
|
1719
|
+
for (const n of nodeRenderData) {
|
|
1720
|
+
n.active = matched.has(n.simulationData.id)
|
|
1721
|
+
}
|
|
1722
|
+
// brain: extends the plan's literal "dim non-matching nodes" to also
|
|
1723
|
+
// light up edges where BOTH endpoints matched. The plan only specifies
|
|
1724
|
+
// node-level highlighting, but a search where every node fades and
|
|
1725
|
+
// every edge fades looks broken — keeping cluster-internal edges
|
|
1726
|
+
// bright preserves the "cluster of matches" gestalt. Pendant edges
|
|
1727
|
+
// (one endpoint matched, one not) still dim along with the unmatched
|
|
1728
|
+
// node, which is the desired UX.
|
|
1729
|
+
for (const l of linkRenderData) {
|
|
1730
|
+
const ld = l.simulationData
|
|
1731
|
+
l.active = matched.has(ld.source.id) && matched.has(ld.target.id)
|
|
1732
|
+
}
|
|
1733
|
+
renderPixiFromD3()
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if (searchInputEl) {
|
|
1737
|
+
const inputEl = searchInputEl
|
|
1738
|
+
inputEl.addEventListener("input", () => {
|
|
1739
|
+
currentSearchQuery = inputEl.value
|
|
1740
|
+
if (searchDebounceTimer !== null) {
|
|
1741
|
+
window.clearTimeout(searchDebounceTimer)
|
|
1742
|
+
}
|
|
1743
|
+
// 100ms debounce — fast enough that typing feels live, slow enough
|
|
1744
|
+
// that holding a key doesn't fire the match pass per repeat tick.
|
|
1745
|
+
searchDebounceTimer = window.setTimeout(() => {
|
|
1746
|
+
searchDebounceTimer = null
|
|
1747
|
+
applySearchHighlight(currentSearchQuery)
|
|
1748
|
+
}, 100)
|
|
1749
|
+
})
|
|
1750
|
+
inputEl.addEventListener("keydown", (e) => {
|
|
1751
|
+
if (e.key !== "Enter") return
|
|
1752
|
+
e.preventDefault()
|
|
1753
|
+
const q = inputEl.value.trim().toLowerCase()
|
|
1754
|
+
if (!q) return
|
|
1755
|
+
// brain: top-hit picker — title-priority with lowest-substring-index
|
|
1756
|
+
// wins. Two intentional rules:
|
|
1757
|
+
// 1. TITLE matches only — tag-only matches keep the highlight but
|
|
1758
|
+
// don't qualify for nav. A "tags" hit means the QUERY hit a
|
|
1759
|
+
// tag, not that the user wants to land on the tag-aggregate
|
|
1760
|
+
// page; navigating there would feel wrong.
|
|
1761
|
+
// 2. LOWEST INDEX wins — "person-a" matching "person-x last-c" (idx 0)
|
|
1762
|
+
// beats "ASKING_PERSON-A.md" (idx 7). Substring position is a rough
|
|
1763
|
+
// "is this the document about X" heuristic; the alternative
|
|
1764
|
+
// (alphabetical, recency, link-degree) all need more state and
|
|
1765
|
+
// don't measurably improve the top-pick for a personal corpus.
|
|
1766
|
+
// Tag-aggregate slugs (`tags/foo`) are skipped explicitly so the
|
|
1767
|
+
// picker never lands on one even if its slug substring matches.
|
|
1768
|
+
let bestId: SimpleSlug | null = null
|
|
1769
|
+
let bestIdx = Number.POSITIVE_INFINITY
|
|
1770
|
+
for (const n of nodeRenderData) {
|
|
1771
|
+
const id = n.simulationData.id
|
|
1772
|
+
if (id.startsWith("tags/")) continue
|
|
1773
|
+
const details = data.get(id)
|
|
1774
|
+
const titleStr =
|
|
1775
|
+
typeof details?.title === "string" ? details.title.toLowerCase() : ""
|
|
1776
|
+
const idx = titleStr.indexOf(q)
|
|
1777
|
+
if (idx >= 0 && idx < bestIdx) {
|
|
1778
|
+
bestIdx = idx
|
|
1779
|
+
bestId = id
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
if (bestId) {
|
|
1783
|
+
const targ = resolveRelative(fullSlug, bestId)
|
|
1784
|
+
window.spaNavigate(new URL(targ, window.location.toString()))
|
|
1785
|
+
}
|
|
1786
|
+
})
|
|
1787
|
+
// Replay any persisted query on chip-driven rerender so flipping a chip
|
|
1788
|
+
// mid-search keeps the highlight intact rather than blowing it away.
|
|
1789
|
+
// brain-extension: stockMode opted out of search above (no input element
|
|
1790
|
+
// was rendered), so don't replay a leftover module-level query — the
|
|
1791
|
+
// stock view should look identical regardless of prior search state.
|
|
1792
|
+
if (currentSearchQuery && !stockMode) {
|
|
1793
|
+
applySearchHighlight(currentSearchQuery)
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
let currentTransform = zoomIdentity
|
|
1798
|
+
if (enableDrag) {
|
|
1799
|
+
select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
|
|
1800
|
+
drag<HTMLCanvasElement, NodeData | undefined>()
|
|
1801
|
+
.container(() => app.canvas)
|
|
1802
|
+
.subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
|
|
1803
|
+
.on("start", function dragstarted(event) {
|
|
1804
|
+
if (!event.active) simulation.alphaTarget(1).restart()
|
|
1805
|
+
event.subject.fx = event.subject.x
|
|
1806
|
+
event.subject.fy = event.subject.y
|
|
1807
|
+
event.subject.__initialDragPos = {
|
|
1808
|
+
x: event.subject.x,
|
|
1809
|
+
y: event.subject.y,
|
|
1810
|
+
fx: event.subject.fx,
|
|
1811
|
+
fy: event.subject.fy,
|
|
1812
|
+
}
|
|
1813
|
+
dragStartTime = Date.now()
|
|
1814
|
+
dragging = true
|
|
1815
|
+
})
|
|
1816
|
+
.on("drag", function dragged(event) {
|
|
1817
|
+
const initPos = event.subject.__initialDragPos
|
|
1818
|
+
event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
|
|
1819
|
+
event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
|
|
1820
|
+
})
|
|
1821
|
+
.on("end", function dragended(event) {
|
|
1822
|
+
if (!event.active) simulation.alphaTarget(0)
|
|
1823
|
+
event.subject.fx = null
|
|
1824
|
+
event.subject.fy = null
|
|
1825
|
+
dragging = false
|
|
1826
|
+
|
|
1827
|
+
// if the time between mousedown and mouseup is short, we consider it a click
|
|
1828
|
+
if (Date.now() - dragStartTime < 500) {
|
|
1829
|
+
const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
|
|
1830
|
+
const targ = resolveRelative(fullSlug, node.id)
|
|
1831
|
+
window.spaNavigate(new URL(targ, window.location.toString()))
|
|
1832
|
+
}
|
|
1833
|
+
}),
|
|
1834
|
+
)
|
|
1835
|
+
} else {
|
|
1836
|
+
for (const node of nodeRenderData) {
|
|
1837
|
+
node.gfx.on("click", () => {
|
|
1838
|
+
const targ = resolveRelative(fullSlug, node.simulationData.id)
|
|
1839
|
+
window.spaNavigate(new URL(targ, window.location.toString()))
|
|
1840
|
+
})
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if (enableZoom) {
|
|
1845
|
+
const zoomBehavior = zoom<HTMLCanvasElement, NodeData>()
|
|
1846
|
+
.extent([
|
|
1847
|
+
[0, 0],
|
|
1848
|
+
[width, height],
|
|
1849
|
+
])
|
|
1850
|
+
.scaleExtent([0.25, 4])
|
|
1851
|
+
.on("zoom", ({ transform }) => {
|
|
1852
|
+
currentTransform = transform
|
|
1853
|
+
stage.scale.set(transform.k, transform.k)
|
|
1854
|
+
stage.position.set(transform.x, transform.y)
|
|
1855
|
+
|
|
1856
|
+
// zoom adjusts opacity of labels too
|
|
1857
|
+
const scale = transform.k * opacityScale
|
|
1858
|
+
let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
|
|
1859
|
+
currentScaleOpacity = scaleOpacity
|
|
1860
|
+
// brain-extension: respect both the hover-dim state AND the
|
|
1861
|
+
// hub-vs-non-hub baseline when re-applying the zoom-driven
|
|
1862
|
+
// opacity. Without this, zooming would clobber both the
|
|
1863
|
+
// hover-dim effect (renderLabels applied during hover) and
|
|
1864
|
+
// the hub-bright / non-hub-dim baseline that's the steady
|
|
1865
|
+
// state when nothing is hovered.
|
|
1866
|
+
const hoverOrSearchActive =
|
|
1867
|
+
hoveredNodeId !== null || searchActive || renderMode === "workbench"
|
|
1868
|
+
const dimAlpha = scaleOpacity * 0.15
|
|
1869
|
+
const activeLabels = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label)
|
|
1870
|
+
const hubLabels = new Set(nodeRenderData.filter((n) => n.isHub).map((n) => n.label))
|
|
1871
|
+
|
|
1872
|
+
for (const label of labelsContainer.children) {
|
|
1873
|
+
if (activeLabels.includes(label)) {
|
|
1874
|
+
// Active labels are pinned at full alpha by renderLabels;
|
|
1875
|
+
// don't overwrite them on zoom.
|
|
1876
|
+
continue
|
|
1877
|
+
}
|
|
1878
|
+
if (hoverOrSearchActive) {
|
|
1879
|
+
label.alpha = dimAlpha
|
|
1880
|
+
} else {
|
|
1881
|
+
// brain-extension: hub labels pinned to full alpha at rest
|
|
1882
|
+
// (matches the renderLabels branch); non-hub labels track
|
|
1883
|
+
// the zoom-driven dim baseline.
|
|
1884
|
+
label.alpha = hubLabels.has(label) ? 1 : dimAlpha
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
})
|
|
1888
|
+
|
|
1889
|
+
const zoomSelection = select<HTMLCanvasElement, NodeData>(app.canvas).call(zoomBehavior)
|
|
1890
|
+
|
|
1891
|
+
// brain-extension: fit the cluster to the canvas with 12% padding on
|
|
1892
|
+
// every side. After pre-tick + bbox-recenter the cluster is centered
|
|
1893
|
+
// at sim (0, 0); render translation puts that at canvas center
|
|
1894
|
+
// (width/2, height/2). For dense local graphs (COMPANY_REDACTED Hub: ~12
|
|
1895
|
+
// neighbors radiating from a hub at ~30px linkDistance) the cluster
|
|
1896
|
+
// diameter exceeds the canvas height, so the bottom row gets clipped
|
|
1897
|
+
// even when perfectly centered. Compute an initial zoom transform
|
|
1898
|
+
// that scales the cluster to fit within (1 - 2 * pad) of each axis,
|
|
1899
|
+
// capped at scale=1 (never zoom IN beyond identity for small
|
|
1900
|
+
// clusters that already fit). Calling `zoomBehavior.transform(...)`
|
|
1901
|
+
// rather than mutating stage directly keeps d3-zoom's internal
|
|
1902
|
+
// state in sync, so subsequent user pan/zoom is relative to the
|
|
1903
|
+
// fit position, not identity.
|
|
1904
|
+
if (graphData.nodes.length > 0) {
|
|
1905
|
+
let bMinX = Infinity
|
|
1906
|
+
let bMaxX = -Infinity
|
|
1907
|
+
let bMinY = Infinity
|
|
1908
|
+
let bMaxY = -Infinity
|
|
1909
|
+
for (const n of graphData.nodes) {
|
|
1910
|
+
if (n.x === undefined || n.y === undefined) continue
|
|
1911
|
+
if (n.x < bMinX) bMinX = n.x
|
|
1912
|
+
if (n.x > bMaxX) bMaxX = n.x
|
|
1913
|
+
if (n.y < bMinY) bMinY = n.y
|
|
1914
|
+
if (n.y > bMaxY) bMaxY = n.y
|
|
1915
|
+
}
|
|
1916
|
+
if (Number.isFinite(bMinX) && Number.isFinite(bMinY)) {
|
|
1917
|
+
// bbox half-extent in render coords (cluster is recentered so
|
|
1918
|
+
// bMinX = -bMaxX and bMinY = -bMaxY, but compute via max(|min|, |max|)
|
|
1919
|
+
// for safety against the symmetric-recenter assumption breaking).
|
|
1920
|
+
const halfW = Math.max(Math.abs(bMinX), Math.abs(bMaxX), 1)
|
|
1921
|
+
const halfH = Math.max(Math.abs(bMinY), Math.abs(bMaxY), 1)
|
|
1922
|
+
const padFraction = 0.12
|
|
1923
|
+
const fitScale = Math.min(
|
|
1924
|
+
((1 - 2 * padFraction) * width) / (2 * halfW),
|
|
1925
|
+
((1 - 2 * padFraction) * height) / (2 * halfH),
|
|
1926
|
+
1.0,
|
|
1927
|
+
)
|
|
1928
|
+
// Scale around canvas center so the cluster (which is at canvas
|
|
1929
|
+
// center pre-zoom) stays centered post-zoom.
|
|
1930
|
+
const tx = (width / 2) * (1 - fitScale)
|
|
1931
|
+
const ty = (height / 2) * (1 - fitScale)
|
|
1932
|
+
zoomBehavior.transform(zoomSelection, zoomIdentity.translate(tx, ty).scale(fitScale))
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
let stopAnimation = false
|
|
1938
|
+
function animate(time: number) {
|
|
1939
|
+
if (stopAnimation) return
|
|
1940
|
+
for (const n of nodeRenderData) {
|
|
1941
|
+
const { x, y } = n.simulationData
|
|
1942
|
+
// brain: gate on "actually has a position" (not "position is truthy"):
|
|
1943
|
+
// `!x || !y` is true when EITHER coordinate equals 0, so a node that
|
|
1944
|
+
// settles exactly at (0, 0) — common for sparse graphs whose bounding
|
|
1945
|
+
// box gets recentered to origin, or for a single-node local graph
|
|
1946
|
+
// initialized at the phyllotaxis origin — never has its position
|
|
1947
|
+
// updated and stays at the Pixi default (canvas top-left).
|
|
1948
|
+
if (x === undefined || y === undefined) continue
|
|
1949
|
+
n.gfx.position.set(x + width / 2, y + height / 2)
|
|
1950
|
+
if (n.label) {
|
|
1951
|
+
placeNodeLabel(n.label, n, x + width / 2, y + height / 2)
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
for (const l of linkRenderData) {
|
|
1956
|
+
const linkData = l.simulationData
|
|
1957
|
+
l.gfx.clear()
|
|
1958
|
+
const x1 = linkData.source.x! + width / 2
|
|
1959
|
+
const y1 = linkData.source.y! + height / 2
|
|
1960
|
+
const x2 = linkData.target.x! + width / 2
|
|
1961
|
+
const y2 = linkData.target.y! + height / 2
|
|
1962
|
+
|
|
1963
|
+
// brain: branch on link kind. Derived edges (Phase D fence) get the
|
|
1964
|
+
// configured dashed/translucent style; wiki edges keep upstream's solid
|
|
1965
|
+
// stroke. Until the derived-edge transformer ships every record will
|
|
1966
|
+
// arrive with kind: "wiki" and the derived branch never triggers — that's
|
|
1967
|
+
// expected.
|
|
1968
|
+
if (linkData.kind === "derived" && derivedEdgeStyle) {
|
|
1969
|
+
drawDashedLine(
|
|
1970
|
+
l.gfx,
|
|
1971
|
+
x1,
|
|
1972
|
+
y1,
|
|
1973
|
+
x2,
|
|
1974
|
+
y2,
|
|
1975
|
+
derivedEdgeStyle.dash,
|
|
1976
|
+
derivedEdgeStyle.width,
|
|
1977
|
+
l.color,
|
|
1978
|
+
l.alpha,
|
|
1979
|
+
)
|
|
1980
|
+
} else {
|
|
1981
|
+
l.gfx
|
|
1982
|
+
.moveTo(x1, y1)
|
|
1983
|
+
.lineTo(x2, y2)
|
|
1984
|
+
.stroke({ alpha: l.alpha, width: 1, color: l.color })
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
tweens.forEach((t) => t.update(time))
|
|
1989
|
+
app.renderer.render(stage)
|
|
1990
|
+
requestAnimationFrame(animate)
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
requestAnimationFrame(animate)
|
|
1994
|
+
const cleanup = () => {
|
|
1995
|
+
stopAnimation = true
|
|
1996
|
+
// brain-extension: cancel any pending search-debounce so a fire after
|
|
1997
|
+
// teardown can't reach a destroyed Pixi app or stale render data.
|
|
1998
|
+
if (searchDebounceTimer !== null) {
|
|
1999
|
+
window.clearTimeout(searchDebounceTimer)
|
|
2000
|
+
searchDebounceTimer = null
|
|
2001
|
+
}
|
|
2002
|
+
if (resizeRerenderTimer !== null) {
|
|
2003
|
+
window.clearTimeout(resizeRerenderTimer)
|
|
2004
|
+
resizeRerenderTimer = null
|
|
2005
|
+
}
|
|
2006
|
+
resizeObserver?.disconnect()
|
|
2007
|
+
// brain-extension: tear down the tooltip element so re-renders (theme change,
|
|
2008
|
+
// SPA nav) don't accumulate orphaned tooltips in the DOM.
|
|
2009
|
+
if (tooltip.parentElement) tooltip.parentElement.removeChild(tooltip)
|
|
2010
|
+
// brain-extension: same for the controls row — `removeAllChildren` on
|
|
2011
|
+
// the next render would clear it, but a cleanup-without-rerender (e.g.
|
|
2012
|
+
// global-graph hideOnEscape) needs the explicit removal so the chip
|
|
2013
|
+
// row doesn't linger as a detached child.
|
|
2014
|
+
if (controlsEl && controlsEl.parentElement) {
|
|
2015
|
+
controlsEl.parentElement.removeChild(controlsEl)
|
|
2016
|
+
}
|
|
2017
|
+
if (workbenchRail && workbenchRail.parentElement) {
|
|
2018
|
+
workbenchRail.parentElement.removeChild(workbenchRail)
|
|
2019
|
+
}
|
|
2020
|
+
if (workbenchCanvasPane && workbenchCanvasPane.parentElement) {
|
|
2021
|
+
workbenchCanvasPane.parentElement.removeChild(workbenchCanvasPane)
|
|
2022
|
+
}
|
|
2023
|
+
if (workbenchInspector && workbenchInspector.parentElement) {
|
|
2024
|
+
workbenchInspector.parentElement.removeChild(workbenchInspector)
|
|
2025
|
+
}
|
|
2026
|
+
graph.classList.remove("brain-graph-workbench-shell")
|
|
2027
|
+
app.destroy()
|
|
2028
|
+
}
|
|
2029
|
+
// brain-extension: write the live cleanup back into the shared ref so a
|
|
2030
|
+
// chip-driven rerender (which mutates `cleanupRef.current` mid-render)
|
|
2031
|
+
// and the cleanup arrays in the nav handler both reach the latest
|
|
2032
|
+
// teardown. The nav handler's cleanup-array entry is `() => ref.current()`,
|
|
2033
|
+
// so this assignment is what makes "dispose this graph" do the right
|
|
2034
|
+
// thing after any number of rerenders.
|
|
2035
|
+
cleanupRef.current = cleanup
|
|
2036
|
+
return cleanup
|
|
2037
|
+
} finally {
|
|
2038
|
+
// brain: closing brace for the renderGraph-wide try/finally that
|
|
2039
|
+
// guards `chipRerenderBusy`. Always clears the flag regardless of
|
|
2040
|
+
// throw/return path so a render error doesn't leave the graph
|
|
2041
|
+
// permanently locked out of chip toggles.
|
|
2042
|
+
chipRerenderBusy = false
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
let localGraphCleanups: (() => void)[] = []
|
|
2047
|
+
let globalGraphCleanups: (() => void)[] = []
|
|
2048
|
+
// brain-extension: separate cleanup bucket for the fullscreen-local-graph
|
|
2049
|
+
// modal so opening the global modal afterwards doesn't tear down its Pixi
|
|
2050
|
+
// app, and vice versa. Both modals share the same Esc handler shape, so
|
|
2051
|
+
// each one's hide function only clears its own bucket.
|
|
2052
|
+
let localFullscreenCleanups: (() => void)[] = []
|
|
2053
|
+
let workbenchGraphCleanups: (() => void)[] = []
|
|
2054
|
+
|
|
2055
|
+
function cleanupLocalGraphs() {
|
|
2056
|
+
for (const cleanup of localGraphCleanups) {
|
|
2057
|
+
cleanup()
|
|
2058
|
+
}
|
|
2059
|
+
localGraphCleanups = []
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function cleanupGlobalGraphs() {
|
|
2063
|
+
for (const cleanup of globalGraphCleanups) {
|
|
2064
|
+
cleanup()
|
|
2065
|
+
}
|
|
2066
|
+
globalGraphCleanups = []
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
function cleanupLocalFullscreenGraphs() {
|
|
2070
|
+
for (const cleanup of localFullscreenCleanups) {
|
|
2071
|
+
cleanup()
|
|
2072
|
+
}
|
|
2073
|
+
localFullscreenCleanups = []
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function cleanupWorkbenchGraphs() {
|
|
2077
|
+
for (const cleanup of workbenchGraphCleanups) {
|
|
2078
|
+
cleanup()
|
|
2079
|
+
}
|
|
2080
|
+
workbenchGraphCleanups = []
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
|
2084
|
+
const slug = e.detail.url
|
|
2085
|
+
addToVisited(simplifySlug(slug))
|
|
2086
|
+
// brain-extension: search query is per-page — clear it on every SPA
|
|
2087
|
+
// navigation so a query typed on page A doesn't carry over to page B.
|
|
2088
|
+
// Chip filters intentionally persist across nav (they're a deliberate
|
|
2089
|
+
// user-applied lens), so they live on at module scope without a reset.
|
|
2090
|
+
currentSearchQuery = ""
|
|
2091
|
+
|
|
2092
|
+
async function renderLocalGraph() {
|
|
2093
|
+
cleanupLocalGraphs()
|
|
2094
|
+
const localGraphContainers = document.getElementsByClassName("graph-container")
|
|
2095
|
+
for (const container of localGraphContainers) {
|
|
2096
|
+
// brain-extension: each container gets its own cleanup ref. The array
|
|
2097
|
+
// entry resolves through the ref, so chip-driven rerenders that mutate
|
|
2098
|
+
// `ref.current` still get torn down correctly on next nav.
|
|
2099
|
+
const ref: CleanupRef = { current: () => {} }
|
|
2100
|
+
await renderGraph(container as HTMLElement, slug, ref)
|
|
2101
|
+
localGraphCleanups.push(() => ref.current())
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
await renderLocalGraph()
|
|
2106
|
+
const handleThemeChange = () => {
|
|
2107
|
+
void renderLocalGraph()
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
document.addEventListener("themechange", handleThemeChange)
|
|
2111
|
+
window.addCleanup(() => {
|
|
2112
|
+
document.removeEventListener("themechange", handleThemeChange)
|
|
2113
|
+
})
|
|
2114
|
+
|
|
2115
|
+
const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[]
|
|
2116
|
+
// brain-extension: `stockMode` flag threads through to renderGraph so the
|
|
2117
|
+
// dot-grid `brain-stock-graph-icon` button can open the same global-graph
|
|
2118
|
+
// modal but with every renderer extension disabled. The default-false branch
|
|
2119
|
+
// (called from the brain-customized globe icon and the Cmd/Ctrl-G shortcut)
|
|
2120
|
+
// is functionally identical to the pre-stockMode behavior.
|
|
2121
|
+
async function renderGlobalGraph(stockMode = false) {
|
|
2122
|
+
const slug = getFullSlug(window)
|
|
2123
|
+
for (const container of containers) {
|
|
2124
|
+
container.classList.add("active")
|
|
2125
|
+
const sidebar = container.closest(".sidebar") as HTMLElement
|
|
2126
|
+
if (sidebar) {
|
|
2127
|
+
sidebar.style.zIndex = "1"
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const graphContainer = container.querySelector(".global-graph-container") as HTMLElement
|
|
2131
|
+
registerEscapeHandler(container, hideGlobalGraph)
|
|
2132
|
+
if (graphContainer) {
|
|
2133
|
+
// brain-extension: same cleanup-ref pattern as the local graph so
|
|
2134
|
+
// chip-driven rerenders inside the global modal stay disposable.
|
|
2135
|
+
const ref: CleanupRef = { current: () => {} }
|
|
2136
|
+
await renderGraph(graphContainer, slug, ref, stockMode)
|
|
2137
|
+
globalGraphCleanups.push(() => ref.current())
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function hideGlobalGraph() {
|
|
2143
|
+
cleanupGlobalGraphs()
|
|
2144
|
+
for (const container of containers) {
|
|
2145
|
+
container.classList.remove("active")
|
|
2146
|
+
const sidebar = container.closest(".sidebar") as HTMLElement
|
|
2147
|
+
if (sidebar) {
|
|
2148
|
+
sidebar.style.zIndex = ""
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// brain-extension: fullscreen-LOCAL-graph modal. Uses its own outer
|
|
2154
|
+
// container (`.local-graph-outer`) so it can co-exist with the global
|
|
2155
|
+
// modal's open/close state without sharing cleanup buckets. The inner
|
|
2156
|
+
// `.local-graph-container` carries a data-cfg that's a clone of the
|
|
2157
|
+
// sidebar's localGraph config + `filterChips: ["tier", "source"]`
|
|
2158
|
+
// forced on, so depth=1 (current page + neighbours) survives but the
|
|
2159
|
+
// user gets the same search + chip rail as the global modal.
|
|
2160
|
+
const localFullscreenContainers = [
|
|
2161
|
+
...document.getElementsByClassName("local-graph-outer"),
|
|
2162
|
+
] as HTMLElement[]
|
|
2163
|
+
const workbenchContainers = [
|
|
2164
|
+
...document.getElementsByClassName("brain-graph-workbench-outer"),
|
|
2165
|
+
] as HTMLElement[]
|
|
2166
|
+
|
|
2167
|
+
async function renderLocalFullscreenGraph() {
|
|
2168
|
+
const slug = getFullSlug(window)
|
|
2169
|
+
for (const container of localFullscreenContainers) {
|
|
2170
|
+
container.classList.add("active")
|
|
2171
|
+
const sidebar = container.closest(".sidebar") as HTMLElement
|
|
2172
|
+
if (sidebar) {
|
|
2173
|
+
sidebar.style.zIndex = "1"
|
|
2174
|
+
}
|
|
2175
|
+
const graphContainer = container.querySelector(
|
|
2176
|
+
".local-graph-container",
|
|
2177
|
+
) as HTMLElement
|
|
2178
|
+
registerEscapeHandler(container, hideLocalFullscreenGraph)
|
|
2179
|
+
if (graphContainer) {
|
|
2180
|
+
const ref: CleanupRef = { current: () => {} }
|
|
2181
|
+
await renderGraph(graphContainer, slug, ref)
|
|
2182
|
+
localFullscreenCleanups.push(() => ref.current())
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
function hideLocalFullscreenGraph() {
|
|
2188
|
+
cleanupLocalFullscreenGraphs()
|
|
2189
|
+
for (const container of localFullscreenContainers) {
|
|
2190
|
+
container.classList.remove("active")
|
|
2191
|
+
const sidebar = container.closest(".sidebar") as HTMLElement
|
|
2192
|
+
if (sidebar) {
|
|
2193
|
+
sidebar.style.zIndex = ""
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
async function renderWorkbenchGraph() {
|
|
2199
|
+
const slug = getFullSlug(window)
|
|
2200
|
+
for (const container of workbenchContainers) {
|
|
2201
|
+
container.classList.add("active")
|
|
2202
|
+
const sidebar = container.closest(".sidebar") as HTMLElement
|
|
2203
|
+
if (sidebar) {
|
|
2204
|
+
sidebar.style.zIndex = "1"
|
|
2205
|
+
}
|
|
2206
|
+
const graphContainer = container.querySelector(
|
|
2207
|
+
".brain-graph-workbench-container",
|
|
2208
|
+
) as HTMLElement
|
|
2209
|
+
registerEscapeHandler(container, hideWorkbenchGraph)
|
|
2210
|
+
if (graphContainer) {
|
|
2211
|
+
const ref: CleanupRef = { current: () => {} }
|
|
2212
|
+
await renderGraph(graphContainer, slug, ref)
|
|
2213
|
+
workbenchGraphCleanups.push(() => ref.current())
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function hideWorkbenchGraph() {
|
|
2219
|
+
cleanupWorkbenchGraphs()
|
|
2220
|
+
for (const container of workbenchContainers) {
|
|
2221
|
+
container.classList.remove("active")
|
|
2222
|
+
const sidebar = container.closest(".sidebar") as HTMLElement
|
|
2223
|
+
if (sidebar) {
|
|
2224
|
+
sidebar.style.zIndex = ""
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
|
2230
|
+
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
|
2231
|
+
e.preventDefault()
|
|
2232
|
+
// brain: Cmd/Ctrl+G drives the fullscreen-LOCAL-graph modal (current
|
|
2233
|
+
// page + its neighbourhood at depth=1, with search + filter chips).
|
|
2234
|
+
// The global graph still has its own globe/dot-grid icons in the
|
|
2235
|
+
// sidebar; the keyboard shortcut intentionally addresses the more
|
|
2236
|
+
// common "show me what THIS page is connected to" intent.
|
|
2237
|
+
const anyLocalFullscreenOpen = localFullscreenContainers.some((c) =>
|
|
2238
|
+
c.classList.contains("active"),
|
|
2239
|
+
)
|
|
2240
|
+
anyLocalFullscreenOpen ? hideLocalFullscreenGraph() : renderLocalFullscreenGraph()
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const containerIcons = document.getElementsByClassName("global-graph-icon")
|
|
2245
|
+
// brain: wrap the renderGlobalGraph call so the click event isn't passed
|
|
2246
|
+
// through as the stockMode argument (event objects are truthy → would
|
|
2247
|
+
// accidentally enable stockMode for the brain-customized globe icon too).
|
|
2248
|
+
const handleGlobalGraphClick = () => {
|
|
2249
|
+
void renderGlobalGraph(false)
|
|
2250
|
+
}
|
|
2251
|
+
Array.from(containerIcons).forEach((icon) => {
|
|
2252
|
+
icon.addEventListener("click", handleGlobalGraphClick)
|
|
2253
|
+
window.addCleanup(() => icon.removeEventListener("click", handleGlobalGraphClick))
|
|
2254
|
+
})
|
|
2255
|
+
|
|
2256
|
+
// brain-extension: the dot-grid affordance opens the same global-graph modal
|
|
2257
|
+
// but with stockMode=true — every renderer extension is suppressed so the
|
|
2258
|
+
// user sees the corpus with stock-Quartz visual semantics.
|
|
2259
|
+
const stockGraphIcons = document.getElementsByClassName("brain-stock-graph-icon")
|
|
2260
|
+
const handleStockGraphClick = () => {
|
|
2261
|
+
void renderGlobalGraph(true)
|
|
2262
|
+
}
|
|
2263
|
+
Array.from(stockGraphIcons).forEach((icon) => {
|
|
2264
|
+
icon.addEventListener("click", handleStockGraphClick)
|
|
2265
|
+
window.addCleanup(() => icon.removeEventListener("click", handleStockGraphClick))
|
|
2266
|
+
})
|
|
2267
|
+
|
|
2268
|
+
// brain-extension: the maximize affordance opens the fullscreen-local-graph
|
|
2269
|
+
// modal — same scope as the sidebar's inline local graph (depth=1) but
|
|
2270
|
+
// rendered into the centered modal panel with search + tier/source chips
|
|
2271
|
+
// on. Same handler shape as the other two icons.
|
|
2272
|
+
const localFullscreenIcons = document.getElementsByClassName(
|
|
2273
|
+
"local-graph-fullscreen-icon",
|
|
2274
|
+
)
|
|
2275
|
+
const handleLocalFullscreenClick = () => {
|
|
2276
|
+
void renderLocalFullscreenGraph()
|
|
2277
|
+
}
|
|
2278
|
+
Array.from(localFullscreenIcons).forEach((icon) => {
|
|
2279
|
+
icon.addEventListener("click", handleLocalFullscreenClick)
|
|
2280
|
+
window.addCleanup(() =>
|
|
2281
|
+
icon.removeEventListener("click", handleLocalFullscreenClick),
|
|
2282
|
+
)
|
|
2283
|
+
})
|
|
2284
|
+
|
|
2285
|
+
const workbenchIcons = document.getElementsByClassName("brain-graph-workbench-icon")
|
|
2286
|
+
const handleWorkbenchClick = () => {
|
|
2287
|
+
void renderWorkbenchGraph()
|
|
2288
|
+
}
|
|
2289
|
+
Array.from(workbenchIcons).forEach((icon) => {
|
|
2290
|
+
icon.addEventListener("click", handleWorkbenchClick)
|
|
2291
|
+
window.addCleanup(() => icon.removeEventListener("click", handleWorkbenchClick))
|
|
2292
|
+
})
|
|
2293
|
+
|
|
2294
|
+
document.addEventListener("keydown", shortcutHandler)
|
|
2295
|
+
window.addCleanup(() => {
|
|
2296
|
+
document.removeEventListener("keydown", shortcutHandler)
|
|
2297
|
+
cleanupLocalGraphs()
|
|
2298
|
+
cleanupGlobalGraphs()
|
|
2299
|
+
cleanupLocalFullscreenGraphs()
|
|
2300
|
+
cleanupWorkbenchGraphs()
|
|
2301
|
+
})
|
|
2302
|
+
})
|