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.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. 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, "&amp;")
276
+ .replace(/</g, "&lt;")
277
+ .replace(/>/g, "&gt;")
278
+ .replace(/"/g, "&quot;")
279
+ .replace(/'/g, "&#039;")
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
+ })