@gamaze/hicortex 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +58 -1
  2. package/THIRD_PARTY_NOTICES.md +108 -0
  3. package/assets/vendor/3d-force-graph.min.js +5 -0
  4. package/assets/vendor/force-graph.min.js +5 -0
  5. package/assets/vendor/three.core.min.js +6 -0
  6. package/assets/vendor/three.module.min.js +6 -0
  7. package/assets/viz.html +1126 -0
  8. package/dist/classify-domains.d.ts +98 -0
  9. package/dist/classify-domains.js +340 -0
  10. package/dist/cli.d.ts +1 -0
  11. package/dist/cli.js +63 -0
  12. package/dist/consolidate.d.ts +139 -2
  13. package/dist/consolidate.js +302 -87
  14. package/dist/db.js +70 -0
  15. package/dist/domain-classify.d.ts +164 -0
  16. package/dist/domain-classify.js +300 -0
  17. package/dist/extensions.d.ts +12 -0
  18. package/dist/graph.d.ts +56 -0
  19. package/dist/graph.js +145 -0
  20. package/dist/index.js +1 -1
  21. package/dist/init.d.ts +25 -0
  22. package/dist/init.js +54 -0
  23. package/dist/lesson-selection.js +12 -5
  24. package/dist/lessons-context.js +2 -1
  25. package/dist/llm.d.ts +67 -0
  26. package/dist/llm.js +122 -0
  27. package/dist/mcp-server.js +82 -27
  28. package/dist/nightly-status.js +9 -28
  29. package/dist/nightly.js +42 -32
  30. package/dist/nofit.d.ts +111 -0
  31. package/dist/nofit.js +176 -0
  32. package/dist/prompts.d.ts +0 -5
  33. package/dist/prompts.js +5 -29
  34. package/dist/relink.d.ts +100 -0
  35. package/dist/relink.js +277 -0
  36. package/dist/retrieval.d.ts +16 -1
  37. package/dist/retrieval.js +34 -2
  38. package/dist/schema-prototypes.d.ts +149 -0
  39. package/dist/schema-prototypes.js +329 -0
  40. package/dist/state.d.ts +32 -0
  41. package/dist/state.js +29 -0
  42. package/dist/status.js +12 -19
  43. package/dist/storage.d.ts +44 -1
  44. package/dist/storage.js +70 -1
  45. package/dist/types.d.ts +90 -0
  46. package/dist/viz.d.ts +69 -0
  47. package/dist/viz.js +180 -0
  48. package/domains.example.json +36 -0
  49. package/package.json +6 -3
