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.
- package/CHANGELOG.md +665 -0
- package/LICENSE +191 -0
- package/README.md +853 -0
- package/hooks/claude-code/post-plan.mjs +391 -0
- package/hooks/claude-code/post-remember.mjs +239 -0
- package/hooks/claude-code/pre-compact.mjs +241 -0
- package/hooks/claude-code/pre-websearch.mjs +90 -0
- package/hooks/claude-code/prompt-submit.mjs +861 -0
- package/hooks/claude-code/session-start.mjs +494 -0
- package/hooks/claude-code/shared.mjs +368 -0
- package/hooks/claude-code/stop.mjs +646 -0
- package/hooks/claude-code/test-user-learning.mjs +531 -0
- package/mcp-server/package.json +38 -0
- package/mcp-server/run.mjs +10 -0
- package/mcp-server/src/config.ts +70 -0
- package/mcp-server/src/http.ts +93 -0
- package/mcp-server/src/index.ts +203 -0
- package/mcp-server/src/preload-http.ts +25 -0
- package/mcp-server/src/preload.ts +29 -0
- package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
- package/mcp-server/src/services/categories.ts +429 -0
- package/mcp-server/src/services/discord.ts +702 -0
- package/mcp-server/src/services/file-checksums.ts +33 -0
- package/mcp-server/src/services/local-embeddings.ts +77 -0
- package/mcp-server/src/services/neural-interface.ts +344 -0
- package/mcp-server/src/services/sqlite.ts +823 -0
- package/mcp-server/src/tools/browser-advanced.ts +123 -0
- package/mcp-server/src/tools/browser-interact.ts +255 -0
- package/mcp-server/src/tools/browser-navigate.ts +77 -0
- package/mcp-server/src/tools/browser-observe.ts +1330 -0
- package/mcp-server/src/tools/browser.ts +107 -0
- package/mcp-server/src/tools/card-tools.ts +193 -0
- package/mcp-server/src/tools/card.ts +22 -0
- package/mcp-server/src/tools/category.ts +379 -0
- package/mcp-server/src/tools/discord-channel.ts +274 -0
- package/mcp-server/src/tools/discord-guild.ts +188 -0
- package/mcp-server/src/tools/discord-member.ts +158 -0
- package/mcp-server/src/tools/discord-message.ts +231 -0
- package/mcp-server/src/tools/discord-onboarding.ts +228 -0
- package/mcp-server/src/tools/discord-role.ts +209 -0
- package/mcp-server/src/tools/discord-thread.ts +110 -0
- package/mcp-server/src/tools/discord-webhook.ts +176 -0
- package/mcp-server/src/tools/discord.ts +40 -0
- package/mcp-server/src/tools/forget.ts +38 -0
- package/mcp-server/src/tools/git-tools.ts +141 -0
- package/mcp-server/src/tools/git.ts +13 -0
- package/mcp-server/src/tools/image-tools.ts +98 -0
- package/mcp-server/src/tools/image.ts +17 -0
- package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
- package/mcp-server/src/tools/leonardo.ts +28 -0
- package/mcp-server/src/tools/loop.ts +348 -0
- package/mcp-server/src/tools/memories.ts +118 -0
- package/mcp-server/src/tools/recall.ts +323 -0
- package/mcp-server/src/tools/reflect.ts +145 -0
- package/mcp-server/src/tools/remember.ts +115 -0
- package/mcp-server/src/tools/response.ts +26 -0
- package/mcp-server/src/tools/restore.ts +34 -0
- package/mcp-server/src/tools/sync.ts +114 -0
- package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
- package/mcp-server/src/tools/tictactoe.ts +13 -0
- package/mcp-server/src/tools/utils.ts +25 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
- package/mcp-server/src/tools/whiteboard.ts +22 -0
- package/mcp-server/src/tui.ts +940 -0
- package/mcp-server/src/types.ts +85 -0
- package/mcp-server/tsconfig.json +19 -0
- package/neural-interface/package.json +27 -0
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/claude.svg +1 -0
- package/neural-interface/public/category-logos/gemini-color.svg +1 -0
- package/neural-interface/public/category-logos/openai.svg +1 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude-chat.css +878 -0
- package/neural-interface/public/claude-chat.html +92 -0
- package/neural-interface/public/claude-chat.js +1168 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/gallery.svg +6 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index-legacy.html +14677 -0
- package/neural-interface/public/index.html +212 -0
- package/neural-interface/public/index2d-legacy.html +8193 -0
- package/neural-interface/public/index2d.html +127 -0
- package/neural-interface/public/invite.html +351 -0
- package/neural-interface/public/leonardoai.svg +1 -0
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/manifest.json +14 -0
- package/neural-interface/public/offline.html +299 -0
- package/neural-interface/public/onboarding.html +2335 -0
- package/neural-interface/public/openclaw-logo-text.png +0 -0
- package/neural-interface/public/pin-svgrepo-com.svg +25 -0
- package/neural-interface/public/sessionicon.svg +8 -0
- package/neural-interface/public/shared/ansi-parser.js +305 -0
- package/neural-interface/public/shared/api.js +1043 -0
- package/neural-interface/public/shared/colors.js +153 -0
- package/neural-interface/public/shared/constants.js +141 -0
- package/neural-interface/public/shared/html-shell.js +777 -0
- package/neural-interface/public/shared/html-term-renderer.js +1022 -0
- package/neural-interface/public/shared/i18n.js +147 -0
- package/neural-interface/public/shared/registry.js +82 -0
- package/neural-interface/public/shared/state.js +86 -0
- package/neural-interface/public/shared/storage.js +150 -0
- package/neural-interface/public/shared/styles.css +21212 -0
- package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
- package/neural-interface/public/shared/ui-bookmarks.js +231 -0
- package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
- package/neural-interface/public/shared/ui-command-runner.js +534 -0
- package/neural-interface/public/shared/ui-cost-widget.js +602 -0
- package/neural-interface/public/shared/ui-detail.js +1454 -0
- package/neural-interface/public/shared/ui-explorer.js +1356 -0
- package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
- package/neural-interface/public/shared/ui-games.js +411 -0
- package/neural-interface/public/shared/ui-help.js +107 -0
- package/neural-interface/public/shared/ui-image-gallery.js +461 -0
- package/neural-interface/public/shared/ui-invite.js +519 -0
- package/neural-interface/public/shared/ui-keybinds.js +452 -0
- package/neural-interface/public/shared/ui-layouts.js +297 -0
- package/neural-interface/public/shared/ui-link.js +731 -0
- package/neural-interface/public/shared/ui-loading.js +255 -0
- package/neural-interface/public/shared/ui-menubar.js +499 -0
- package/neural-interface/public/shared/ui-multiselect.js +93 -0
- package/neural-interface/public/shared/ui-navbar.js +222 -0
- package/neural-interface/public/shared/ui-notifications.js +287 -0
- package/neural-interface/public/shared/ui-panels.js +309 -0
- package/neural-interface/public/shared/ui-resume.js +653 -0
- package/neural-interface/public/shared/ui-search.js +145 -0
- package/neural-interface/public/shared/ui-sessions.js +531 -0
- package/neural-interface/public/shared/ui-settings.js +5987 -0
- package/neural-interface/public/shared/ui-sidebar.js +1905 -0
- package/neural-interface/public/shared/ui-skills.js +2805 -0
- package/neural-interface/public/shared/ui-stats.js +38 -0
- package/neural-interface/public/shared/ui-sync.js +201 -0
- package/neural-interface/public/shared/ui-terminal.js +6385 -0
- package/neural-interface/public/shared/ui-tooltip.js +123 -0
- package/neural-interface/public/shared/ui-trash.js +439 -0
- package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
- package/neural-interface/public/shared/ui-tutorial.js +1117 -0
- package/neural-interface/public/shared/ui-update.js +244 -0
- package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
- package/neural-interface/public/shared/ui-workspaces.js +722 -0
- package/neural-interface/public/shared/utils.js +360 -0
- package/neural-interface/public/shared/vterm-buffer.js +1102 -0
- package/neural-interface/public/sw.js +52 -0
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/toolbar-album.svg +8 -0
- package/neural-interface/public/toolbar-question.svg +6 -0
- package/neural-interface/public/toolbar-sessions.svg +6 -0
- package/neural-interface/public/variant/2d/background.js +113 -0
- package/neural-interface/public/variant/2d/context-menu.js +133 -0
- package/neural-interface/public/variant/2d/gfx.js +45 -0
- package/neural-interface/public/variant/2d/graph.js +1635 -0
- package/neural-interface/public/variant/2d/hulls.js +207 -0
- package/neural-interface/public/variant/2d/lasso.js +135 -0
- package/neural-interface/public/variant/2d/layout.js +594 -0
- package/neural-interface/public/variant/2d/main.js +521 -0
- package/neural-interface/public/variant/2d/minimap.js +312 -0
- package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
- package/neural-interface/public/variant/3d/background.js +884 -0
- package/neural-interface/public/variant/3d/camera.js +661 -0
- package/neural-interface/public/variant/3d/gfx.js +116 -0
- package/neural-interface/public/variant/3d/graph.js +2399 -0
- package/neural-interface/public/variant/3d/main.js +500 -0
- package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
- package/neural-interface/scripts/rebuild-pty.js +100 -0
- package/neural-interface/server.js +15000 -0
- package/neural-interface/templates/CLAUDE-template.md +211 -0
- package/package.json +53 -0
- package/postinstall.js +135 -0
- package/setup.js +260 -0
- package/skills/leonardo/SKILL.md +159 -0
- package/skills/leonardo/icon.svg +1 -0
- package/skills/leonardo/modules/browser-guide.md +378 -0
- package/skills/leonardo/modules/image-prompter.md +277 -0
- package/skills/leonardo/modules/model-advisor.md +100 -0
- package/skills/leonardo/modules/prompt-library.md +110 -0
- package/skills/leonardo/modules/style-guide.md +186 -0
- package/skills/leonardo/modules/video-prompter.md +311 -0
- package/skills/synabun/SKILL.md +185 -0
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +374 -0
- package/skills/synabun/modules/changelog.md +194 -0
- package/skills/synabun/modules/idea.md +144 -0
- package/skills/synabun/modules/memorize.md +188 -0
- 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
|
+
}
|