synabun 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,1635 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — 2D Canvas Renderer
3
+ // Pure canvas rendering with orbital layout,
4
+ // viewport pan/zoom, hit detection, drag system
5
+ // ═══════════════════════════════════════════
6
+
7
+ import { gfx } from './gfx.js';
8
+ import { computeLayout, resetLayout, flattenRegions, CARD_W, CARD_H } from './layout.js';
9
+ import { drawBackground, initBackground } from './background.js';
10
+ import { state, emit, on } from '../../shared/state.js';
11
+ import { catColor, hexAlpha } from '../../shared/colors.js';
12
+ import { KEYS } from '../../shared/constants.js';
13
+ import { storage } from '../../shared/storage.js';
14
+ import { truncate, normalizeNodes } from '../../shared/utils.js';
15
+
16
+ // ═══════════════════════════════════════════
17
+ // PRIVATE STATE
18
+ // ═══════════════════════════════════════════
19
+
20
+ let _canvas = null;
21
+ let _ctx = null;
22
+ let _container = null;
23
+ let _animFrameId = null;
24
+ let _callbacks = {};
25
+
26
+ // Layout data
27
+ let _regions = []; // CategoryRegion[] from computeLayout
28
+ let _cards = []; // CardPosition[] from computeLayout
29
+ let _cardById = new Map(); // id → CardPosition
30
+
31
+ // Category logo preload cache
32
+ const _categoryLogos = new Map();
33
+
34
+ // Fallback logo for OpenClaw (hardcoded like 3D variant)
35
+ const _openclawLogo = new Image();
36
+ _openclawLogo.src = '/openclaw-logo-text.png';
37
+ let _openclawLogoReady = false;
38
+ _openclawLogo.onload = () => { _openclawLogoReady = true; };
39
+
40
+ // Graph removal scheduling
41
+ let _graphRemovalTimer = null;
42
+
43
+ // ═══════════════════════════════════════════
44
+ // PERFORMANCE CACHES
45
+ // ═══════════════════════════════════════════
46
+
47
+ // Neighbor adjacency map: id → Set<id> — built once in applyGraphData
48
+ let _neighborMap = new Map();
49
+
50
+ // Resolved links cache — built once in applyGraphData, avoids per-frame ID lookups
51
+ // Each entry: { sourceCard, targetCard, strength, crossCat }
52
+ let _resolvedLinks = [];
53
+ let _resolvedLinksStrong = []; // strength >= 0.4 (shown at LOD 1)
54
+
55
+ // Card text cache: nodeId → { content, lines }
56
+ const _textCache = new Map();
57
+
58
+ // DPR for high-DPI rendering (capped to limit fill rate on high-DPI displays)
59
+ const MAX_DPR = 1.5;
60
+ let _dpr = 1;
61
+
62
+ // ═══════════════════════════════════════════
63
+ // VIEWPORT (pan/zoom state)
64
+ // ═══════════════════════════════════════════
65
+
66
+ const _viewport = {
67
+ offsetX: 0,
68
+ offsetY: 0,
69
+ scale: 0.4,
70
+ minScale: 0.02,
71
+ maxScale: 3.0,
72
+ };
73
+
74
+ // Animation state
75
+ let _animating = false;
76
+ let _animStart = 0;
77
+ let _animDuration = 0;
78
+ let _animFrom = {};
79
+ let _animTo = {};
80
+
81
+ // Smooth zoom state — lerps toward target for fluid feel
82
+ const _smoothZoom = {
83
+ targetScale: 0.4,
84
+ anchorX: 0, // cursor position in buffer coords (zoom pivot)
85
+ anchorY: 0,
86
+ active: false,
87
+ };
88
+
89
+ // ═══════════════════════════════════════════
90
+ // INTERACTION STATE
91
+ // ═══════════════════════════════════════════
92
+
93
+ const _pointer = {
94
+ isPanning: false,
95
+ isDragging: false,
96
+ dragTarget: null, // { type: 'card'|'region', item: CardPosition|Region }
97
+ dragSet: [], // items moving together (rigid-body)
98
+ dragStartWorld: null, // world coords at drag start
99
+ lastScreenX: 0,
100
+ lastScreenY: 0,
101
+ downScreenX: 0,
102
+ downScreenY: 0,
103
+ lastMoveTime: 0, // for velocity tracking
104
+ downTime: 0,
105
+ hasMoved: false,
106
+ };
107
+
108
+ // Position save timer
109
+ let _posSaveTimer = null;
110
+
111
+ // Focus mode — true when visualization is paused (zero-resource mode)
112
+ let _focusPaused = false;
113
+
114
+ // Pan inertia — coasting after mouse release
115
+ const _panInertia = {
116
+ vx: 0,
117
+ vy: 0,
118
+ active: false,
119
+ };
120
+
121
+ // ═══════════════════════════════════════════
122
+ // COORDINATE TRANSFORMS
123
+ // ═══════════════════════════════════════════
124
+
125
+ export function screenToWorld(sx, sy) {
126
+ return {
127
+ x: (sx * _dpr - _viewport.offsetX) / _viewport.scale,
128
+ y: (sy * _dpr - _viewport.offsetY) / _viewport.scale,
129
+ };
130
+ }
131
+
132
+ export function worldToScreen(wx, wy) {
133
+ return {
134
+ x: (wx * _viewport.scale + _viewport.offsetX) / _dpr,
135
+ y: (wy * _viewport.scale + _viewport.offsetY) / _dpr,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Get the visible world-space bounding box (with margin for partially visible items).
141
+ * @param {number} margin Extra world-space pixels around the viewport edges
142
+ * @returns {{ left: number, right: number, top: number, bottom: number }}
143
+ */
144
+ function _getVisibleBounds(margin = 0) {
145
+ const invScale = 1 / _viewport.scale;
146
+ const left = -_viewport.offsetX * invScale - margin;
147
+ const top = -_viewport.offsetY * invScale - margin;
148
+ const right = (_canvas.width - _viewport.offsetX) * invScale + margin;
149
+ const bottom = (_canvas.height - _viewport.offsetY) * invScale + margin;
150
+ return { left, right, top, bottom };
151
+ }
152
+
153
+ // ═══════════════════════════════════════════
154
+ // PUBLIC API: Viewport accessors
155
+ // ═══════════════════════════════════════════
156
+
157
+ export function getViewport() { return { ..._viewport, dpr: _dpr }; }
158
+ export function getAllCards() { return _cards; }
159
+ export function getRegions() { return _regions; }
160
+
161
+
162
+ // ═══════════════════════════════════════════
163
+ // INIT
164
+ // ═══════════════════════════════════════════
165
+
166
+ /**
167
+ * Initialize the 2D canvas renderer.
168
+ * @param {HTMLElement} container The #graph-container element
169
+ * @param {Object} callbacks { onRenderFramePost }
170
+ * @returns {Object} Public API shim (centerAt, zoom, etc.)
171
+ */
172
+ export function initGraph(container, callbacks = {}) {
173
+ _container = container;
174
+ _callbacks = callbacks;
175
+
176
+ // Create or find the main canvas
177
+ _canvas = document.getElementById('canvas-main');
178
+ if (!_canvas) {
179
+ _canvas = document.createElement('canvas');
180
+ _canvas.id = 'canvas-main';
181
+ _canvas.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1;';
182
+ container.appendChild(_canvas);
183
+ }
184
+ _ctx = _canvas.getContext('2d');
185
+
186
+ // Size canvas (DPR-aware, capped for perf)
187
+ _dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
188
+ _resizeCanvas();
189
+
190
+ // Center viewport
191
+ _viewport.offsetX = _canvas.width / 2;
192
+ _viewport.offsetY = _canvas.height / 2;
193
+ _smoothZoom.targetScale = _viewport.scale;
194
+
195
+ // Attach event listeners
196
+ _canvas.addEventListener('pointerdown', _onPointerDown);
197
+ _canvas.addEventListener('pointermove', _onPointerMove);
198
+ _canvas.addEventListener('pointerup', _onPointerUp);
199
+ _canvas.addEventListener('pointerleave', _onPointerLeave);
200
+ _canvas.addEventListener('wheel', _onWheel, { passive: false });
201
+ _canvas.addEventListener('dblclick', _onDblClick);
202
+ _canvas.addEventListener('contextmenu', _onContextMenu);
203
+ window.addEventListener('resize', _resizeCanvas);
204
+
205
+ // Start render loop
206
+ _animFrameId = requestAnimationFrame(_renderLoop);
207
+
208
+ // Init background (logo preload)
209
+ initBackground();
210
+
211
+ return _getPublicAPI();
212
+ }
213
+
214
+ /**
215
+ * Get the public API object (force-graph compatible shim).
216
+ * @returns {Object|null}
217
+ */
218
+ export function getGraph() {
219
+ if (!_canvas) return null;
220
+ return _getPublicAPI();
221
+ }
222
+
223
+
224
+ // ═══════════════════════════════════════════
225
+ // PUBLIC API: Data & Layout
226
+ // ═══════════════════════════════════════════
227
+
228
+ /**
229
+ * Apply graph data: filters by activeCategories, runs layout, updates render state.
230
+ * Call after data load, category change, or search change.
231
+ */
232
+ export function applyGraphData() {
233
+ if (!_canvas) return;
234
+
235
+ // Filter nodes by active categories
236
+ const visibleNodes = state.allNodes.filter(n => {
237
+ const cat = n.payload?.category;
238
+ return cat && state.activeCategories.has(cat);
239
+ });
240
+
241
+ // Compute orbital layout
242
+ const result = computeLayout(visibleNodes, state.categoryMetadata, state.activeCategories);
243
+ _regions = result.regions;
244
+ _cards = result.cards;
245
+
246
+ // Build card lookup
247
+ _cardById.clear();
248
+ for (const card of _cards) {
249
+ _cardById.set(card.node.id, card);
250
+ }
251
+
252
+ // Build neighbor adjacency map + resolved links (O(links) once, not per frame)
253
+ _neighborMap.clear();
254
+ _resolvedLinks = [];
255
+ _resolvedLinksStrong = [];
256
+ for (const link of state.allLinks) {
257
+ const s = typeof link.source === 'object' ? link.source.id : link.source;
258
+ const t = typeof link.target === 'object' ? link.target.id : link.target;
259
+ if (!_neighborMap.has(s)) _neighborMap.set(s, new Set());
260
+ if (!_neighborMap.has(t)) _neighborMap.set(t, new Set());
261
+ _neighborMap.get(s).add(t);
262
+ _neighborMap.get(t).add(s);
263
+
264
+ const sc = _cardById.get(s);
265
+ const tc = _cardById.get(t);
266
+ if (!sc || !tc) continue;
267
+ const crossCat = (sc.node.payload?.category !== tc.node.payload?.category);
268
+ const entry = { sourceCard: sc, targetCard: tc, strength: link.strength || 0.5, crossCat };
269
+ _resolvedLinks.push(entry);
270
+ if (entry.strength >= 0.4) _resolvedLinksStrong.push(entry);
271
+ }
272
+
273
+ // Clear text cache (content may have changed)
274
+ _textCache.clear();
275
+
276
+ // Preload category logos
277
+ const cats = [];
278
+ for (const [name, meta] of Object.entries(state.categoryMetadata)) {
279
+ if (meta.logo) cats.push({ name, logo: meta.logo });
280
+ }
281
+ preloadCategoryLogos(cats);
282
+
283
+ emit('stats-changed');
284
+ }
285
+
286
+ /**
287
+ * Preload category logo images from metadata.
288
+ * @param {Array<{name:string, logo:string}>} categories
289
+ */
290
+ export function preloadCategoryLogos(categories) {
291
+ const names = new Set(categories.map(c => c.name));
292
+ for (const [k] of _categoryLogos) { if (!names.has(k)) _categoryLogos.delete(k); }
293
+ for (const cat of categories) {
294
+ if (!cat.logo) continue;
295
+ const existing = _categoryLogos.get(cat.name);
296
+ if (existing && existing.src === cat.logo) continue;
297
+ const img = new Image();
298
+ const entry = { img, ready: false, src: cat.logo };
299
+ img.onload = () => { entry.ready = true; };
300
+ img.onerror = () => { _categoryLogos.delete(cat.name); };
301
+ img.src = cat.logo;
302
+ _categoryLogos.set(cat.name, entry);
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Re-fetch data from API and re-apply layout.
308
+ */
309
+ export async function refreshGraph() {
310
+ const res = await fetch('/api/memories');
311
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
312
+ const data = await res.json();
313
+ state.allNodes = normalizeNodes(data.nodes);
314
+ state.allLinks = data.links;
315
+ applyGraphData();
316
+ emit('data-loaded', data);
317
+ }
318
+
319
+ /**
320
+ * Re-compute layout from scratch (replaces reheatSimulation).
321
+ */
322
+ export function reheatSimulation() {
323
+ resetLayout();
324
+ applyGraphData();
325
+ }
326
+
327
+ /**
328
+ * Schedule a deferred graph data reapply (after deletion animation).
329
+ */
330
+ export function scheduleGraphRemoval(delay = 500) {
331
+ cancelScheduledRemoval();
332
+ _graphRemovalTimer = setTimeout(() => {
333
+ applyGraphData();
334
+ _graphRemovalTimer = null;
335
+ }, delay);
336
+ }
337
+
338
+ export function cancelScheduledRemoval() {
339
+ if (_graphRemovalTimer) {
340
+ clearTimeout(_graphRemovalTimer);
341
+ _graphRemovalTimer = null;
342
+ }
343
+ }
344
+
345
+
346
+ /** Stop the render loop (focus mode). */
347
+ export function stopAnimation() {
348
+ if (_animFrameId) {
349
+ cancelAnimationFrame(_animFrameId);
350
+ _animFrameId = null;
351
+ }
352
+ }
353
+
354
+ /** Restart the render loop after stopping. */
355
+ export function startAnimation() {
356
+ if (!_animFrameId && _canvas) {
357
+ _animFrameId = requestAnimationFrame(_renderLoop);
358
+ }
359
+ }
360
+
361
+ /** Pause all interaction (focus mode — zero resource consumption). */
362
+ export function pauseInteraction() {
363
+ _focusPaused = true;
364
+ _panInertia.active = false;
365
+ // Reset any in-flight drag/pan so state doesn't stick across focus toggles
366
+ _pointer.isPanning = false;
367
+ _pointer.isDragging = false;
368
+ // Ensure canvas doesn't block pointer events to layers below (whiteboard).
369
+ // This also covers the boot race where viz:toggle fires before _bootComplete.
370
+ if (_canvas) _canvas.classList.add('viz-hidden-2d');
371
+ }
372
+
373
+ /** Resume interaction after focus mode exit. */
374
+ export function resumeInteraction() {
375
+ _focusPaused = false;
376
+ if (_canvas) _canvas.classList.remove('viz-hidden-2d');
377
+ }
378
+
379
+ // ═══════════════════════════════════════════
380
+ // RENDER LOOP
381
+ // ═══════════════════════════════════════════
382
+
383
+ function _renderLoop(time) {
384
+ if (!_canvas || !_ctx) return;
385
+ const w = _canvas.width;
386
+ const h = _canvas.height;
387
+ const ctx = _ctx;
388
+
389
+ // Process viewport animation
390
+ if (_animating) {
391
+ const elapsed = time - _animStart;
392
+ const t = Math.min(elapsed / _animDuration, 1);
393
+ const ease = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; // easeInOutQuad
394
+
395
+ _viewport.offsetX = _animFrom.offsetX + (_animTo.offsetX - _animFrom.offsetX) * ease;
396
+ _viewport.offsetY = _animFrom.offsetY + (_animTo.offsetY - _animFrom.offsetY) * ease;
397
+ _viewport.scale = _animFrom.scale + (_animTo.scale - _animFrom.scale) * ease;
398
+
399
+ if (t >= 1) {
400
+ _animating = false;
401
+ _smoothZoom.targetScale = _viewport.scale;
402
+ _smoothZoom.active = false;
403
+ }
404
+ }
405
+
406
+ // Process smooth zoom (lerp toward target scale with cursor as pivot)
407
+ if (_smoothZoom.active) {
408
+ const LERP = 0.18;
409
+ const diff = _smoothZoom.targetScale - _viewport.scale;
410
+ if (Math.abs(diff) < 0.0001) {
411
+ _viewport.scale = _smoothZoom.targetScale;
412
+ _smoothZoom.active = false;
413
+ } else {
414
+ const newScale = _viewport.scale + diff * LERP;
415
+ const mx = _smoothZoom.anchorX;
416
+ const my = _smoothZoom.anchorY;
417
+ _viewport.offsetX = mx - (mx - _viewport.offsetX) * (newScale / _viewport.scale);
418
+ _viewport.offsetY = my - (my - _viewport.offsetY) * (newScale / _viewport.scale);
419
+ _viewport.scale = newScale;
420
+ }
421
+ }
422
+
423
+ // Process pan inertia (coasting after mouse release)
424
+ if (_panInertia.active) {
425
+ const FRICTION = 0.92;
426
+ _viewport.offsetX += _panInertia.vx;
427
+ _viewport.offsetY += _panInertia.vy;
428
+ _panInertia.vx *= FRICTION;
429
+ _panInertia.vy *= FRICTION;
430
+ if (Math.abs(_panInertia.vx) < 0.1 && Math.abs(_panInertia.vy) < 0.1) {
431
+ _panInertia.active = false;
432
+ }
433
+ }
434
+
435
+ // ── Compute visible world bounds (with margin for partially visible items) ──
436
+ const cardMargin = Math.max(CARD_W, CARD_H);
437
+ const vb = _getVisibleBounds(cardMargin);
438
+
439
+ // ── LOD thresholds (card screen-space size in CSS pixels) ──
440
+ const cardScreenW = CARD_W * _viewport.scale / _dpr;
441
+ // LOD 0: full card (cardScreenW >= 25px)
442
+ // LOD 1: simple colored rect (12-25px)
443
+ // LOD 2: tiny dot (< 12px)
444
+ const lod = cardScreenW >= 25 ? 0 : cardScreenW >= 12 ? 1 : 2;
445
+
446
+ // ── 1. Background (screen-space) ──
447
+ drawBackground(ctx, time, w, h);
448
+
449
+ // ── 2. Apply pan/zoom transform ──
450
+ ctx.save();
451
+ ctx.translate(_viewport.offsetX, _viewport.offsetY);
452
+ ctx.scale(_viewport.scale, _viewport.scale);
453
+
454
+ // ── 3. Category region glows (culled, skip at LOD 2) ──
455
+ if (gfx.regionGlowOpacity > 0 && lod < 2) {
456
+ for (const region of _regions) {
457
+ if (_isRegionVisible(region, vb)) _drawRegionGlow(ctx, region);
458
+ for (const child of (region.children || [])) {
459
+ if (_isRegionVisible(child, vb)) _drawRegionGlow(ctx, child);
460
+ }
461
+ }
462
+ }
463
+
464
+ // ── 4. Connection lines (batched + culled, LOD-aware, skip at LOD 2) ──
465
+ if (state.linkMode !== 'off' && _resolvedLinks.length > 0 && lod < 2) {
466
+ _drawLinks(ctx, vb, lod);
467
+ }
468
+
469
+ // ── 5. Memory cards (LOD-aware + culled) ──
470
+ if (lod === 2) {
471
+ // LOD 2: tiny dots — batch into single path per color for speed
472
+ _drawCardDots(ctx, vb);
473
+ } else if (lod === 1) {
474
+ // LOD 1: simple colored rectangles, no text/shadows
475
+ _drawCardSimple(ctx, vb);
476
+ } else {
477
+ // LOD 0: full detail
478
+ for (const card of _cards) {
479
+ if (card.x + CARD_W / 2 < vb.left || card.x - CARD_W / 2 > vb.right ||
480
+ card.y + CARD_H / 2 < vb.top || card.y - CARD_H / 2 > vb.bottom) continue;
481
+ _drawCard(ctx, card);
482
+ }
483
+ }
484
+
485
+ // ── 6. Category labels (LOD-aware + culled) ──
486
+ // Child labels hidden at LOD >= 1 (zoomed out too far to be useful)
487
+ // Child labels fade near the LOD boundary for smooth transition
488
+ const childLabelOpacity = lod >= 1 ? 0 : Math.min(1, (cardScreenW - 25) / 15); // fade 25→40px
489
+ for (const region of _regions) {
490
+ if (_isRegionVisible(region, vb)) _drawCategoryLabel(ctx, region, true, 1);
491
+ if (childLabelOpacity > 0) {
492
+ for (const child of (region.children || [])) {
493
+ if (_isRegionVisible(child, vb)) _drawCategoryLabel(ctx, child, false, childLabelOpacity);
494
+ }
495
+ }
496
+ }
497
+
498
+ ctx.restore();
499
+
500
+ // ── 7. Post-render callback (minimap, etc.) ──
501
+ if (_callbacks.onRenderFramePost) {
502
+ _callbacks.onRenderFramePost();
503
+ }
504
+ emit('render-frame-post');
505
+
506
+ _animFrameId = requestAnimationFrame(_renderLoop);
507
+ }
508
+
509
+ /** Check if a region's bounding circle is within the visible bounds */
510
+ function _isRegionVisible(region, vb) {
511
+ const r = (region.radius || 200) + 100; // extra margin for glow/labels
512
+ return region.cx + r > vb.left && region.cx - r < vb.right &&
513
+ region.cy + r > vb.top && region.cy - r < vb.bottom;
514
+ }
515
+
516
+
517
+ // ═══════════════════════════════════════════
518
+ // DRAWING: Region Glow
519
+ // ═══════════════════════════════════════════
520
+
521
+ function _drawRegionGlow(ctx, region) {
522
+ if (!region.radius || region.radius <= 0) return;
523
+ const r = region.radius + 40;
524
+ const grad = ctx.createRadialGradient(region.cx, region.cy, 0, region.cx, region.cy, r);
525
+ grad.addColorStop(0, hexAlpha(region.color, gfx.regionGlowOpacity));
526
+ grad.addColorStop(1, hexAlpha(region.color, 0));
527
+ ctx.fillStyle = grad;
528
+ ctx.beginPath();
529
+ ctx.arc(region.cx, region.cy, r, 0, Math.PI * 2);
530
+ ctx.fill();
531
+ }
532
+
533
+
534
+ // ═══════════════════════════════════════════
535
+ // DRAWING: Category Labels
536
+ // ═══════════════════════════════════════════
537
+
538
+ function _drawCategoryLabel(ctx, region, isParent, labelOpacity = 1) {
539
+ // Respect label visibility toggle
540
+ if (!state.labelsVisible) return;
541
+
542
+ const mul = state.labelSizeMultiplier || 1;
543
+ const logoEntry = _categoryLogos.get(region.name);
544
+ // Fall back to hardcoded OpenClaw logo if no uploaded logo
545
+ const hasLogo = (logoEntry && logoEntry.ready) || (region.name === 'openclaw' && _openclawLogoReady);
546
+ const logoImg = (logoEntry && logoEntry.ready) ? logoEntry.img : _openclawLogo;
547
+
548
+ // ── Zoom compensation: ensure labels stay readable at any zoom ──
549
+ // Capped at 2.5x to prevent labels from growing larger than the gaps between categories
550
+ const MAX_ZOOM_BOOST = isParent ? 2.5 : 2.0;
551
+ const MIN_SCREEN_PX = isParent ? 18 : 13;
552
+ const baseFontSize = isParent ? 38 : 22;
553
+ const rawFontSize = baseFontSize * mul;
554
+ const screenPx = rawFontSize * _viewport.scale / _dpr;
555
+ const zoomBoost = screenPx < MIN_SCREEN_PX
556
+ ? Math.min(MIN_SCREEN_PX / screenPx, MAX_ZOOM_BOOST)
557
+ : 1;
558
+
559
+ // Apply label opacity (for child label fade-out at zoom boundaries)
560
+ const needsOpacity = labelOpacity < 1;
561
+ if (needsOpacity) {
562
+ ctx.save();
563
+ ctx.globalAlpha = labelOpacity;
564
+ }
565
+
566
+ if (isParent) {
567
+ // ── PARENT: logo-first if available, clean text otherwise ──
568
+ const fontSize = Math.round(38 * mul * zoomBoost);
569
+ const nameText = region.name.toUpperCase();
570
+
571
+ // Subtle glow ring
572
+ ctx.save();
573
+ const glowR = 70 * mul * zoomBoost;
574
+ const grad = ctx.createRadialGradient(region.cx, region.cy, glowR * 0.3, region.cx, region.cy, glowR);
575
+ grad.addColorStop(0, hexAlpha(region.color, 0.12));
576
+ grad.addColorStop(1, hexAlpha(region.color, 0));
577
+ ctx.fillStyle = grad;
578
+ ctx.beginPath();
579
+ ctx.arc(region.cx, region.cy, glowR, 0, Math.PI * 2);
580
+ ctx.fill();
581
+ ctx.restore();
582
+
583
+ let cursorY = region.cy; // tracks vertical stacking position
584
+
585
+ if (hasLogo) {
586
+ // Logo with its own zoom compensation (independent of text zoomBoost)
587
+ // Logos are the primary identifier so they need a higher cap and their own min screen size
588
+ const BASE_LOGO_H = 64 * mul;
589
+ const MIN_LOGO_SCREEN = 32; // minimum screen pixels for parent logo
590
+ const logoScreenH = BASE_LOGO_H * _viewport.scale / _dpr;
591
+ const logoBoost = logoScreenH < MIN_LOGO_SCREEN
592
+ ? Math.min(MIN_LOGO_SCREEN / logoScreenH, 5.0)
593
+ : 1;
594
+ const logoH = Math.round(BASE_LOGO_H * logoBoost);
595
+ const aspect = (logoImg.naturalWidth || 1) / (logoImg.naturalHeight || 1);
596
+ const logoW = Math.round(logoH * aspect);
597
+ const logoY = region.cy - logoH / 2;
598
+ ctx.save();
599
+ ctx.globalAlpha = 0.92;
600
+ ctx.drawImage(logoImg,
601
+ region.cx - logoW / 2, logoY,
602
+ logoW, logoH
603
+ );
604
+ ctx.restore();
605
+ cursorY = logoY + logoH + 6;
606
+ } else {
607
+ // No logo — glass pill behind text
608
+ ctx.font = `bold ${fontSize}px 'Space Grotesk', 'Inter', sans-serif`;
609
+ ctx.textAlign = 'center';
610
+ ctx.textBaseline = 'middle';
611
+
612
+ // Measure text to size the glass pill
613
+ const textW = ctx.measureText(nameText).width;
614
+ const padX = fontSize * 0.6;
615
+ const padY = fontSize * 0.4;
616
+
617
+ // Memory count text (compute now so pill can encompass both)
618
+ const count = region.memories ? region.memories.length : 0;
619
+ const childCount = (region.children || []).reduce((sum, c) => sum + (c.memories ? c.memories.length : 0), 0);
620
+ const total = count + childCount;
621
+ const subSize = Math.round(fontSize * 0.45);
622
+ const countText = total > 0 ? `${total} ${total === 1 ? 'memory' : 'memories'}` : '';
623
+ const countH = total > 0 ? subSize + padY * 0.5 : 0;
624
+
625
+ const pillW = textW + padX * 2;
626
+ const pillH = fontSize + padY * 2 + countH;
627
+ const pillX = region.cx - pillW / 2;
628
+ const pillY = cursorY - fontSize / 2 - padY;
629
+ const pillR = Math.min(14, pillH / 2);
630
+
631
+ // Glass pill — diffuse color-tinted glassmorphic
632
+ ctx.save();
633
+ const col = region.color;
634
+
635
+ // Colored diffuse glow behind pill
636
+ ctx.shadowColor = hexAlpha(col, 0.3);
637
+ ctx.shadowBlur = 44;
638
+ _roundRect(ctx, pillX, pillY, pillW, pillH, pillR);
639
+ ctx.fillStyle = 'rgba(4, 5, 8, 0.92)';
640
+ ctx.fill();
641
+ ctx.shadowColor = 'transparent';
642
+ ctx.shadowBlur = 0;
643
+
644
+ // Color-tinted inner wash
645
+ _roundRect(ctx, pillX, pillY, pillW, pillH, pillR);
646
+ ctx.fillStyle = hexAlpha(col, 0.1);
647
+ ctx.fill();
648
+
649
+ // Top highlight edge
650
+ const hlGrad = ctx.createLinearGradient(pillX, pillY, pillX, pillY + pillH * 0.35);
651
+ hlGrad.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
652
+ hlGrad.addColorStop(1, 'rgba(255, 255, 255, 0)');
653
+ _roundRect(ctx, pillX, pillY, pillW, pillH * 0.35, pillR);
654
+ ctx.fillStyle = hlGrad;
655
+ ctx.fill();
656
+
657
+ // Color-tinted border
658
+ _roundRect(ctx, pillX, pillY, pillW, pillH, pillR);
659
+ ctx.strokeStyle = hexAlpha(col, 0.2);
660
+ ctx.lineWidth = 1;
661
+ ctx.stroke();
662
+
663
+ ctx.restore();
664
+
665
+ // Draw name text
666
+ ctx.font = `bold ${fontSize}px 'Space Grotesk', 'Inter', sans-serif`;
667
+ ctx.textAlign = 'center';
668
+ ctx.textBaseline = 'middle';
669
+ ctx.fillStyle = region.color;
670
+ ctx.fillText(nameText, region.cx, cursorY);
671
+ cursorY += fontSize * 0.5 + 4;
672
+
673
+ // Memory count inside the pill
674
+ if (total > 0) {
675
+ ctx.font = `500 ${subSize}px 'Inter', sans-serif`;
676
+ ctx.fillStyle = hexAlpha(region.color, 0.5);
677
+ ctx.fillText(countText, region.cx, cursorY + subSize * 0.7);
678
+ }
679
+ }
680
+
681
+ // Memory count below (only for logo path — text path handles it inside the pill)
682
+ if (hasLogo) {
683
+ const count = region.memories ? region.memories.length : 0;
684
+ const childCount = (region.children || []).reduce((sum, c) => sum + (c.memories ? c.memories.length : 0), 0);
685
+ const total = count + childCount;
686
+ if (total > 0) {
687
+ const fontSize2 = Math.round(38 * mul * zoomBoost);
688
+ const subSize = Math.round(fontSize2 * 0.45);
689
+ ctx.font = `500 ${subSize}px 'Inter', sans-serif`;
690
+ ctx.fillStyle = hexAlpha(region.color, 0.5);
691
+ ctx.fillText(`${total} ${total === 1 ? 'memory' : 'memories'}`, region.cx, cursorY + subSize * 0.7);
692
+ }
693
+ }
694
+
695
+ } else {
696
+ // ── CHILD: smaller, simpler ──
697
+ const fontSize = Math.round(22 * mul * zoomBoost);
698
+ const nameText = region.name.toUpperCase();
699
+
700
+ let cursorY = region.cy;
701
+
702
+ if (hasLogo) {
703
+ // Logo with its own zoom compensation (independent of text zoomBoost)
704
+ const BASE_LOGO_H = 32 * mul;
705
+ const MIN_LOGO_SCREEN = 20; // minimum screen pixels for child logo
706
+ const logoScreenH = BASE_LOGO_H * _viewport.scale / _dpr;
707
+ const logoBoost = logoScreenH < MIN_LOGO_SCREEN
708
+ ? Math.min(MIN_LOGO_SCREEN / logoScreenH, 4.0)
709
+ : 1;
710
+ const logoH = Math.round(BASE_LOGO_H * logoBoost);
711
+ const aspect = (logoImg.naturalWidth || 1) / (logoImg.naturalHeight || 1);
712
+ const logoW = Math.round(logoH * aspect);
713
+ const logoY = region.cy - logoH / 2;
714
+ ctx.save();
715
+ ctx.globalAlpha = 0.75;
716
+ ctx.drawImage(logoImg,
717
+ region.cx - logoW / 2, logoY,
718
+ logoW, logoH
719
+ );
720
+ ctx.restore();
721
+ cursorY = logoY + logoH + 4;
722
+ } else {
723
+ // No logo — glass pill behind text
724
+ ctx.font = `600 ${fontSize}px 'Space Grotesk', 'Inter', sans-serif`;
725
+ ctx.textAlign = 'center';
726
+ ctx.textBaseline = 'middle';
727
+
728
+ const textW = ctx.measureText(nameText).width;
729
+ const padX = fontSize * 0.5;
730
+ const padY = fontSize * 0.35;
731
+
732
+ // Count text
733
+ const count = region.memories ? region.memories.length : 0;
734
+ const subSize = Math.round(fontSize * 0.5);
735
+ const countH = count > 0 ? subSize + padY * 0.4 : 0;
736
+
737
+ const pillW = textW + padX * 2;
738
+ const pillH = fontSize + padY * 2 + countH;
739
+ const pillX = region.cx - pillW / 2;
740
+ const pillY = cursorY - fontSize / 2 - padY;
741
+ const pillR = Math.min(10, pillH / 2);
742
+
743
+ // Glass pill — diffuse color-tinted glassmorphic
744
+ ctx.save();
745
+ const col = region.color;
746
+
747
+ // Colored diffuse glow
748
+ ctx.shadowColor = hexAlpha(col, 0.25);
749
+ ctx.shadowBlur = 30;
750
+ _roundRect(ctx, pillX, pillY, pillW, pillH, pillR);
751
+ ctx.fillStyle = 'rgba(4, 5, 8, 0.88)';
752
+ ctx.fill();
753
+ ctx.shadowColor = 'transparent';
754
+ ctx.shadowBlur = 0;
755
+
756
+ // Color-tinted inner wash
757
+ _roundRect(ctx, pillX, pillY, pillW, pillH, pillR);
758
+ ctx.fillStyle = hexAlpha(col, 0.08);
759
+ ctx.fill();
760
+
761
+ // Color-tinted border
762
+ _roundRect(ctx, pillX, pillY, pillW, pillH, pillR);
763
+ ctx.strokeStyle = hexAlpha(col, 0.18);
764
+ ctx.lineWidth = 1;
765
+ ctx.stroke();
766
+ ctx.restore();
767
+
768
+ // Name text
769
+ ctx.font = `600 ${fontSize}px 'Space Grotesk', 'Inter', sans-serif`;
770
+ ctx.textAlign = 'center';
771
+ ctx.textBaseline = 'middle';
772
+ ctx.fillStyle = hexAlpha(region.color, 0.8);
773
+ ctx.fillText(nameText, region.cx, cursorY);
774
+ cursorY += fontSize * 0.5 + 4;
775
+
776
+ // Count inside pill
777
+ if (count > 0) {
778
+ ctx.font = `500 ${subSize}px 'Inter', sans-serif`;
779
+ ctx.fillStyle = hexAlpha(region.color, 0.4);
780
+ ctx.fillText(`${count}`, region.cx, cursorY + subSize * 0.6);
781
+ }
782
+ }
783
+
784
+ // Memory count (only for logo path)
785
+ if (hasLogo) {
786
+ const count = region.memories ? region.memories.length : 0;
787
+ if (count > 0) {
788
+ const subSize = Math.round(fontSize * 0.5);
789
+ ctx.font = `500 ${subSize}px 'Inter', sans-serif`;
790
+ ctx.fillStyle = hexAlpha(region.color, 0.4);
791
+ ctx.fillText(`${count}`, region.cx, cursorY + subSize * 0.6);
792
+ }
793
+ }
794
+ }
795
+
796
+ if (needsOpacity) ctx.restore();
797
+ }
798
+
799
+
800
+ // Pre-computed card background fill style (used by LOD 0, 1, and 2 drawing functions)
801
+ const _cardBgFill = `rgba(15, 18, 25, ${gfx.cardOpacity})`;
802
+
803
+
804
+ // ═══════════════════════════════════════════
805
+ // DRAWING: LOD 2 — Dots (zoomed far out)
806
+ // ═══════════════════════════════════════════
807
+
808
+ /**
809
+ * Draw all visible cards as tiny colored dots, batched by color.
810
+ * At LOD 2, cards are ~2px circles — no text, no shadows, no borders.
811
+ * Groups by category color for minimal state changes (one fillStyle + fill per color).
812
+ */
813
+ function _drawCardDots(ctx, vb) {
814
+ const hasSearch = state.searchResults !== null;
815
+ // Group visible cards by color
816
+ const byColor = new Map();
817
+ for (const card of _cards) {
818
+ if (card.x < vb.left || card.x > vb.right ||
819
+ card.y < vb.top || card.y > vb.bottom) continue;
820
+ // Skip non-matching cards during search
821
+ if (hasSearch && !state.searchResults.has(card.node.id)) continue;
822
+ const color = catColor(card.node.payload?.category || '');
823
+ let list = byColor.get(color);
824
+ if (!list) { list = []; byColor.set(color, list); }
825
+ list.push(card);
826
+ }
827
+
828
+ const TWO_PI = Math.PI * 2;
829
+ for (const [color, cards] of byColor) {
830
+ ctx.fillStyle = color;
831
+ ctx.beginPath();
832
+ for (const card of cards) {
833
+ const dotR = hasSearch && state.searchResults.has(card.node.id) ? 5 : 3;
834
+ ctx.moveTo(card.x + dotR, card.y);
835
+ ctx.arc(card.x, card.y, dotR, 0, TWO_PI);
836
+ }
837
+ ctx.fill();
838
+ }
839
+ }
840
+
841
+
842
+ // ═══════════════════════════════════════════
843
+ // DRAWING: LOD 1 — Simple Rects (medium zoom)
844
+ // ═══════════════════════════════════════════
845
+
846
+ /**
847
+ * Draw visible cards as simple colored rectangles — no text, no shadows, no font rendering.
848
+ * Much faster than full cards but still shows shape and color coding.
849
+ */
850
+ function _drawCardSimple(ctx, vb) {
851
+ const halfW = CARD_W / 2;
852
+ const halfH = CARD_H / 2;
853
+ const selectedId = state.selectedNodeId;
854
+ const hasSearch = state.searchResults !== null;
855
+
856
+ for (const card of _cards) {
857
+ if (card.x + halfW < vb.left || card.x - halfW > vb.right ||
858
+ card.y + halfH < vb.top || card.y - halfH > vb.bottom) continue;
859
+ // Skip non-matching cards during search
860
+ if (hasSearch && !state.searchResults.has(card.node.id)) continue;
861
+
862
+ const isMatch = hasSearch && state.searchResults.has(card.node.id);
863
+ const scale = isMatch ? 1.3 : 1;
864
+ const w = CARD_W * scale;
865
+ const h = CARD_H * scale;
866
+ const x = card.x - w / 2;
867
+ const y = card.y - h / 2;
868
+ const color = catColor(card.node.payload?.category || '');
869
+
870
+ // Card background
871
+ ctx.fillStyle = _cardBgFill;
872
+ ctx.fillRect(x, y, w, h);
873
+
874
+ // Left color bar (thicker at this LOD for visibility)
875
+ ctx.fillStyle = color;
876
+ ctx.fillRect(x, y, 4, h);
877
+
878
+ // Selection or search highlight
879
+ if (card.node.id === selectedId || isMatch) {
880
+ ctx.strokeStyle = color;
881
+ ctx.lineWidth = isMatch ? 1.5 : 2;
882
+ ctx.strokeRect(x, y, w, h);
883
+ }
884
+ }
885
+ }
886
+
887
+
888
+ // ═══════════════════════════════════════════
889
+ // DRAWING: LOD 0 — Full Detail Cards
890
+ // ═══════════════════════════════════════════
891
+
892
+ function _drawCard(ctx, card) {
893
+ const node = card.node;
894
+ const cat = node.payload?.category || '';
895
+ const color = catColor(cat);
896
+ const importance = node.payload?.importance || 5;
897
+ const content = node.payload?.content || '';
898
+ const isSelected = node.id === state.selectedNodeId;
899
+ const isHovered = node.id === state.hoveredNodeId;
900
+ const isMultiSelected = state.multiSelected.has(node.id);
901
+
902
+ // Opacity for search/focus dimming — use pre-built neighbor map (O(1) lookup)
903
+ let opacity = 1;
904
+ const isSearchMatch = state.searchResults && state.searchResults.has(node.id);
905
+ const isSearchMiss = state.searchResults && !isSearchMatch;
906
+ if (isSearchMiss) return; // Vanish non-matching memories during search
907
+ if (isSearchMatch) opacity = 1; // Full brightness for matches
908
+ if (state.focusedNodeId && state.focusedNodeId !== node.id) {
909
+ const neighbors = _neighborMap.get(state.focusedNodeId);
910
+ if (neighbors && neighbors.has(node.id)) opacity = 0.7;
911
+ else opacity = 0.15;
912
+ }
913
+ if (opacity < 0.05) return;
914
+
915
+ // Search matches get scaled up
916
+ const searchScale = isSearchMatch ? 1.3 : 1;
917
+ const scaledW = CARD_W * searchScale;
918
+ const scaledH = CARD_H * searchScale;
919
+ const x = card.x - scaledW / 2;
920
+ const y = card.y - scaledH / 2;
921
+ const needsShadow = isHovered || isSelected || isMultiSelected || isSearchMatch;
922
+
923
+ // Only save/restore when we actually change global state
924
+ if (opacity < 1 || needsShadow) {
925
+ ctx.save();
926
+ ctx.globalAlpha = opacity;
927
+ }
928
+
929
+ // ── Hover glow (shadow) ──
930
+ if (isSearchMatch && !isHovered && !isSelected) {
931
+ ctx.shadowColor = hexAlpha(color, 0.7);
932
+ ctx.shadowBlur = 14;
933
+ } else if (needsShadow) {
934
+ ctx.shadowColor = isSelected ? color : hexAlpha(color, 0.6);
935
+ ctx.shadowBlur = isSelected ? 16 : 10;
936
+ }
937
+
938
+ // ── Card background ──
939
+ _roundRect(ctx, x, y, scaledW, scaledH, 8);
940
+ ctx.fillStyle = _cardBgFill;
941
+ ctx.fill();
942
+
943
+ // Reset shadow after fill (only if set)
944
+ if (needsShadow) {
945
+ ctx.shadowColor = 'transparent';
946
+ ctx.shadowBlur = 0;
947
+ }
948
+
949
+ // ── Selection/hover border ──
950
+ if (isSelected || isMultiSelected) {
951
+ _roundRect(ctx, x, y, scaledW, scaledH, 8);
952
+ ctx.strokeStyle = color;
953
+ ctx.lineWidth = 1.5;
954
+ ctx.stroke();
955
+ } else if (isHovered) {
956
+ _roundRect(ctx, x, y, scaledW, scaledH, 8);
957
+ ctx.strokeStyle = hexAlpha(color, 0.4);
958
+ ctx.lineWidth = 1;
959
+ ctx.stroke();
960
+ } else if (isSearchMatch) {
961
+ _roundRect(ctx, x, y, scaledW, scaledH, 8);
962
+ ctx.strokeStyle = hexAlpha(color, 0.5);
963
+ ctx.lineWidth = 1;
964
+ ctx.stroke();
965
+ }
966
+
967
+ // ── Left color bar (simplified — no clip needed for 3px bar) ──
968
+ ctx.fillStyle = color;
969
+ ctx.fillRect(x, y + 8, 3, scaledH - 16);
970
+
971
+ // ── Category label ──
972
+ ctx.font = "bold 8px 'Inter', sans-serif";
973
+ ctx.fillStyle = color;
974
+ ctx.textAlign = 'left';
975
+ ctx.textBaseline = 'top';
976
+ ctx.fillText(cat.toUpperCase(), x + 8, y + 6);
977
+
978
+ // ── Content preview (cached text wrapping) ──
979
+ ctx.font = "10px 'Inter', sans-serif";
980
+ ctx.fillStyle = 'rgba(200, 206, 220, 0.85)';
981
+ const lines = _getWrappedText(ctx, node.id, content, scaledW - 16, 3);
982
+ for (let i = 0; i < lines.length; i++) {
983
+ ctx.fillText(lines[i], x + 8, y + 20 + i * 13);
984
+ }
985
+
986
+ // ── Importance stars ──
987
+ if (importance > 0) {
988
+ ctx.font = "8px 'Inter', sans-serif";
989
+ ctx.fillStyle = 'rgba(255, 200, 50, 0.6)';
990
+ ctx.textAlign = 'right';
991
+ ctx.textBaseline = 'bottom';
992
+ const stars = importance <= 5 ? '\u2606'.repeat(importance) : '\u2605'.repeat(Math.min(importance, 10));
993
+ ctx.fillText(stars, x + scaledW - 6, y + scaledH - 4);
994
+ }
995
+
996
+ // ── Pin indicator ──
997
+ if (card.pinned) {
998
+ ctx.font = "9px sans-serif";
999
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
1000
+ ctx.textAlign = 'right';
1001
+ ctx.textBaseline = 'top';
1002
+ ctx.fillText('\uD83D\uDCCC', x + scaledW - 4, y + 4);
1003
+ }
1004
+
1005
+ if (opacity < 1 || needsShadow) {
1006
+ ctx.restore();
1007
+ }
1008
+ }
1009
+
1010
+ /**
1011
+ * Get cached wrapped text lines for a card.
1012
+ * Only re-wraps when content changes.
1013
+ */
1014
+ function _getWrappedText(ctx, nodeId, content, maxWidth, maxLines) {
1015
+ const cached = _textCache.get(nodeId);
1016
+ if (cached && cached.content === content) return cached.lines;
1017
+ const lines = _wrapText(ctx, content, maxWidth, maxLines);
1018
+ _textCache.set(nodeId, { content, lines });
1019
+ return lines;
1020
+ }
1021
+
1022
+
1023
+ // ═══════════════════════════════════════════
1024
+ // DRAWING: Links
1025
+ // ═══════════════════════════════════════════
1026
+
1027
+ function _drawLinks(ctx, vb, lod) {
1028
+ // LOD 1: only draw strong links (strength >= 0.4), reduced opacity
1029
+ const links = lod >= 1 ? _resolvedLinksStrong : _resolvedLinks;
1030
+ const opacity = lod >= 1 ? gfx.linkOpacity * 0.5 : gfx.linkOpacity;
1031
+ ctx.strokeStyle = `rgba(100, 140, 200, ${opacity})`;
1032
+ ctx.lineWidth = 0.5;
1033
+
1034
+ ctx.beginPath();
1035
+ const isIntra = state.linkMode === 'intra';
1036
+ const hasSearch = state.searchResults !== null;
1037
+
1038
+ for (let i = 0, len = links.length; i < len; i++) {
1039
+ const link = links[i];
1040
+ const sc = link.sourceCard;
1041
+ const tc = link.targetCard;
1042
+
1043
+ // Viewport cull: skip if both endpoints are off-screen
1044
+ const sx = sc.x, sy = sc.y, tx = tc.x, ty = tc.y;
1045
+ if ((sx < vb.left && tx < vb.left) || (sx > vb.right && tx > vb.right) ||
1046
+ (sy < vb.top && ty < vb.top) || (sy > vb.bottom && ty > vb.bottom)) continue;
1047
+
1048
+ // Filter by link mode
1049
+ if (isIntra && link.crossCat) continue;
1050
+
1051
+ // Hide links where both endpoints are not search matches
1052
+ if (hasSearch && !state.searchResults.has(sc.node.id) && !state.searchResults.has(tc.node.id)) continue;
1053
+
1054
+ ctx.moveTo(sx, sy);
1055
+ ctx.lineTo(tx, ty);
1056
+ }
1057
+ ctx.stroke();
1058
+ }
1059
+
1060
+
1061
+ // ═══════════════════════════════════════════
1062
+ // DRAWING HELPERS
1063
+ // ═══════════════════════════════════════════
1064
+
1065
+ function _roundRect(ctx, x, y, w, h, r) {
1066
+ ctx.beginPath();
1067
+ ctx.moveTo(x + r, y);
1068
+ ctx.lineTo(x + w - r, y);
1069
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
1070
+ ctx.lineTo(x + w, y + h - r);
1071
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
1072
+ ctx.lineTo(x + r, y + h);
1073
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
1074
+ ctx.lineTo(x, y + r);
1075
+ ctx.quadraticCurveTo(x, y, x + r, y);
1076
+ ctx.closePath();
1077
+ }
1078
+
1079
+ function _wrapText(ctx, text, maxWidth, maxLines) {
1080
+ const words = (text || '').replace(/\n/g, ' ').split(' ');
1081
+ const lines = [];
1082
+ let line = '';
1083
+ for (const word of words) {
1084
+ const test = line + (line ? ' ' : '') + word;
1085
+ if (ctx.measureText(test).width > maxWidth && line) {
1086
+ lines.push(line);
1087
+ if (lines.length >= maxLines) {
1088
+ lines[lines.length - 1] = lines[lines.length - 1].replace(/...$/, '') + '\u2026';
1089
+ return lines;
1090
+ }
1091
+ line = word;
1092
+ } else {
1093
+ line = test;
1094
+ }
1095
+ }
1096
+ if (line) {
1097
+ if (lines.length >= maxLines) {
1098
+ lines[lines.length - 1] += '\u2026';
1099
+ } else {
1100
+ lines.push(line);
1101
+ }
1102
+ }
1103
+ return lines;
1104
+ }
1105
+
1106
+
1107
+ // ═══════════════════════════════════════════
1108
+ // HIT DETECTION
1109
+ // ═══════════════════════════════════════════
1110
+
1111
+ function _hitTest(screenX, screenY) {
1112
+ const { x, y } = screenToWorld(screenX, screenY);
1113
+
1114
+ // Check cards (reverse order = topmost first)
1115
+ for (let i = _cards.length - 1; i >= 0; i--) {
1116
+ const card = _cards[i];
1117
+ if (x >= card.x - CARD_W / 2 && x <= card.x + CARD_W / 2 &&
1118
+ y >= card.y - CARD_H / 2 && y <= card.y + CARD_H / 2) {
1119
+ return { type: 'card', item: card, node: card.node };
1120
+ }
1121
+ }
1122
+
1123
+ // Check category labels — hit zone scales with label size + zoom compensation
1124
+ const allRegions = flattenRegions(_regions);
1125
+ const mul = state.labelSizeMultiplier || 1;
1126
+ for (const region of allRegions) {
1127
+ if (!state.labelsVisible) {
1128
+ // Labels hidden — small dot hit zone so categories are still clickable
1129
+ if (x >= region.cx - 30 && x <= region.cx + 30 &&
1130
+ y >= region.cy - 30 && y <= region.cy + 30) {
1131
+ return { type: 'region', item: region };
1132
+ }
1133
+ } else {
1134
+ // Compute zoom compensation to match rendered size (capped, same as _drawCategoryLabel)
1135
+ const MAX_ZOOM_BOOST = region.isParent ? 2.5 : 2.0;
1136
+ const baseFontSize = region.isParent ? 38 : 22;
1137
+ const MIN_SCREEN_PX = region.isParent ? 18 : 13;
1138
+ const rawFontSize = baseFontSize * mul;
1139
+ const screenPx = rawFontSize * _viewport.scale / _dpr;
1140
+ const zoomBoost = screenPx < MIN_SCREEN_PX
1141
+ ? Math.min(MIN_SCREEN_PX / screenPx, MAX_ZOOM_BOOST)
1142
+ : 1;
1143
+
1144
+ const fontSize = Math.round(baseFontSize * mul * zoomBoost);
1145
+ const hasLogo = _categoryLogos.get(region.name)?.ready || (region.name === 'openclaw' && _openclawLogoReady);
1146
+
1147
+ // Hit zone: logo width or text width + padding
1148
+ let labelW, labelH;
1149
+ if (hasLogo) {
1150
+ // Logo has its own zoom compensation (same as _drawCategoryLabel)
1151
+ const baseLogo = (region.isParent ? 64 : 32) * mul;
1152
+ const minLogoScreen = region.isParent ? 32 : 20;
1153
+ const logoScreen = baseLogo * _viewport.scale / _dpr;
1154
+ const logoBoost = logoScreen < minLogoScreen
1155
+ ? Math.min(minLogoScreen / logoScreen, region.isParent ? 5.0 : 4.0)
1156
+ : 1;
1157
+ const logoH = baseLogo * logoBoost;
1158
+ const logoW = logoH * 3; // conservative aspect estimate
1159
+ labelW = Math.max(logoW, 160) + 20;
1160
+ labelH = logoH + fontSize * 0.5 + 30;
1161
+ } else {
1162
+ // Match glass pill sizing: text width + padX on each side, fontSize + padY*2 + count height
1163
+ const nameText = region.name.toUpperCase();
1164
+ const approxTextW = fontSize * 0.65 * nameText.length;
1165
+ const padX = fontSize * (region.isParent ? 0.6 : 0.5);
1166
+ const padY = fontSize * (region.isParent ? 0.4 : 0.35);
1167
+ const countH = fontSize * 0.5; // always include count space for hit zone
1168
+ labelW = approxTextW + padX * 2 + 20; // extra margin for easier clicking
1169
+ labelH = fontSize + padY * 2 + countH + 20;
1170
+ }
1171
+ if (x >= region.cx - labelW / 2 && x <= region.cx + labelW / 2 &&
1172
+ y >= region.cy - labelH / 2 && y <= region.cy + labelH / 2) {
1173
+ return { type: 'region', item: region };
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ return null;
1179
+ }
1180
+
1181
+
1182
+ // ═══════════════════════════════════════════
1183
+ // DRAG SYSTEM
1184
+ // ═══════════════════════════════════════════
1185
+
1186
+ function _buildDragSet(hit) {
1187
+ const set = [];
1188
+
1189
+ if (hit.type === 'card') {
1190
+ // If this card is part of a multi-selection, drag ALL selected cards
1191
+ if (state.multiSelected.size > 0 && state.multiSelected.has(hit.node.id)) {
1192
+ for (const card of _cards) {
1193
+ if (state.multiSelected.has(card.node.id)) {
1194
+ set.push({ type: 'card', ref: card });
1195
+ }
1196
+ }
1197
+ } else {
1198
+ // Just this card
1199
+ set.push({ type: 'card', ref: hit.item });
1200
+ }
1201
+ } else if (hit.type === 'region') {
1202
+ const region = hit.item;
1203
+ // The region label itself
1204
+ set.push({ type: 'region', ref: region });
1205
+
1206
+ // All cards directly on this region
1207
+ for (const card of _cards) {
1208
+ const cat = card.node.payload?.category;
1209
+ if (cat === region.name) {
1210
+ set.push({ type: 'card', ref: card });
1211
+ }
1212
+ }
1213
+
1214
+ // If parent, also include children + their cards
1215
+ if (region.isParent && region.children) {
1216
+ for (const child of region.children) {
1217
+ set.push({ type: 'region', ref: child });
1218
+ for (const card of _cards) {
1219
+ const cat = card.node.payload?.category;
1220
+ if (cat === child.name) {
1221
+ set.push({ type: 'card', ref: card });
1222
+ }
1223
+ }
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ return set;
1229
+ }
1230
+
1231
+ function _applyDragDelta(dx, dy) {
1232
+ for (const item of _pointer.dragSet) {
1233
+ if (item.type === 'card') {
1234
+ item.ref.x += dx;
1235
+ item.ref.y += dy;
1236
+ item.ref.pinned = true;
1237
+ } else if (item.type === 'region') {
1238
+ item.ref.cx += dx;
1239
+ item.ref.cy += dy;
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+
1245
+ // ═══════════════════════════════════════════
1246
+ // POSITION PERSISTENCE
1247
+ // ═══════════════════════════════════════════
1248
+
1249
+ function _savePositions() {
1250
+ const positions = {};
1251
+ for (const card of _cards) {
1252
+ positions[card.node.id] = { x: card.x, y: card.y, pinned: card.pinned };
1253
+ }
1254
+ for (const region of flattenRegions(_regions)) {
1255
+ positions[`_cat_${region.name}`] = { x: region.cx, y: region.cy };
1256
+ }
1257
+ try { storage.setItem(KEYS.NODE_POS_2D, JSON.stringify(positions)); } catch (_) {}
1258
+ }
1259
+
1260
+ function _schedulePositionSave() {
1261
+ if (_posSaveTimer) clearTimeout(_posSaveTimer);
1262
+ _posSaveTimer = setTimeout(_savePositions, 2000);
1263
+ }
1264
+
1265
+
1266
+ // ═══════════════════════════════════════════
1267
+ // EVENT HANDLERS
1268
+ // ═══════════════════════════════════════════
1269
+
1270
+ function _onPointerDown(e) {
1271
+ if (_focusPaused || e.button !== 0) return;
1272
+ // Skip if Shift (lasso mode handled by lasso.js)
1273
+ if (e.shiftKey) return;
1274
+
1275
+ const hit = _hitTest(e.clientX, e.clientY);
1276
+
1277
+ _pointer.downScreenX = e.clientX;
1278
+ _pointer.downScreenY = e.clientY;
1279
+ _pointer.lastScreenX = e.clientX;
1280
+ _pointer.lastScreenY = e.clientY;
1281
+ _pointer.downTime = performance.now();
1282
+ _pointer.lastMoveTime = performance.now();
1283
+ _pointer.hasMoved = false;
1284
+ _panInertia.active = false; // stop coasting on new interaction
1285
+
1286
+ if (hit) {
1287
+ _pointer.isDragging = true;
1288
+ _pointer.isPanning = false;
1289
+ _pointer.dragTarget = hit;
1290
+ _pointer.dragSet = _buildDragSet(hit);
1291
+ _pointer.dragStartWorld = screenToWorld(e.clientX, e.clientY);
1292
+ _canvas.setPointerCapture(e.pointerId);
1293
+ } else {
1294
+ _pointer.isPanning = true;
1295
+ _pointer.isDragging = false;
1296
+ _pointer.dragTarget = null;
1297
+ _canvas.setPointerCapture(e.pointerId);
1298
+ }
1299
+ }
1300
+
1301
+ const DRAG_THRESHOLD = 5; // pixels before drag/pan starts
1302
+
1303
+ function _onPointerMove(e) {
1304
+ if (_focusPaused) return;
1305
+ // Check if we've exceeded the drag threshold before actually moving
1306
+ if ((_pointer.isPanning || _pointer.isDragging) && !_pointer.hasMoved) {
1307
+ const tdx = e.clientX - _pointer.downScreenX;
1308
+ const tdy = e.clientY - _pointer.downScreenY;
1309
+ if (Math.abs(tdx) < DRAG_THRESHOLD && Math.abs(tdy) < DRAG_THRESHOLD) return;
1310
+ _pointer.hasMoved = true;
1311
+ }
1312
+
1313
+ if (_pointer.isPanning) {
1314
+ const dx = (e.clientX - _pointer.lastScreenX) * _dpr;
1315
+ const dy = (e.clientY - _pointer.lastScreenY) * _dpr;
1316
+ _viewport.offsetX += dx;
1317
+ _viewport.offsetY += dy;
1318
+ // Track velocity for inertia
1319
+ const now = performance.now();
1320
+ const dt = now - _pointer.lastMoveTime || 16;
1321
+ _panInertia.vx = dx / dt * 16; // normalize to ~60fps frame
1322
+ _panInertia.vy = dy / dt * 16;
1323
+ _pointer.lastMoveTime = now;
1324
+ _pointer.lastScreenX = e.clientX;
1325
+ _pointer.lastScreenY = e.clientY;
1326
+ // Kill any active inertia while dragging
1327
+ _panInertia.active = false;
1328
+ } else if (_pointer.isDragging) {
1329
+ const worldNow = screenToWorld(e.clientX, e.clientY);
1330
+ const worldPrev = screenToWorld(_pointer.lastScreenX, _pointer.lastScreenY);
1331
+ const dx = worldNow.x - worldPrev.x;
1332
+ const dy = worldNow.y - worldPrev.y;
1333
+ _applyDragDelta(dx, dy);
1334
+ _pointer.lastScreenX = e.clientX;
1335
+ _pointer.lastScreenY = e.clientY;
1336
+ } else {
1337
+ // Hover detection
1338
+ const hit = _hitTest(e.clientX, e.clientY);
1339
+ const newHoverId = (hit && hit.type === 'card') ? hit.node.id : null;
1340
+ if (newHoverId !== state.hoveredNodeId) {
1341
+ state.hoveredNodeId = newHoverId;
1342
+ }
1343
+ // Update cursor for both cards and regions
1344
+ _canvas.style.cursor = hit ? 'pointer' : 'grab';
1345
+ if (newHoverId) emit('node-hover', hit.node);
1346
+ else if (!hit) emit('node-hover', null);
1347
+ }
1348
+ }
1349
+
1350
+ function _onPointerUp(e) {
1351
+ if (_focusPaused) return;
1352
+ const wasDragging = _pointer.isDragging;
1353
+ const wasPanning = _pointer.isPanning;
1354
+ const moved = _pointer.hasMoved;
1355
+ const elapsed = performance.now() - _pointer.downTime;
1356
+
1357
+ _pointer.isPanning = false;
1358
+ _pointer.isDragging = false;
1359
+ _canvas.releasePointerCapture(e.pointerId);
1360
+
1361
+ // Start pan inertia if we were panning and had velocity
1362
+ if (wasPanning && moved) {
1363
+ const speed = Math.sqrt(_panInertia.vx ** 2 + _panInertia.vy ** 2);
1364
+ if (speed > 0.5) {
1365
+ _panInertia.active = true;
1366
+ }
1367
+ return;
1368
+ }
1369
+
1370
+ if (wasDragging && moved) {
1371
+ _schedulePositionSave();
1372
+ return;
1373
+ }
1374
+
1375
+ // Click (no significant movement)
1376
+ if (!moved || elapsed < 200) {
1377
+ const hit = _hitTest(e.clientX, e.clientY);
1378
+
1379
+ if (hit && hit.type === 'card') {
1380
+ // Ctrl/Cmd+Click: toggle multi-select (same as 3D variant)
1381
+ if (e.ctrlKey || e.metaKey) {
1382
+ if (state.multiSelected.has(hit.node.id)) {
1383
+ state.multiSelected.delete(hit.node.id);
1384
+ } else {
1385
+ state.multiSelected.add(hit.node.id);
1386
+ }
1387
+ emit('multiselect:update');
1388
+ return;
1389
+ }
1390
+ // Regular click: clear multi-select, select node
1391
+ if (state.multiSelected.size > 0) {
1392
+ state.multiSelected.clear();
1393
+ emit('multiselect:update');
1394
+ }
1395
+ state.selectedNodeId = hit.node.id;
1396
+ emit('node-selected', hit.node);
1397
+ } else if (hit && hit.type === 'region') {
1398
+ // Zoom to fit this category region
1399
+ _zoomToRegion(hit.item);
1400
+ } else {
1401
+ // Click background — deselect everything
1402
+ if (state.multiSelected.size > 0) {
1403
+ state.multiSelected.clear();
1404
+ emit('multiselect:update');
1405
+ }
1406
+ state.selectedNodeId = null;
1407
+ emit('background-click');
1408
+ emit('node-selected', null);
1409
+ }
1410
+ }
1411
+ }
1412
+
1413
+ function _onPointerLeave() {
1414
+ if (_focusPaused) return;
1415
+ if (state.hoveredNodeId) {
1416
+ state.hoveredNodeId = null;
1417
+ _canvas.style.cursor = 'grab';
1418
+ emit('node-hover', null);
1419
+ }
1420
+ }
1421
+
1422
+ function _onWheel(e) {
1423
+ if (_focusPaused) return;
1424
+ e.preventDefault();
1425
+ // Accumulate zoom target — each tick multiplies by factor
1426
+ const zoomFactor = e.deltaY > 0 ? 0.88 : 1.12;
1427
+ _smoothZoom.targetScale = Math.max(
1428
+ _viewport.minScale,
1429
+ Math.min(_viewport.maxScale, (_smoothZoom.active ? _smoothZoom.targetScale : _viewport.scale) * zoomFactor)
1430
+ );
1431
+ // Update anchor to cursor position (buffer coords)
1432
+ _smoothZoom.anchorX = e.clientX * _dpr;
1433
+ _smoothZoom.anchorY = e.clientY * _dpr;
1434
+ _smoothZoom.active = true;
1435
+ }
1436
+
1437
+ function _onDblClick(e) {
1438
+ if (_focusPaused) return;
1439
+ const hit = _hitTest(e.clientX, e.clientY);
1440
+ if (hit && hit.type === 'card') {
1441
+ // Unpin card
1442
+ hit.item.pinned = false;
1443
+ _schedulePositionSave();
1444
+ } else {
1445
+ // Zoom to fit all
1446
+ _zoomToFit(400, 60);
1447
+ }
1448
+ }
1449
+
1450
+ function _onContextMenu(e) {
1451
+ if (_focusPaused) { e.preventDefault(); return; }
1452
+ e.preventDefault();
1453
+ const hit = _hitTest(e.clientX, e.clientY);
1454
+ if (hit && hit.type === 'card') {
1455
+ emit('node-context-menu', { node: hit.node, event: e });
1456
+ }
1457
+ }
1458
+
1459
+
1460
+ // ═══════════════════════════════════════════
1461
+ // VIEWPORT NAVIGATION
1462
+ // ═══════════════════════════════════════════
1463
+
1464
+ function _centerAt(worldX, worldY, duration = 0) {
1465
+ const targetOffsetX = _canvas.width / 2 - worldX * _viewport.scale;
1466
+ const targetOffsetY = _canvas.height / 2 - worldY * _viewport.scale;
1467
+
1468
+ if (duration <= 0) {
1469
+ _viewport.offsetX = targetOffsetX;
1470
+ _viewport.offsetY = targetOffsetY;
1471
+ return;
1472
+ }
1473
+
1474
+ _animFrom = {
1475
+ offsetX: _viewport.offsetX,
1476
+ offsetY: _viewport.offsetY,
1477
+ scale: _viewport.scale,
1478
+ };
1479
+ _animTo = {
1480
+ offsetX: targetOffsetX,
1481
+ offsetY: targetOffsetY,
1482
+ scale: _viewport.scale,
1483
+ };
1484
+ _animStart = performance.now();
1485
+ _animDuration = duration;
1486
+ _animating = true;
1487
+ }
1488
+
1489
+ function _zoomToFit(duration = 400, padding = 60) {
1490
+ if (_cards.length === 0) return;
1491
+
1492
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1493
+ for (const card of _cards) {
1494
+ if (card.x - CARD_W / 2 < minX) minX = card.x - CARD_W / 2;
1495
+ if (card.x + CARD_W / 2 > maxX) maxX = card.x + CARD_W / 2;
1496
+ if (card.y - CARD_H / 2 < minY) minY = card.y - CARD_H / 2;
1497
+ if (card.y + CARD_H / 2 > maxY) maxY = card.y + CARD_H / 2;
1498
+ }
1499
+
1500
+ const rangeX = (maxX - minX) || 1;
1501
+ const rangeY = (maxY - minY) || 1;
1502
+ const centerX = (minX + maxX) / 2;
1503
+ const centerY = (minY + maxY) / 2;
1504
+
1505
+ const scaledPadding = padding * _dpr;
1506
+ const availW = _canvas.width - scaledPadding * 2;
1507
+ const availH = _canvas.height - scaledPadding * 2;
1508
+ const newScale = Math.min(availW / rangeX, availH / rangeY);
1509
+ const clampedScale = Math.max(_viewport.minScale, Math.min(_viewport.maxScale, newScale));
1510
+
1511
+ if (duration <= 0) {
1512
+ _viewport.scale = clampedScale;
1513
+ _viewport.offsetX = _canvas.width / 2 - centerX * clampedScale;
1514
+ _viewport.offsetY = _canvas.height / 2 - centerY * clampedScale;
1515
+ return;
1516
+ }
1517
+
1518
+ _animFrom = {
1519
+ offsetX: _viewport.offsetX,
1520
+ offsetY: _viewport.offsetY,
1521
+ scale: _viewport.scale,
1522
+ };
1523
+ _animTo = {
1524
+ offsetX: _canvas.width / 2 - centerX * clampedScale,
1525
+ offsetY: _canvas.height / 2 - centerY * clampedScale,
1526
+ scale: clampedScale,
1527
+ };
1528
+ _animStart = performance.now();
1529
+ _animDuration = duration;
1530
+ _animating = true;
1531
+ }
1532
+
1533
+ function _zoomToRegion(region, duration = 400) {
1534
+ const padding = 80 * _dpr;
1535
+ const regionR = region.radius || 200;
1536
+
1537
+ const rangeX = regionR * 2;
1538
+ const rangeY = regionR * 2;
1539
+
1540
+ const availW = _canvas.width - padding * 2;
1541
+ const availH = _canvas.height - padding * 2;
1542
+ const newScale = Math.min(availW / rangeX, availH / rangeY);
1543
+ const clampedScale = Math.max(_viewport.minScale, Math.min(_viewport.maxScale, newScale));
1544
+
1545
+ _animFrom = {
1546
+ offsetX: _viewport.offsetX,
1547
+ offsetY: _viewport.offsetY,
1548
+ scale: _viewport.scale,
1549
+ };
1550
+ _animTo = {
1551
+ offsetX: _canvas.width / 2 - region.cx * clampedScale,
1552
+ offsetY: _canvas.height / 2 - region.cy * clampedScale,
1553
+ scale: clampedScale,
1554
+ };
1555
+ _animStart = performance.now();
1556
+ _animDuration = duration;
1557
+ _animating = true;
1558
+ }
1559
+
1560
+
1561
+ // ═══════════════════════════════════════════
1562
+ // RESIZE
1563
+ // ═══════════════════════════════════════════
1564
+
1565
+ function _resizeCanvas() {
1566
+ if (!_canvas) return;
1567
+ _dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
1568
+ const w = window.innerWidth;
1569
+ const h = window.innerHeight;
1570
+ // Set canvas buffer size to DPR-scaled resolution for crisp rendering
1571
+ _canvas.width = Math.round(w * _dpr);
1572
+ _canvas.height = Math.round(h * _dpr);
1573
+ // CSS display size stays at viewport dimensions
1574
+ _canvas.style.width = w + 'px';
1575
+ _canvas.style.height = h + 'px';
1576
+ }
1577
+
1578
+
1579
+ // ═══════════════════════════════════════════
1580
+ // PUBLIC API SHIM (force-graph compatible)
1581
+ // ═══════════════════════════════════════════
1582
+
1583
+ function _getPublicAPI() {
1584
+ return {
1585
+ centerAt: (x, y, duration) => {
1586
+ if (x == null && y == null) {
1587
+ // getter mode (used by minimap)
1588
+ return {
1589
+ x: (_canvas.width / 2 - _viewport.offsetX) / _viewport.scale,
1590
+ y: (_canvas.height / 2 - _viewport.offsetY) / _viewport.scale,
1591
+ };
1592
+ }
1593
+ _centerAt(x, y, duration);
1594
+ },
1595
+ zoom: (level, duration) => {
1596
+ if (level == null) return _viewport.scale; // getter
1597
+ const newScale = Math.max(_viewport.minScale, Math.min(_viewport.maxScale, level));
1598
+ if (duration > 0) {
1599
+ _animFrom = { offsetX: _viewport.offsetX, offsetY: _viewport.offsetY, scale: _viewport.scale };
1600
+ _animTo = { offsetX: _viewport.offsetX, offsetY: _viewport.offsetY, scale: newScale };
1601
+ // Zoom from center
1602
+ const cx = _canvas.width / 2;
1603
+ const cy = _canvas.height / 2;
1604
+ _animTo.offsetX = cx - (cx - _viewport.offsetX) * (newScale / _viewport.scale);
1605
+ _animTo.offsetY = cy - (cy - _viewport.offsetY) * (newScale / _viewport.scale);
1606
+ _animStart = performance.now();
1607
+ _animDuration = duration;
1608
+ _animating = true;
1609
+ } else {
1610
+ _viewport.scale = newScale;
1611
+ }
1612
+ },
1613
+ zoomToFit: (duration, padding) => _zoomToFit(duration, padding),
1614
+ graphData: () => ({
1615
+ nodes: _cards.map(c => ({
1616
+ id: c.node.id,
1617
+ x: c.x,
1618
+ y: c.y,
1619
+ payload: c.node.payload,
1620
+ })),
1621
+ links: state.allLinks,
1622
+ }),
1623
+ width: (w) => {
1624
+ if (w != null) { _canvas.style.width = w + 'px'; _resizeCanvas(); }
1625
+ return _canvas ? Math.round(_canvas.width / _dpr) : 0;
1626
+ },
1627
+ height: (h) => {
1628
+ if (h != null) { _canvas.style.height = h + 'px'; _resizeCanvas(); }
1629
+ return _canvas ? Math.round(_canvas.height / _dpr) : 0;
1630
+ },
1631
+ // No-ops for compatibility
1632
+ linkVisibility: (fn) => fn,
1633
+ d3ReheatSimulation: () => reheatSimulation(),
1634
+ };
1635
+ }