@@ -0,0 +1,1126 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Hicortex — knowledge graph</title>
7
+ <!--
8
+ Hicortex knowledge-graph visualization (#124, redesigned in #139).
9
+ 3D force-directed view by default (3d-force-graph + three.js), 2D toggle
10
+ (force-graph). All renderer bundles are vendored and served same-origin by
11
+ this daemon from /viz/vendor/ — zero external requests, works offline.
12
+ Versions + licenses: THIRD_PARTY_NOTICES.md in the package.
13
+ Data source: GET /graph?op=export on this server (same origin, bearer-only).
14
+
15
+ Script loading order (guaranteed by the HTML spec — module scripts and
16
+ deferred classic scripts execute in document order after parsing, before
17
+ DOMContentLoaded):
18
+ 1. inline module: imports vendored three, publishes window.THREE
19
+ 2. 3d-force-graph.min.js (UMD, deferred) — picks up window.THREE so the
20
+ page's sprite code and the renderer share ONE three instance
21
+ 3. force-graph.min.js (UMD, deferred)
22
+ 4. DOMContentLoaded → boot() from the main inline script below
23
+ -->
24
+ <script type="module">
25
+ import * as THREE from "/viz/vendor/three.module.min.js";
26
+ window.THREE = THREE;
27
+ </script>
28
+ <script src="/viz/vendor/3d-force-graph.min.js" defer></script>
29
+ <script src="/viz/vendor/force-graph.min.js" defer></script>
30
+ <style>
31
+ :root {
32
+ --bg: #0f1117;
33
+ --panel: #161a23;
34
+ --panel-border: #262c3a;
35
+ --text: #d5dae4;
36
+ --text-dim: #8b93a5;
37
+ --accent: #5b9dd9;
38
+ --danger: #e05555;
39
+ }
40
+ * { box-sizing: border-box; margin: 0; padding: 0; }
41
+ html, body { height: 100%; overflow: hidden; }
42
+ body {
43
+ background: var(--bg);
44
+ color: var(--text);
45
+ font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
46
+ }
47
+
48
+ /* ---- graph containers (full-viewport, panels float above) ---- */
49
+ #graph-3d, #graph-2d { position: fixed; inset: 0; }
50
+ #graph-2d { display: none; }
51
+ body.mode-2d #graph-3d { display: none; }
52
+ body.mode-2d #graph-2d { display: block; }
53
+
54
+ /* ---- top bar ---- */
55
+ #topbar {
56
+ position: fixed; top: 0; left: 0; right: 0;
57
+ display: flex; align-items: center; gap: 10px;
58
+ padding: 8px 12px;
59
+ background: rgba(22, 26, 35, 0.94);
60
+ border-bottom: 1px solid var(--panel-border);
61
+ z-index: 10;
62
+ }
63
+ #topbar h1 { font-size: 14px; font-weight: 600; white-space: nowrap; }
64
+ #meta { color: var(--text-dim); margin-left: auto; white-space: nowrap; }
65
+
66
+ /* ---- left control panel ---- */
67
+ #panel {
68
+ position: fixed; top: 46px; left: 12px; width: 236px;
69
+ background: rgba(22, 26, 35, 0.94);
70
+ border: 1px solid var(--panel-border); border-radius: 8px;
71
+ padding: 12px; z-index: 10;
72
+ max-height: calc(100vh - 70px); overflow-y: auto;
73
+ }
74
+ #panel h2 {
75
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
76
+ color: var(--text-dim); margin: 14px 0 6px;
77
+ }
78
+ #panel h2:first-child { margin-top: 0; }
79
+ #panel .row { display: flex; align-items: center; gap: 6px; margin: 5px 0; color: var(--text-dim); }
80
+ #panel .row > label:first-child { flex: none; width: 78px; }
81
+ #panel select, #panel input[type="number"], #panel input[type="text"] {
82
+ flex: 1; min-width: 0;
83
+ background: var(--bg); color: var(--text);
84
+ border: 1px solid var(--panel-border); border-radius: 4px;
85
+ padding: 3px 6px; font: inherit;
86
+ }
87
+ #panel input[type="range"] { flex: 1; min-width: 0; accent-color: var(--accent); }
88
+ #panel .val { flex: none; width: 34px; text-align: right; font-variant-numeric: tabular-nums; }
89
+ #panel button {
90
+ background: var(--accent); color: #0b0d12; font: inherit; font-weight: 600;
91
+ border: none; border-radius: 4px; padding: 5px 12px; cursor: pointer;
92
+ }
93
+ #panel button:hover { filter: brightness(1.1); }
94
+ #panel button.secondary { background: var(--panel-border); color: var(--text); }
95
+ #apply { width: 100%; margin-top: 4px; }
96
+
97
+ /* ---- iOS-style 2D/3D switch ---- */
98
+ .switch { position: relative; display: inline-block; width: 40px; height: 22px; flex: none; }
99
+ .switch input { opacity: 0; width: 0; height: 0; position: absolute; }
100
+ .switch .knob {
101
+ position: absolute; inset: 0; cursor: pointer;
102
+ background: var(--panel-border); border-radius: 22px; transition: background 0.15s;
103
+ }
104
+ .switch .knob::before {
105
+ content: ""; position: absolute; width: 18px; height: 18px; left: 2px; top: 2px;
106
+ background: var(--text); border-radius: 50%; transition: transform 0.15s;
107
+ }
108
+ .switch input:checked + .knob { background: var(--accent); }
109
+ .switch input:checked + .knob::before { transform: translateX(18px); }
110
+ .switch input:focus-visible + .knob { outline: 2px solid var(--accent); outline-offset: 2px; }
111
+ .mode-label { color: var(--text-dim); }
112
+ .mode-label.active { color: var(--text); font-weight: 600; }
113
+
114
+ /* ---- per-control tooltips (data-tip, CSS-only, 0.4s hover delay) ---- */
115
+ [data-tip] { position: relative; }
116
+ [data-tip]::after {
117
+ content: attr(data-tip);
118
+ position: absolute; left: 0; top: calc(100% + 6px); z-index: 30; width: 220px;
119
+ background: rgba(15, 17, 23, 0.97); border: 1px solid var(--panel-border);
120
+ border-radius: 5px; padding: 6px 8px;
121
+ color: var(--text); font-size: 11.5px; line-height: 1.4; font-weight: 400;
122
+ white-space: normal; pointer-events: none;
123
+ opacity: 0; visibility: hidden;
124
+ transition: opacity 0.12s ease 0s, visibility 0s linear 0.5s;
125
+ }
126
+ [data-tip]:hover::after {
127
+ opacity: 1; visibility: visible;
128
+ transition-delay: 0.4s, 0.4s;
129
+ }
130
+ /* Rows near the panel bottom open their tooltip upward — #panel scrolls
131
+ (overflow-y: auto), which would clip a downward tooltip at the edge. */
132
+ .tip-up[data-tip]::after { top: auto; bottom: calc(100% + 6px); }
133
+
134
+ #panel .hint {
135
+ margin-top: 12px; border-top: 1px solid var(--panel-border); padding-top: 8px;
136
+ color: var(--text-dim); font-size: 11.5px; line-height: 1.4;
137
+ }
138
+
139
+ /* ---- zoom controls (bottom-right) ---- */
140
+ #zoomctl {
141
+ position: fixed; bottom: 12px; right: 12px; z-index: 9;
142
+ display: flex; flex-direction: column; gap: 6px;
143
+ }
144
+ #zoomctl button {
145
+ width: 34px; height: 34px; font-size: 17px; line-height: 1;
146
+ background: rgba(22, 26, 35, 0.94); color: var(--text);
147
+ border: 1px solid var(--panel-border); border-radius: 6px; cursor: pointer;
148
+ }
149
+ #zoomctl button:hover { border-color: var(--accent); color: var(--accent); }
150
+
151
+ /* ---- legend (bottom-right, above detail toggle area) ---- */
152
+ #legend {
153
+ position: fixed; bottom: 12px; left: 12px;
154
+ background: rgba(22, 26, 35, 0.94);
155
+ border: 1px solid var(--panel-border); border-radius: 6px;
156
+ padding: 10px 12px; max-width: 236px; z-index: 9;
157
+ max-height: 34vh; overflow-y: auto;
158
+ }
159
+ #legend h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin: 6px 0 4px; }
160
+ #legend h2:first-child { margin-top: 0; }
161
+ .legend-row { display: flex; align-items: center; gap: 7px; padding: 1.5px 0; color: var(--text-dim); }
162
+ .legend-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
163
+ .legend-line { width: 22px; height: 0; flex: none; border-top-width: 2px; }
164
+ .legend-halo { box-shadow: 0 0 0 2.5px rgba(255,255,255,0.55); }
165
+
166
+ /* ---- detail panel ---- */
167
+ #detail {
168
+ position: fixed; top: 0; right: 0; bottom: 0; width: 380px; max-width: 90vw;
169
+ background: var(--panel); border-left: 1px solid var(--panel-border);
170
+ z-index: 15; display: none; flex-direction: column;
171
+ }
172
+ #detail.open { display: flex; }
173
+ #detail header {
174
+ display: flex; align-items: flex-start; gap: 8px;
175
+ padding: 14px 14px 10px; border-bottom: 1px solid var(--panel-border);
176
+ }
177
+ #detail header h2 { font-size: 14px; flex: 1; word-break: break-word; }
178
+ #detail-close {
179
+ background: none; border: none; color: var(--text-dim);
180
+ font-size: 18px; cursor: pointer; line-height: 1; padding: 0 2px;
181
+ }
182
+ #detail-close:hover { color: var(--text); }
183
+ #detail-meta { padding: 10px 14px; border-bottom: 1px solid var(--panel-border); }
184
+ #detail-meta div { display: flex; gap: 8px; padding: 1.5px 0; }
185
+ #detail-meta dt { color: var(--text-dim); width: 90px; flex: none; }
186
+ #detail-meta dd { word-break: break-word; }
187
+ #detail-content {
188
+ padding: 12px 14px; overflow-y: auto; flex: 1;
189
+ white-space: pre-wrap; word-break: break-word; font-size: 12.5px; color: var(--text);
190
+ }
191
+
192
+ /* ---- overlays: token prompt / status ---- */
193
+ #overlay {
194
+ position: fixed; inset: 0; display: none; z-index: 30;
195
+ align-items: center; justify-content: center;
196
+ background: rgba(15, 17, 23, 0.85);
197
+ }
198
+ #overlay.open { display: flex; }
199
+ #overlay .box {
200
+ background: var(--panel); border: 1px solid var(--panel-border);
201
+ border-radius: 8px; padding: 22px; max-width: 420px; text-align: center;
202
+ }
203
+ #overlay .box h2 { font-size: 15px; margin-bottom: 8px; }
204
+ #overlay .box p { color: var(--text-dim); margin-bottom: 12px; }
205
+ #overlay .box code { background: var(--bg); padding: 1px 5px; border-radius: 3px; }
206
+ #overlay input {
207
+ width: 100%; background: var(--bg); color: var(--text);
208
+ border: 1px solid var(--panel-border); border-radius: 4px;
209
+ padding: 6px 8px; font: inherit; margin-bottom: 10px;
210
+ }
211
+ #overlay button {
212
+ background: var(--accent); color: #0b0d12; font: inherit; font-weight: 600;
213
+ border: none; border-radius: 4px; padding: 6px 16px; cursor: pointer;
214
+ }
215
+ #overlay .err { color: var(--danger); margin-top: 8px; display: none; }
216
+
217
+ #status {
218
+ position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%);
219
+ color: var(--text-dim); z-index: 5; display: none; text-align: center;
220
+ max-width: 420px;
221
+ }
222
+
223
+ /* graph tooltips (both renderers use .graph-tooltip / .scene-tooltip) */
224
+ .graph-tooltip, .scene-tooltip {
225
+ font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
226
+ background: rgba(15, 17, 23, 0.96) !important;
227
+ color: #d5dae4 !important;
228
+ border: 1px solid #262c3a; border-radius: 5px;
229
+ padding: 6px 8px !important; max-width: 340px;
230
+ }
231
+ </style>
232
+ </head>
233
+ <body>
234
+ <div id="graph-3d"></div>
235
+ <div id="graph-2d"></div>
236
+
237
+ <div id="topbar">
238
+ <h1>Hicortex — knowledge graph</h1>
239
+ <span id="meta"></span>
240
+ </div>
241
+
242
+ <div id="panel">
243
+ <h2>Filters</h2>
244
+ <div class="row" data-tip="Show only memories from one knowledge domain. Node colors match domains (see legend)."><label for="f-domain">Domain</label>
245
+ <select id="f-domain"><option value="">all</option></select>
246
+ </div>
247
+ <div class="row" data-tip="Show only one memory type (episode, lesson, fact…)."><label for="f-type">Type</label>
248
+ <select id="f-type"><option value="">all</option></select>
249
+ </div>
250
+ <div class="row" data-tip="Hide memories weaker than this. Strength decays over time and grows with use and links — node size shows it."><label for="f-strength">Min strength</label>
251
+ <input type="range" id="f-strength" min="0" max="1" step="0.05" value="0">
252
+ <span class="val" id="f-strength-val">0.00</span>
253
+ </div>
254
+ <div class="row" data-tip="How many memories to load, strongest first (server cap: 10000)."><label for="f-limit">Node limit</label>
255
+ <input type="number" id="f-limit" min="1" max="10000" value="5000">
256
+ </div>
257
+ <button id="apply" data-tip="Reload the graph from the server with these filters.">Apply</button>
258
+
259
+ <h2>View</h2>
260
+ <div class="row" data-tip="Switch between 3D and 2D rendering of the same graph. Drag to rotate (3D) or pan (2D), scroll to zoom.">
261
+ <label for="mode-switch">View</label>
262
+ <span class="mode-label" id="mode-label-2d">2D</span>
263
+ <label class="switch"><input type="checkbox" id="mode-switch" checked><span class="knob"></span></label>
264
+ <span class="mode-label active" id="mode-label-3d">3D</span>
265
+ </div>
266
+ <div class="row" data-tip="Generic links (similarity, extends, relates to) weaker than this are hidden to reduce clutter. Specific relationships (updates, supersedes, contradicts…) are always shown."><label for="v-simcut">Weak links</label>
267
+ <input type="range" id="v-simcut" min="0" max="1" step="0.05" value="0.55">
268
+ <span class="val" id="v-simcut-val">0.55</span>
269
+ </div>
270
+ <div class="row tip-up" data-tip="Share of the strongest memories that keep permanent labels. Hubs are always labeled; the rest appear as you zoom in."><label for="v-labels">Label density</label>
271
+ <input type="range" id="v-labels" min="0" max="1" step="0.05" value="0.15">
272
+ <span class="val" id="v-labels-val">15%</span>
273
+ </div>
274
+
275
+ <h2>Search</h2>
276
+ <div class="row tip-up" data-tip="Highlight memories matching this text; everything else dims. Clear to reset.">
277
+ <input type="text" id="search" placeholder="highlight matching nodes…">
278
+ </div>
279
+
280
+ <div class="hint">Click a node to read the memory. White halo = hub.</div>
281
+ </div>
282
+
283
+ <div id="zoomctl">
284
+ <button id="zoom-in" title="Zoom in (or scroll / pinch on the graph)">+</button>
285
+ <button id="zoom-out" title="Zoom out">&minus;</button>
286
+ <button id="zoom-fit" title="Fit the whole graph in view">&#x26F6;</button>
287
+ </div>
288
+
289
+ <div id="legend">
290
+ <h2>Domains</h2>
291
+ <div id="legend-domains"></div>
292
+ <h2>Node</h2>
293
+ <div class="legend-row"><span class="legend-dot" style="background:#8b93a5; width:6px; height:6px;"></span> weak memory</div>
294
+ <div class="legend-row"><span class="legend-dot" style="background:#8b93a5; width:13px; height:13px;"></span> strong memory</div>
295
+ <div class="legend-row"><span class="legend-dot legend-halo" style="background:#8b93a5;"></span> hub (highly linked)</div>
296
+ <h2>Links</h2>
297
+ <div id="legend-edges"></div>
298
+ </div>
299
+
300
+ <aside id="detail">
301
+ <header>
302
+ <h2 id="detail-title"></h2>
303
+ <button id="detail-close" title="Close (Esc)">&times;</button>
304
+ </header>
305
+ <dl id="detail-meta"></dl>
306
+ <div id="detail-content"></div>
307
+ </aside>
308
+
309
+ <div id="overlay">
310
+ <div class="box">
311
+ <h2>Authentication required</h2>
312
+ <p>This server requires a token for remote access.
313
+ Run <code>hicortex status</code> on the server to get the token.</p>
314
+ <input type="password" id="token-input" placeholder="hctx-…" autocomplete="off">
315
+ <button id="token-submit">Connect</button>
316
+ <div class="err" id="token-err">Still unauthorized — check the token.</div>
317
+ </div>
318
+ </div>
319
+
320
+ <div id="status"></div>
321
+
322
+ <script>
323
+ "use strict";
324
+ (function () {
325
+ // =========================================================================
326
+ // Auth token handling — unchanged from #124: URL ?token= handoff (stripped
327
+ // from history immediately), localStorage persistence, in-page 401 prompt.
328
+ // The token is only ever sent as an Authorization header on /graph fetches.
329
+ // =========================================================================
330
+ var token = null;
331
+ (function initToken() {
332
+ var params = new URLSearchParams(window.location.search);
333
+ var fromUrl = params.get("token");
334
+ if (fromUrl) {
335
+ token = fromUrl;
336
+ try { localStorage.setItem("hicortexToken", fromUrl); } catch (e) { /* private mode */ }
337
+ // Strip the token from the URL immediately — no token in history/bookmarks.
338
+ params.delete("token");
339
+ var qs = params.toString();
340
+ history.replaceState(null, "", window.location.pathname + (qs ? "?" + qs : ""));
341
+ } else {
342
+ try { token = localStorage.getItem("hicortexToken"); } catch (e) { token = null; }
343
+ }
344
+ })();
345
+
346
+ // =========================================================================
347
+ // DOM references
348
+ // =========================================================================
349
+ var el3d = document.getElementById("graph-3d");
350
+ var el2d = document.getElementById("graph-2d");
351
+ var detail = document.getElementById("detail");
352
+ var detailTitle = document.getElementById("detail-title");
353
+ var detailMeta = document.getElementById("detail-meta");
354
+ var detailContent = document.getElementById("detail-content");
355
+ var overlay = document.getElementById("overlay");
356
+ var statusEl = document.getElementById("status");
357
+ var metaEl = document.getElementById("meta");
358
+ var selDomain = document.getElementById("f-domain");
359
+ var selType = document.getElementById("f-type");
360
+ var rngStrength = document.getElementById("f-strength");
361
+ var rngStrengthVal = document.getElementById("f-strength-val");
362
+ var inpLimit = document.getElementById("f-limit");
363
+ var rngSimCut = document.getElementById("v-simcut");
364
+ var rngSimCutVal = document.getElementById("v-simcut-val");
365
+ var rngLabels = document.getElementById("v-labels");
366
+ var rngLabelsVal = document.getElementById("v-labels-val");
367
+ var inpSearch = document.getElementById("search");
368
+ var modeSwitch = document.getElementById("mode-switch");
369
+ var modeLabel2d = document.getElementById("mode-label-2d");
370
+ var modeLabel3d = document.getElementById("mode-label-3d");
371
+
372
+ // Reflect the current mode in the switch + side labels (checked = 3D).
373
+ function syncModeControl() {
374
+ modeSwitch.checked = mode === "3d";
375
+ modeLabel3d.classList.toggle("active", mode === "3d");
376
+ modeLabel2d.classList.toggle("active", mode === "2d");
377
+ }
378
+
379
+ function showStatus(msg) { statusEl.textContent = msg; statusEl.style.display = "block"; }
380
+ function hideStatus() { statusEl.style.display = "none"; }
381
+
382
+ // The built-in hover tooltips (nodeLabel/linkLabel) render HTML — escape
383
+ // every piece of memory-derived text that goes into them. Everything else
384
+ // in the page uses textContent only (pinned security property).
385
+ function esc(s) {
386
+ return String(s == null ? "" : s)
387
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
388
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
389
+ }
390
+
391
+ // =========================================================================
392
+ // Visual encodings — same in both views
393
+ // =========================================================================
394
+ var PALETTE = [
395
+ "#5b9dd9", "#e0a545", "#5fbf77", "#d96b6b", "#a07ee0", "#4fc3c3",
396
+ "#e07ab8", "#b3c04a", "#e08a52", "#7a8ee0", "#5fae9a", "#c9c9c9"
397
+ ];
398
+ function hashStr(s) {
399
+ var h = 5381;
400
+ for (var i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
401
+ return h;
402
+ }
403
+ function domainColor(domain) {
404
+ if (!domain) return "#6b7385"; // no-domain gray
405
+ return PALETTE[hashStr(domain) % PALETTE.length];
406
+ }
407
+ // Edge styling: dash only where the renderer supports it (2D); the 3D view
408
+ // is color-only for typed edges (accepted in #139).
409
+ // The full relationship taxonomy the server can emit (types.ts
410
+ // VALID_RELATIONSHIP_TYPES): lowercase heuristic types from the linking
411
+ // stage's classifier + uppercase LLM-classified types. In practice the
412
+ // lowercase generics (extends, relates_to) dominate the live data.
413
+ var EDGE_STYLES = {
414
+ similarity: { color: "rgba(139,147,165,0.30)", color3d: "#3a4150", width: 1, dash: [] },
415
+ extends: { color: "rgba(139,147,165,0.35)", color3d: "#4a5468", width: 1, dash: [] },
416
+ relates_to: { color: "rgba(139,165,150,0.35)", color3d: "#4a6058", width: 1, dash: [] },
417
+ updates: { color: "#3fb8b0", color3d: "#3fb8b0", width: 1.4, dash: [] },
418
+ derives: { color: "#8fbf5f", color3d: "#8fbf5f", width: 1.4, dash: [] },
419
+ CONTRADICTS: { color: "#e05555", color3d: "#e05555", width: 1.6, dash: [5, 4] },
420
+ SUPERSEDES: { color: "#e0913f", color3d: "#e0913f", width: 1.6, dash: [] },
421
+ DEPENDS_ON: { color: "#5b9dd9", color3d: "#5b9dd9", width: 1.6, dash: [] },
422
+ CAUSED_BY: { color: "#a07ee0", color3d: "#a07ee0", width: 1.6, dash: [] },
423
+ VALIDATES: { color: "#5fbf77", color3d: "#5fbf77", width: 1.6, dash: [] }
424
+ };
425
+ var EDGE_FALLBACK = { color: "rgba(139,147,165,0.4)", color3d: "#565e70", width: 1.2, dash: [] };
426
+ function edgeStyle(rel) { return EDGE_STYLES[rel] || EDGE_FALLBACK; }
427
+
428
+ // Generic "these two are broadly related" links — high-volume, low-signal.
429
+ // The weak-links slider thresholds these; specific types always show.
430
+ var GENERIC_RELS = { similarity: true, extends: true, relates_to: true };
431
+
432
+ var REL_LABELS = {
433
+ similarity: "similarity", extends: "extends", relates_to: "relates to",
434
+ updates: "updates", derives: "derives",
435
+ CONTRADICTS: "contradicts", SUPERSEDES: "supersedes",
436
+ DEPENDS_ON: "depends on", CAUSED_BY: "caused by", VALIDATES: "validates"
437
+ };
438
+
439
+ var DIM_NODE = "#262c3a";
440
+ var DIM_EDGE_2D = "rgba(38,44,58,0.35)";
441
+ var DIM_EDGE_3D = "#1c202a";
442
+
443
+ function nodeVal(n) {
444
+ var s = Math.max(0, Math.min(1, n.strength || 0));
445
+ var v = 1 + s * 7; // volume/area units — renderers turn this into radius
446
+ return n.isHub ? v * 1.6 : v;
447
+ }
448
+ function labelText(n) { return (n.label || "").slice(0, 40); }
449
+
450
+ // Edge legend — rebuilt per fetch from the relationship types actually
451
+ // present in the data, so it never lists types the graph doesn't show.
452
+ function buildEdgeLegend(edges) {
453
+ var host = document.getElementById("legend-edges");
454
+ host.innerHTML = "";
455
+ var present = {};
456
+ (edges || []).forEach(function (e) { present[e.relationship] = true; });
457
+ // Stable order: EDGE_STYLES declaration order, unknown types appended
458
+ var types = Object.keys(EDGE_STYLES).filter(function (t) { return present[t]; });
459
+ Object.keys(present).forEach(function (t) {
460
+ if (!EDGE_STYLES[t]) types.push(t);
461
+ });
462
+ if (types.length === 0) {
463
+ var none = document.createElement("div");
464
+ none.className = "legend-row";
465
+ none.appendChild(document.createTextNode("no links loaded"));
466
+ host.appendChild(none);
467
+ return;
468
+ }
469
+ types.forEach(function (t) {
470
+ var st = edgeStyle(t);
471
+ var row = document.createElement("div");
472
+ row.className = "legend-row";
473
+ var line = document.createElement("span");
474
+ line.className = "legend-line";
475
+ line.style.borderTopStyle = st.dash.length ? "dashed" : "solid";
476
+ line.style.borderTopColor = st.color;
477
+ row.appendChild(line);
478
+ row.appendChild(document.createTextNode(REL_LABELS[t] || t));
479
+ host.appendChild(row);
480
+ });
481
+ }
482
+
483
+ // =========================================================================
484
+ // State
485
+ // =========================================================================
486
+ var raw = { nodes: [], edges: [], domains: [], types: [], meta: { total: 0, shown: 0, edgeCount: 0 } };
487
+ var mode = "3d";
488
+ try { if (localStorage.getItem("hicortexVizMode") === "2d") mode = "2d"; } catch (e) { /* ignore */ }
489
+ var graph3d = null; // ForceGraph3D instance (lazy)
490
+ var graph2d = null; // ForceGraph instance (lazy)
491
+ var dataVersion = 0; // bumped on every server refetch
492
+ var appliedVersion = { "3d": -1, "2d": -1 }; // which dataVersion each renderer holds
493
+ var labeledIds = {}; // id -> true: always-visible label (hubs + top strength)
494
+ var searchTerm = "";
495
+ var matchIds = null; // null = no active search; {} = active match set
496
+ var selectedNode = null;
497
+ var graphRadius = 300; // rough world-space radius, used for 3D label fade
498
+
499
+ function simCut() { return parseFloat(rngSimCut.value); }
500
+
501
+ function isMatch(id) { return matchIds === null || matchIds[id] === true; }
502
+
503
+ // =========================================================================
504
+ // Data fetch (server refetch on Apply — same /graph?op=export surface)
505
+ // =========================================================================
506
+ function fetchGraph() {
507
+ var qs = new URLSearchParams({ op: "export" });
508
+ if (selDomain.value) qs.set("domain", selDomain.value);
509
+ if (selType.value) qs.set("type", selType.value);
510
+ if (parseFloat(rngStrength.value) > 0) qs.set("minStrength", rngStrength.value);
511
+ var lim = parseInt(inpLimit.value, 10);
512
+ if (lim > 0) qs.set("limit", String(Math.min(lim, 2000)));
513
+
514
+ var headers = {};
515
+ if (token) headers["Authorization"] = "Bearer " + token;
516
+
517
+ showStatus("Loading graph…");
518
+ fetch("/graph?" + qs.toString(), { headers: headers })
519
+ .then(function (resp) {
520
+ if (resp.status === 401) { showTokenPrompt(); throw new Error("unauthorized"); }
521
+ if (!resp.ok) {
522
+ return resp.json().catch(function () { return {}; }).then(function (body) {
523
+ throw new Error(body.error || ("Server error " + resp.status));
524
+ });
525
+ }
526
+ return resp.json();
527
+ })
528
+ .then(function (data) {
529
+ overlay.classList.remove("open");
530
+ applyData(data);
531
+ })
532
+ .catch(function (err) {
533
+ if (err.message === "unauthorized") return; // prompt already shown
534
+ showStatus("Failed to load graph: " + err.message);
535
+ });
536
+ }
537
+
538
+ function showTokenPrompt() {
539
+ hideStatus();
540
+ overlay.classList.add("open");
541
+ document.getElementById("token-input").focus();
542
+ }
543
+ document.getElementById("token-submit").addEventListener("click", submitToken);
544
+ document.getElementById("token-input").addEventListener("keydown", function (e) {
545
+ if (e.key === "Enter") submitToken();
546
+ });
547
+ function submitToken() {
548
+ var v = document.getElementById("token-input").value.trim();
549
+ if (!v) return;
550
+ token = v;
551
+ try { localStorage.setItem("hicortexToken", v); } catch (e) { /* ignore */ }
552
+ document.getElementById("token-err").style.display = "none";
553
+ var headers = { "Authorization": "Bearer " + token };
554
+ fetch("/graph?op=export&limit=1", { headers: headers }).then(function (resp) {
555
+ if (resp.status === 401) {
556
+ document.getElementById("token-err").style.display = "block";
557
+ return;
558
+ }
559
+ overlay.classList.remove("open");
560
+ fetchGraph();
561
+ }).catch(function () {
562
+ document.getElementById("token-err").style.display = "block";
563
+ });
564
+ }
565
+
566
+ // =========================================================================
567
+ // Data → renderer state
568
+ // =========================================================================
569
+ function applyData(data) {
570
+ raw = data;
571
+ dataVersion++;
572
+
573
+ fillSelect(selDomain, data.domains || []);
574
+ fillSelect(selType, data.types || []);
575
+ buildDomainLegend(data.domains || []);
576
+ buildEdgeLegend(data.edges || []);
577
+ computeLabeled();
578
+
579
+ selectedNode = null;
580
+ detail.classList.remove("open");
581
+
582
+ if (!data.nodes || data.nodes.length === 0) {
583
+ updateMeta(0);
584
+ showStatus("No memories yet — the graph will appear once the nightly capture has run.");
585
+ // Still push the empty data so a previous graph is cleared.
586
+ } else {
587
+ hideStatus();
588
+ }
589
+
590
+ refreshActive();
591
+ }
592
+
593
+ // Always-visible labels: every hub, plus the top-strength slice picked by
594
+ // the label-density slider. The rest fade in by camera proximity (3D) or
595
+ // zoom level (2D).
596
+ function computeLabeled() {
597
+ labeledIds = {};
598
+ var frac = parseFloat(rngLabels.value);
599
+ var byStrength = raw.nodes.slice().sort(function (a, b) { return b.strength - a.strength; });
600
+ var quota = Math.max(raw.nodes.length ? 3 : 0, Math.round(raw.nodes.length * frac));
601
+ for (var i = 0; i < byStrength.length; i++) {
602
+ var n = byStrength[i];
603
+ if (n.isHub || i < quota) labeledIds[n.id] = true;
604
+ }
605
+ }
606
+
607
+ // Generic links (similarity/extends/relates_to) below the threshold are
608
+ // hidden client-side; specific relationship types always show.
609
+ function visibleEdges() {
610
+ var cut = simCut();
611
+ return (raw.edges || []).filter(function (e) {
612
+ return !GENERIC_RELS[e.relationship] || (e.strength || 0) >= cut;
613
+ });
614
+ }
615
+
616
+ function updateMeta(shownEdges) {
617
+ var hidden = (raw.meta.edgeCount || 0) - shownEdges;
618
+ metaEl.textContent = "showing " + raw.meta.shown + " of " + raw.meta.total +
619
+ " · " + shownEdges + " edges" + (hidden > 0 ? " (" + hidden + " hidden)" : "");
620
+ }
621
+
622
+ // Renderers mutate node objects (positions) and rewrite link endpoints to
623
+ // object refs — each renderer gets its own copies, built from `raw`.
624
+ function cloneNodes() {
625
+ return raw.nodes.map(function (n) {
626
+ return {
627
+ id: n.id, label: n.label, content: n.content,
628
+ memory_type: n.memory_type, domain: n.domain, project: n.project,
629
+ strength: n.strength, linkCount: n.linkCount, isHub: n.isHub,
630
+ created_at: n.created_at
631
+ };
632
+ });
633
+ }
634
+ function cloneEdges(edges) {
635
+ return edges.map(function (e) {
636
+ return { source: e.source, target: e.target, relationship: e.relationship, strength: e.strength };
637
+ });
638
+ }
639
+
640
+ // Push current data + encodings into the ACTIVE renderer. The hidden one is
641
+ // refreshed lazily on toggle.
642
+ function refreshActive() {
643
+ var edges = visibleEdges();
644
+ updateMeta(edges.length);
645
+ if (mode === "3d") {
646
+ ensure3d();
647
+ if (appliedVersion["3d"] !== dataVersion) {
648
+ appliedVersion["3d"] = dataVersion;
649
+ graph3d.graphData({ nodes: cloneNodes(), links: cloneEdges(edges) });
650
+ frame3dOnce = true;
651
+ } else {
652
+ // Same nodes (positions preserved), new link set (threshold change).
653
+ graph3d.graphData({ nodes: graph3d.graphData().nodes, links: cloneEdges(edges) });
654
+ }
655
+ } else {
656
+ ensure2d();
657
+ if (appliedVersion["2d"] !== dataVersion) {
658
+ appliedVersion["2d"] = dataVersion;
659
+ graph2d.graphData({ nodes: cloneNodes(), links: cloneEdges(edges) });
660
+ frame2dOnce = true;
661
+ } else {
662
+ graph2d.graphData({ nodes: graph2d.graphData().nodes, links: cloneEdges(edges) });
663
+ }
664
+ }
665
+ }
666
+
667
+ function fillSelect(sel, values) {
668
+ var current = sel.value;
669
+ while (sel.options.length > 1) sel.remove(1);
670
+ values.forEach(function (v) {
671
+ var opt = document.createElement("option");
672
+ opt.value = v; opt.textContent = v;
673
+ sel.appendChild(opt);
674
+ });
675
+ sel.value = values.indexOf(current) >= 0 ? current : "";
676
+ }
677
+
678
+ function buildDomainLegend(domains) {
679
+ var host = document.getElementById("legend-domains");
680
+ while (host.firstChild) host.removeChild(host.firstChild);
681
+ domains.slice(0, 14).forEach(function (d) {
682
+ var row = document.createElement("div");
683
+ row.className = "legend-row";
684
+ var dot = document.createElement("span");
685
+ dot.className = "legend-dot";
686
+ dot.style.background = domainColor(d);
687
+ row.appendChild(dot);
688
+ row.appendChild(document.createTextNode(d));
689
+ host.appendChild(row);
690
+ });
691
+ var noneRow = document.createElement("div");
692
+ noneRow.className = "legend-row";
693
+ var noneDot = document.createElement("span");
694
+ noneDot.className = "legend-dot";
695
+ noneDot.style.background = domainColor(null);
696
+ noneRow.appendChild(noneDot);
697
+ noneRow.appendChild(document.createTextNode("no domain"));
698
+ host.appendChild(noneRow);
699
+ }
700
+
701
+ function tooltipHtml(n) {
702
+ return "<b>" + esc(labelText(n)) + "</b><br>" +
703
+ esc((n.domain || "no domain") + " · " + (n.memory_type || "?") +
704
+ " · strength " + (typeof n.strength === "number" ? n.strength.toFixed(2) : "?"));
705
+ }
706
+
707
+ // =========================================================================
708
+ // 3D renderer (default view)
709
+ // =========================================================================
710
+ var frame3dOnce = false;
711
+ var SPRITE_CAP = 1500; // above this only always-on labels get sprites
712
+
713
+ function makeTextSprite(text, color, worldHeight) {
714
+ var THREE = window.THREE;
715
+ var fontPx = 28;
716
+ var pad = 10;
717
+ var canvas = document.createElement("canvas");
718
+ var g = canvas.getContext("2d");
719
+ var font = "600 " + fontPx + "px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
720
+ g.font = font;
721
+ var w = Math.max(2, Math.ceil(g.measureText(text).width));
722
+ canvas.width = w + pad * 2;
723
+ canvas.height = fontPx + pad * 2;
724
+ g = canvas.getContext("2d");
725
+ g.font = font;
726
+ g.textBaseline = "middle";
727
+ // soft dark backing for readability against edges
728
+ g.fillStyle = "rgba(15,17,23,0.55)";
729
+ g.fillRect(0, 0, canvas.width, canvas.height);
730
+ g.fillStyle = color;
731
+ g.fillText(text, pad, canvas.height / 2);
732
+ var tex = new THREE.CanvasTexture(canvas);
733
+ tex.colorSpace = THREE.SRGBColorSpace;
734
+ var mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false });
735
+ var sprite = new THREE.Sprite(mat);
736
+ sprite.scale.set(worldHeight * canvas.width / canvas.height, worldHeight, 1);
737
+ return sprite;
738
+ }
739
+
740
+ function makeHaloSprite(worldSize) {
741
+ var THREE = window.THREE;
742
+ var canvas = document.createElement("canvas");
743
+ canvas.width = 64; canvas.height = 64;
744
+ var g = canvas.getContext("2d");
745
+ var grad = g.createRadialGradient(32, 32, 8, 32, 32, 32);
746
+ grad.addColorStop(0, "rgba(255,255,255,0.5)");
747
+ grad.addColorStop(0.6, "rgba(255,255,255,0.15)");
748
+ grad.addColorStop(1, "rgba(255,255,255,0)");
749
+ g.fillStyle = grad;
750
+ g.fillRect(0, 0, 64, 64);
751
+ var tex = new THREE.CanvasTexture(canvas);
752
+ var mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false });
753
+ var sprite = new THREE.Sprite(mat);
754
+ sprite.scale.set(worldSize, worldSize, 1);
755
+ return sprite;
756
+ }
757
+
758
+ function nodeRadius3d(n) {
759
+ // three-forcegraph: sphere radius = cbrt(val) * nodeRelSize (default 4)
760
+ return Math.cbrt(nodeVal(n)) * 4;
761
+ }
762
+
763
+ function ensure3d() {
764
+ if (graph3d) { graph3d.resumeAnimation(); return; }
765
+ graph3d = new window.ForceGraph3D(el3d, { controlType: "orbit" })
766
+ .width(window.innerWidth).height(window.innerHeight)
767
+ .backgroundColor("#0f1117")
768
+ .showNavInfo(false)
769
+ .nodeVal(nodeVal)
770
+ .nodeColor(function (n) { return isMatch(n.id) ? domainColor(n.domain) : DIM_NODE; })
771
+ .nodeLabel(function (n) { return tooltipHtml(n); })
772
+ .nodeThreeObjectExtend(true)
773
+ .nodeThreeObject(function (n) { return buildNodeExtras(n); })
774
+ .linkColor(function (l) {
775
+ if (matchIds !== null && !(isMatch(idOf(l.source)) && isMatch(idOf(l.target)))) return DIM_EDGE_3D;
776
+ return edgeStyle(l.relationship).color3d;
777
+ })
778
+ .linkWidth(function (l) { return GENERIC_RELS[l.relationship] ? 0 : 1.2; })
779
+ .linkOpacity(0.55)
780
+ .linkLabel(function (l) { return esc(l.relationship); })
781
+ .onNodeClick(function (n) { openDetail(n); })
782
+ .onBackgroundClick(function () { closeDetail(); })
783
+ .onEngineStop(function () {
784
+ computeGraphRadius();
785
+ updateSpriteFade();
786
+ if (frame3dOnce) { frame3dOnce = false; graph3d.zoomToFit(600, 40); }
787
+ });
788
+ // Fade secondary labels by camera proximity — orbit controls emit
789
+ // "change" on every camera move.
790
+ var controls = graph3d.controls();
791
+ if (controls && typeof controls.addEventListener === "function") {
792
+ var pending = false;
793
+ controls.addEventListener("change", function () {
794
+ if (pending) return;
795
+ pending = true;
796
+ requestAnimationFrame(function () { pending = false; updateSpriteFade(); });
797
+ });
798
+ }
799
+ }
800
+
801
+ function idOf(endpoint) {
802
+ // after force-graph binds data, link endpoints become node objects
803
+ return typeof endpoint === "object" && endpoint !== null ? endpoint.id : endpoint;
804
+ }
805
+
806
+ function buildNodeExtras(n) {
807
+ var THREE = window.THREE;
808
+ var always = labeledIds[n.id] === true;
809
+ var makeSecondary = raw.nodes.length <= SPRITE_CAP;
810
+ if (!always && !makeSecondary && !n.isHub) return false;
811
+
812
+ var group = new THREE.Group();
813
+ var r = nodeRadius3d(n);
814
+ if (n.isHub) {
815
+ var halo = makeHaloSprite(r * 3.2);
816
+ group.add(halo);
817
+ }
818
+ if (always || makeSecondary) {
819
+ var color = isMatch(n.id) ? "#d5dae4" : "#4a5163";
820
+ var sprite = makeTextSprite(labelText(n), color, 6);
821
+ sprite.position.set(0, r + 5, 0);
822
+ sprite.material.opacity = always ? 1 : 0;
823
+ n.__labelSprite = sprite;
824
+ n.__labelAlways = always;
825
+ group.add(sprite);
826
+ }
827
+ return group;
828
+ }
829
+
830
+ function computeGraphRadius() {
831
+ if (!graph3d) return;
832
+ var nodes = graph3d.graphData().nodes;
833
+ var max = 0;
834
+ for (var i = 0; i < nodes.length; i++) {
835
+ var n = nodes[i];
836
+ var d = (n.x || 0) * (n.x || 0) + (n.y || 0) * (n.y || 0) + (n.z || 0) * (n.z || 0);
837
+ if (d > max) max = d;
838
+ }
839
+ graphRadius = Math.max(120, Math.sqrt(max));
840
+ }
841
+
842
+ function updateSpriteFade() {
843
+ if (!graph3d) return;
844
+ var cam = graph3d.camera();
845
+ if (!cam) return;
846
+ var near = graphRadius * 0.45;
847
+ var far = graphRadius * 1.1;
848
+ var nodes = graph3d.graphData().nodes;
849
+ for (var i = 0; i < nodes.length; i++) {
850
+ var n = nodes[i];
851
+ var s = n.__labelSprite;
852
+ if (!s) continue;
853
+ var matched = isMatch(n.id);
854
+ if (n.__labelAlways) {
855
+ s.material.opacity = matched ? 1 : 0.15;
856
+ continue;
857
+ }
858
+ if (matchIds !== null) {
859
+ // active search: reveal matching secondary labels, hide the rest
860
+ s.material.opacity = matched ? 0.95 : 0;
861
+ continue;
862
+ }
863
+ var dx = cam.position.x - (n.x || 0);
864
+ var dy = cam.position.y - (n.y || 0);
865
+ var dz = cam.position.z - (n.z || 0);
866
+ var dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
867
+ var t = (far - dist) / (far - near);
868
+ s.material.opacity = Math.max(0, Math.min(0.9, t));
869
+ }
870
+ }
871
+
872
+ // =========================================================================
873
+ // 2D renderer (toggle)
874
+ // =========================================================================
875
+ var frame2dOnce = false;
876
+
877
+ function ensure2d() {
878
+ if (graph2d) { graph2d.resumeAnimation(); return; }
879
+ graph2d = new window.ForceGraph(el2d)
880
+ .width(window.innerWidth).height(window.innerHeight)
881
+ .backgroundColor("#0f1117")
882
+ .nodeVal(nodeVal)
883
+ .nodeColor(function (n) { return isMatch(n.id) ? domainColor(n.domain) : DIM_NODE; })
884
+ .nodeLabel(function (n) { return tooltipHtml(n); })
885
+ .nodeCanvasObjectMode(function () { return "after"; })
886
+ .nodeCanvasObject(function (n, ctx, scale) { drawNodeExtras2d(n, ctx, scale); })
887
+ .linkColor(function (l) {
888
+ if (matchIds !== null && !(isMatch(idOf(l.source)) && isMatch(idOf(l.target)))) return DIM_EDGE_2D;
889
+ return edgeStyle(l.relationship).color;
890
+ })
891
+ .linkWidth(function (l) { return edgeStyle(l.relationship).width; })
892
+ .linkLineDash(function (l) { return edgeStyle(l.relationship).dash; })
893
+ .linkLabel(function (l) { return esc(l.relationship); })
894
+ .onNodeClick(function (n) { openDetail(n); })
895
+ .onBackgroundClick(function () { closeDetail(); })
896
+ .onEngineStop(function () {
897
+ if (frame2dOnce) { frame2dOnce = false; graph2d.zoomToFit(400, 60); }
898
+ });
899
+ }
900
+
901
+ function nodeRadius2d(n) {
902
+ // force-graph: circle area ∝ val; radius = sqrt(val) * nodeRelSize (4)
903
+ return Math.sqrt(nodeVal(n)) * 4;
904
+ }
905
+
906
+ function drawNodeExtras2d(n, ctx, scale) {
907
+ var r = nodeRadius2d(n);
908
+ var matched = isMatch(n.id);
909
+
910
+ if (n.isHub) {
911
+ ctx.beginPath();
912
+ ctx.arc(n.x, n.y, r + 2.5 / scale, 0, Math.PI * 2);
913
+ ctx.strokeStyle = matched ? "rgba(255,255,255,0.55)" : "rgba(255,255,255,0.12)";
914
+ ctx.lineWidth = 1.6 / scale;
915
+ ctx.stroke();
916
+ }
917
+ if (n === selectedNode) {
918
+ ctx.beginPath();
919
+ ctx.arc(n.x, n.y, r + 1 / scale, 0, Math.PI * 2);
920
+ ctx.strokeStyle = "#ffffff";
921
+ ctx.lineWidth = 1.5 / scale;
922
+ ctx.stroke();
923
+ }
924
+
925
+ var always = labeledIds[n.id] === true;
926
+ var alpha;
927
+ if (matchIds !== null) {
928
+ alpha = matched ? 1 : (always ? 0.12 : 0);
929
+ } else if (always) {
930
+ alpha = 1;
931
+ } else {
932
+ // fade the rest in by zoom
933
+ alpha = Math.max(0, Math.min(0.9, (scale - 1.4) / 1.6));
934
+ }
935
+ if (alpha <= 0.01) return;
936
+
937
+ var fontSize = Math.max(4, 11 / scale);
938
+ ctx.font = "600 " + fontSize + "px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
939
+ ctx.globalAlpha = alpha;
940
+ ctx.fillStyle = matched ? "#d5dae4" : "#4a5163";
941
+ ctx.textBaseline = "middle";
942
+ ctx.fillText(labelText(n), n.x + r + 3 / scale, n.y);
943
+ ctx.globalAlpha = 1;
944
+ }
945
+
946
+ // =========================================================================
947
+ // View toggle 3D ⇄ 2D
948
+ // =========================================================================
949
+ function setMode(next) {
950
+ mode = next;
951
+ try { localStorage.setItem("hicortexVizMode", mode); } catch (e) { /* ignore */ }
952
+ document.body.classList.toggle("mode-2d", mode === "2d");
953
+ syncModeControl();
954
+ if (mode === "3d" && graph2d) graph2d.pauseAnimation();
955
+ if (mode === "2d" && graph3d) graph3d.pauseAnimation();
956
+ refreshActive();
957
+ }
958
+ modeSwitch.addEventListener("change", function () { setMode(modeSwitch.checked ? "3d" : "2d"); });
959
+
960
+ // =========================================================================
961
+ // Zoom controls — explicit buttons alongside the built-in wheel/pinch zoom
962
+ // =========================================================================
963
+ function zoomBy(factor) {
964
+ if (mode === "3d") {
965
+ if (!graph3d) return;
966
+ // Move the camera toward/away from the orbit target (factor < 1 = in).
967
+ var p = graph3d.cameraPosition();
968
+ var t = graph3d.controls().target;
969
+ graph3d.cameraPosition({
970
+ x: t.x + (p.x - t.x) * factor,
971
+ y: t.y + (p.y - t.y) * factor,
972
+ z: t.z + (p.z - t.z) * factor
973
+ }, t, 200);
974
+ } else {
975
+ if (!graph2d) return;
976
+ graph2d.zoom(graph2d.zoom() / factor, 200);
977
+ }
978
+ }
979
+ function zoomFit() {
980
+ if (mode === "3d" && graph3d) graph3d.zoomToFit(600, 40);
981
+ if (mode === "2d" && graph2d) graph2d.zoomToFit(400, 60);
982
+ }
983
+ document.getElementById("zoom-in").addEventListener("click", function () { zoomBy(0.7); });
984
+ document.getElementById("zoom-out").addEventListener("click", function () { zoomBy(1.43); });
985
+ document.getElementById("zoom-fit").addEventListener("click", zoomFit);
986
+
987
+ // =========================================================================
988
+ // Search — client-side highlight (matching bright, others dimmed)
989
+ // =========================================================================
990
+ function matchesSearch(n) {
991
+ var q = searchTerm;
992
+ return (n.label && n.label.toLowerCase().indexOf(q) >= 0) ||
993
+ (n.content && n.content.toLowerCase().indexOf(q) >= 0) ||
994
+ (n.domain && n.domain.toLowerCase().indexOf(q) >= 0) ||
995
+ (n.project && n.project.toLowerCase().indexOf(q) >= 0);
996
+ }
997
+
998
+ function applySearch() {
999
+ searchTerm = inpSearch.value.trim().toLowerCase();
1000
+ if (!searchTerm) {
1001
+ matchIds = null;
1002
+ } else {
1003
+ matchIds = {};
1004
+ raw.nodes.forEach(function (n) { if (matchesSearch(n)) matchIds[n.id] = true; });
1005
+ }
1006
+ // Setting the color accessors to FRESH closures (new identity) forces
1007
+ // both renderers to re-evaluate colors for every node/link.
1008
+ if (graph3d) {
1009
+ graph3d
1010
+ .nodeColor(function (n) { return isMatch(n.id) ? domainColor(n.domain) : DIM_NODE; })
1011
+ .linkColor(function (l) {
1012
+ if (matchIds !== null && !(isMatch(idOf(l.source)) && isMatch(idOf(l.target)))) return DIM_EDGE_3D;
1013
+ return edgeStyle(l.relationship).color3d;
1014
+ });
1015
+ updateSpriteFade();
1016
+ }
1017
+ if (graph2d) {
1018
+ graph2d
1019
+ .nodeColor(function (n) { return isMatch(n.id) ? domainColor(n.domain) : DIM_NODE; })
1020
+ .linkColor(function (l) {
1021
+ if (matchIds !== null && !(isMatch(idOf(l.source)) && isMatch(idOf(l.target)))) return DIM_EDGE_2D;
1022
+ return edgeStyle(l.relationship).color;
1023
+ });
1024
+ }
1025
+ }
1026
+ inpSearch.addEventListener("input", applySearch);
1027
+
1028
+ // =========================================================================
1029
+ // Detail panel — memory content is ALWAYS set via textContent, never
1030
+ // innerHTML (pinned security property).
1031
+ // =========================================================================
1032
+ function openDetail(n) {
1033
+ selectedNode = n;
1034
+ detailTitle.textContent = n.label;
1035
+ while (detailMeta.firstChild) detailMeta.removeChild(detailMeta.firstChild);
1036
+ var fields = [
1037
+ ["ID", n.id],
1038
+ ["Domain", n.domain || "—"],
1039
+ ["Project", n.project || "—"],
1040
+ ["Type", n.memory_type || "—"],
1041
+ ["Strength", typeof n.strength === "number" ? n.strength.toFixed(3) : "—"],
1042
+ ["Links", String(n.linkCount) + (n.isHub ? " (hub)" : "")],
1043
+ ["Created", n.created_at || "—"]
1044
+ ];
1045
+ fields.forEach(function (f) {
1046
+ var row = document.createElement("div");
1047
+ var dt = document.createElement("dt"); dt.textContent = f[0];
1048
+ var dd = document.createElement("dd"); dd.textContent = f[1];
1049
+ row.appendChild(dt); row.appendChild(dd);
1050
+ detailMeta.appendChild(row);
1051
+ });
1052
+ detailContent.textContent = n.content;
1053
+ detail.classList.add("open");
1054
+ }
1055
+ function closeDetail() {
1056
+ selectedNode = null;
1057
+ detail.classList.remove("open");
1058
+ }
1059
+ document.getElementById("detail-close").addEventListener("click", closeDetail);
1060
+ window.addEventListener("keydown", function (e) {
1061
+ if (e.key === "Escape") closeDetail();
1062
+ });
1063
+
1064
+ // =========================================================================
1065
+ // Controls wiring
1066
+ // =========================================================================
1067
+ rngStrength.addEventListener("input", function () {
1068
+ rngStrengthVal.textContent = parseFloat(rngStrength.value).toFixed(2);
1069
+ });
1070
+ document.getElementById("apply").addEventListener("click", fetchGraph);
1071
+
1072
+ rngSimCut.addEventListener("input", function () {
1073
+ rngSimCutVal.textContent = parseFloat(rngSimCut.value).toFixed(2);
1074
+ });
1075
+ rngSimCut.addEventListener("change", function () { refreshActive(); });
1076
+
1077
+ rngLabels.addEventListener("input", function () {
1078
+ rngLabelsVal.textContent = Math.round(parseFloat(rngLabels.value) * 100) + "%";
1079
+ });
1080
+ rngLabels.addEventListener("change", function () {
1081
+ computeLabeled();
1082
+ // Rebuild node extras (3D) / trigger a redraw (2D) with the new label
1083
+ // set — fresh closures so the renderers can't skip the update.
1084
+ if (graph3d) {
1085
+ graph3d.nodeThreeObject(function (n) { return buildNodeExtras(n); });
1086
+ updateSpriteFade();
1087
+ }
1088
+ if (graph2d) {
1089
+ graph2d.nodeCanvasObject(function (n, ctx, scale) { drawNodeExtras2d(n, ctx, scale); });
1090
+ }
1091
+ });
1092
+
1093
+ window.addEventListener("resize", function () {
1094
+ if (graph3d) graph3d.width(window.innerWidth).height(window.innerHeight);
1095
+ if (graph2d) graph2d.width(window.innerWidth).height(window.innerHeight);
1096
+ });
1097
+
1098
+ // =========================================================================
1099
+ // Boot — after DOMContentLoaded all deferred vendor scripts have executed.
1100
+ // Fail explicitly when a vendored bundle is missing (broken install).
1101
+ // =========================================================================
1102
+ function boot() {
1103
+ if (!window.THREE || !window.ForceGraph3D || !window.ForceGraph) {
1104
+ showStatus(
1105
+ "Failed to load the graph renderer bundles (/viz/vendor/…). " +
1106
+ "The package install looks incomplete — reinstall @gamaze/hicortex " +
1107
+ "and restart the server."
1108
+ );
1109
+ return;
1110
+ }
1111
+ document.body.classList.toggle("mode-2d", mode === "2d");
1112
+ syncModeControl();
1113
+ fetchGraph();
1114
+ }
1115
+ if (document.readyState === "complete" || document.readyState === "interactive") {
1116
+ // DOM already parsed — but deferred scripts may still be pending; queue
1117
+ // behind them via DOMContentLoaded when possible, else run now.
1118
+ if (document.readyState === "complete") boot();
1119
+ else window.addEventListener("DOMContentLoaded", boot);
1120
+ } else {
1121
+ window.addEventListener("DOMContentLoaded", boot);
1122
+ }
1123
+ })();
1124
+ </script>
1125
+ </body>
1126
+ </html>