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,2399 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════
|
|
2
|
+
// SynaBun Neural Interface — 3D Graph (Raw Three.js)
|
|
3
|
+
// Core graph initialization, node/link rendering,
|
|
4
|
+
// layout computation, animation loop, interactions
|
|
5
|
+
// ═══════════════════════════════════════════
|
|
6
|
+
//
|
|
7
|
+
// THREE, TWEEN, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass
|
|
8
|
+
// are globals from CDN imports. No ForceGraph3D dependency.
|
|
9
|
+
console.log('[graph.js] LOADED — v4 polished layout + fresnel orbs');
|
|
10
|
+
|
|
11
|
+
import { state, emit, on } from '../../shared/state.js';
|
|
12
|
+
import { KEYS } from '../../shared/constants.js';
|
|
13
|
+
import { storage } from '../../shared/storage.js';
|
|
14
|
+
import { catColor } from '../../shared/colors.js';
|
|
15
|
+
import { fetchLinks } from '../../shared/api.js';
|
|
16
|
+
import { gfx } from './gfx.js';
|
|
17
|
+
|
|
18
|
+
// ── Constants ──────────────────────────────
|
|
19
|
+
const FLOOR_Y = -500;
|
|
20
|
+
const PLEXUS_RADIUS = 200;
|
|
21
|
+
const _MULTI_SELECT_BLUE = new THREE.Color(0.4, 0.7, 1.0);
|
|
22
|
+
const _MAX_BATCH_LINKS = 15000;
|
|
23
|
+
const _posStorageKey = KEYS.NODE_POS_3D;
|
|
24
|
+
|
|
25
|
+
// ── Module state ───────────────────────────
|
|
26
|
+
let graph = null;
|
|
27
|
+
let _graphContainer = null;
|
|
28
|
+
let animFrameId = null;
|
|
29
|
+
let graphRemovalTimer = null;
|
|
30
|
+
let _bloomPass = null;
|
|
31
|
+
|
|
32
|
+
// Frustum culling (reused every frame — zero alloc)
|
|
33
|
+
const _worldPos = new THREE.Vector3();
|
|
34
|
+
const _mouseDir = new THREE.Vector3();
|
|
35
|
+
const _frustum = new THREE.Frustum();
|
|
36
|
+
const _frustumPadded = new THREE.Frustum();
|
|
37
|
+
const _projScreenMatrix = new THREE.Matrix4();
|
|
38
|
+
|
|
39
|
+
// Batch link rendering — single draw call for ALL visible links
|
|
40
|
+
let _linkBatch = null;
|
|
41
|
+
let _linkBatchGeo = null;
|
|
42
|
+
let _linkBatchDirty = true;
|
|
43
|
+
let _linkPosDirty = true; // positions need update (drag, layout change)
|
|
44
|
+
let _batchLinks = [];
|
|
45
|
+
let _visualLinks = [];
|
|
46
|
+
let _nodeById = new Map();
|
|
47
|
+
let _linkBatchPositions = new Float32Array(_MAX_BATCH_LINKS * 6);
|
|
48
|
+
let _linkBatchColors = new Float32Array(_MAX_BATCH_LINKS * 6);
|
|
49
|
+
let _crossCategoryLinks = [];
|
|
50
|
+
|
|
51
|
+
// Drag state
|
|
52
|
+
const _drag = {
|
|
53
|
+
active: false,
|
|
54
|
+
node: null,
|
|
55
|
+
type: null,
|
|
56
|
+
dragSet: new Set(),
|
|
57
|
+
frozenSet: new Set(),
|
|
58
|
+
prevPos: null,
|
|
59
|
+
depthOffset: new THREE.Vector3(),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// 3D mouse position tracking via raycasting
|
|
63
|
+
const _mouse = new THREE.Vector2();
|
|
64
|
+
const _raycaster = new THREE.Raycaster();
|
|
65
|
+
const _mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
|
66
|
+
const _mouse3D = new THREE.Vector3();
|
|
67
|
+
let _mouse3DValid = false;
|
|
68
|
+
let _mouseMoved = false;
|
|
69
|
+
const _dragDepthVec = new THREE.Vector3();
|
|
70
|
+
|
|
71
|
+
// Node scale boost when links are off
|
|
72
|
+
let noLinksScaleBoost = 1.0;
|
|
73
|
+
|
|
74
|
+
// Pointer-over-UI guard
|
|
75
|
+
let _pointerOverUI = false;
|
|
76
|
+
|
|
77
|
+
// Focus mode — true when visualization is paused (zero-resource mode)
|
|
78
|
+
let _focusPaused = false;
|
|
79
|
+
|
|
80
|
+
// ── Raw Three.js objects (replaces ForceGraph3D) ──
|
|
81
|
+
let _scene = null;
|
|
82
|
+
let _camera = null;
|
|
83
|
+
let _renderer = null;
|
|
84
|
+
let _controls = null;
|
|
85
|
+
let _composer = null;
|
|
86
|
+
|
|
87
|
+
// ── Graph data store (replaces ForceGraph3D's internal data) ──
|
|
88
|
+
let _graphNodes = [];
|
|
89
|
+
let _graphLinks = [];
|
|
90
|
+
|
|
91
|
+
// ── Anchor/Tag scene objects (memory nodes have ZERO Three.js objects) ──
|
|
92
|
+
const _anchorTagObjects = new Map(); // nodeId → THREE.Group
|
|
93
|
+
let _anchorTagArray = []; // flat array for fast iteration in animate()
|
|
94
|
+
|
|
95
|
+
// ── Pointer events state for custom raycasting ──
|
|
96
|
+
let _pointerDownPos = null;
|
|
97
|
+
let _pointerDownTime = 0;
|
|
98
|
+
let _pointerDownNode = null;
|
|
99
|
+
const _dragPlane = new THREE.Plane();
|
|
100
|
+
const _dragHit = new THREE.Vector3();
|
|
101
|
+
|
|
102
|
+
// ═══════════════════════════════════════════
|
|
103
|
+
// GRAPH PROXY — matches ForceGraph3D API surface
|
|
104
|
+
// so camera.js, background.js, main.js need zero changes
|
|
105
|
+
// ═══════════════════════════════════════════
|
|
106
|
+
|
|
107
|
+
function _tweenCameraPosition(toPos, toLookAt, durationMs) {
|
|
108
|
+
if (!_camera || !_controls) return;
|
|
109
|
+
if (!durationMs || durationMs <= 0) {
|
|
110
|
+
_camera.position.set(toPos.x, toPos.y, toPos.z);
|
|
111
|
+
if (toLookAt) {
|
|
112
|
+
_controls.target.set(toLookAt.x, toLookAt.y, toLookAt.z);
|
|
113
|
+
_controls.update();
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
new TWEEN.Tween(_camera.position)
|
|
118
|
+
.to({ x: toPos.x, y: toPos.y, z: toPos.z }, durationMs)
|
|
119
|
+
.easing(TWEEN.Easing.Quadratic.Out)
|
|
120
|
+
.start();
|
|
121
|
+
if (toLookAt) {
|
|
122
|
+
new TWEEN.Tween(_controls.target)
|
|
123
|
+
.to({ x: toLookAt.x, y: toLookAt.y, z: toLookAt.z }, durationMs)
|
|
124
|
+
.easing(TWEEN.Easing.Quadratic.Out)
|
|
125
|
+
.onUpdate(() => _controls.update())
|
|
126
|
+
.start();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const graphProxy = {
|
|
131
|
+
scene: () => _scene,
|
|
132
|
+
camera: () => _camera,
|
|
133
|
+
controls: () => _controls,
|
|
134
|
+
renderer: () => _renderer,
|
|
135
|
+
graphData: (data) => {
|
|
136
|
+
if (data !== undefined) {
|
|
137
|
+
_graphNodes = data.nodes || [];
|
|
138
|
+
_graphLinks = data.links || [];
|
|
139
|
+
} else {
|
|
140
|
+
return { nodes: _graphNodes, links: _graphLinks };
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
cameraPosition: (pos, lookAt, ms) => _tweenCameraPosition(pos, lookAt, ms ?? 0),
|
|
144
|
+
postProcessingComposer: () => _composer,
|
|
145
|
+
backgroundColor: (hex) => {
|
|
146
|
+
if (_renderer) _renderer.setClearColor(new THREE.Color(hex));
|
|
147
|
+
},
|
|
148
|
+
d3Force: () => ({ strength: () => ({}) }), // noop — forces are disabled
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ═══════════════════════════════════════════
|
|
152
|
+
// INSTANCED RENDERING — memory nodes
|
|
153
|
+
// ═══════════════════════════════════════════
|
|
154
|
+
const _MAX_MEMORY_NODES = 8000;
|
|
155
|
+
const _NODE_LIMIT_KEY = 'neural-node-limit';
|
|
156
|
+
let _nodeLimit = parseInt(storage.getItem(_NODE_LIMIT_KEY) || '0', 10) || 0; // 0 = no limit
|
|
157
|
+
|
|
158
|
+
// 3 InstancedMesh for wireframes (one per LOD tier)
|
|
159
|
+
let _wireInstLow = null; // importance 1-4
|
|
160
|
+
let _wireInstMed = null; // importance 5-7
|
|
161
|
+
let _wireInstHigh = null; // importance 8+
|
|
162
|
+
|
|
163
|
+
// 1 Points mesh for glows
|
|
164
|
+
let _glowPoints = null;
|
|
165
|
+
let _glowPointsGeo = null;
|
|
166
|
+
|
|
167
|
+
// Index management
|
|
168
|
+
let _nodeInstanceMap = new Map(); // nodeId → { tier, index }
|
|
169
|
+
let _tierNodes = [[], [], []]; // [lowNodes, medNodes, highNodes]
|
|
170
|
+
let _glowNodeOrder = []; // all memory nodes in global index order
|
|
171
|
+
|
|
172
|
+
// Per-node state (parallel Float32Arrays — static, updated only on dirty)
|
|
173
|
+
const _nodeOpacity = new Float32Array(_MAX_MEMORY_NODES);
|
|
174
|
+
const _nodeGlowOpacity = new Float32Array(_MAX_MEMORY_NODES);
|
|
175
|
+
const _nodeScale = new Float32Array(_MAX_MEMORY_NODES);
|
|
176
|
+
const _nodeColorR = new Float32Array(_MAX_MEMORY_NODES);
|
|
177
|
+
const _nodeColorG = new Float32Array(_MAX_MEMORY_NODES);
|
|
178
|
+
const _nodeColorB = new Float32Array(_MAX_MEMORY_NODES);
|
|
179
|
+
const _nodeBaseColorR = new Float32Array(_MAX_MEMORY_NODES);
|
|
180
|
+
const _nodeBaseColorG = new Float32Array(_MAX_MEMORY_NODES);
|
|
181
|
+
const _nodeBaseColorB = new Float32Array(_MAX_MEMORY_NODES);
|
|
182
|
+
const _nodeBaseOpacity = new Float32Array(_MAX_MEMORY_NODES);
|
|
183
|
+
const _nodeImportance = new Float32Array(_MAX_MEMORY_NODES);
|
|
184
|
+
const _nodeBaseRadius = new Float32Array(_MAX_MEMORY_NODES);
|
|
185
|
+
|
|
186
|
+
// Dirty flag — only recompute + upload GPU buffers when state changes
|
|
187
|
+
let _instancesDirty = true;
|
|
188
|
+
|
|
189
|
+
// Reusable math scratch objects (zero-alloc per frame)
|
|
190
|
+
const _instMatrix = new THREE.Matrix4();
|
|
191
|
+
const _instPos = new THREE.Vector3();
|
|
192
|
+
const _instScaleV = new THREE.Vector3();
|
|
193
|
+
const _identityQuat = new THREE.Quaternion(); // identity — no rotation
|
|
194
|
+
|
|
195
|
+
// ═══════════════════════════════════════════
|
|
196
|
+
// SOFT DOT TEXTURE
|
|
197
|
+
// ═══════════════════════════════════════════
|
|
198
|
+
let _softDotTex = null;
|
|
199
|
+
function getSoftDotTexture() {
|
|
200
|
+
if (_softDotTex) return _softDotTex;
|
|
201
|
+
const size = 64;
|
|
202
|
+
const canvas = document.createElement('canvas');
|
|
203
|
+
canvas.width = size; canvas.height = size;
|
|
204
|
+
const ctx = canvas.getContext('2d');
|
|
205
|
+
const c = size / 2;
|
|
206
|
+
const imgData = ctx.createImageData(size, size);
|
|
207
|
+
const d = imgData.data;
|
|
208
|
+
for (let py = 0; py < size; py++) {
|
|
209
|
+
for (let px = 0; px < size; px++) {
|
|
210
|
+
const dx = (px - c) / c, dy = (py - c) / c;
|
|
211
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
212
|
+
const t = Math.max(0, Math.min(1, (0.55 - dist) / (0.55 - 0.1)));
|
|
213
|
+
const alpha = t * t * (3 - 2 * t);
|
|
214
|
+
const idx = (py * size + px) * 4;
|
|
215
|
+
d[idx] = 255; d[idx + 1] = 255; d[idx + 2] = 255;
|
|
216
|
+
d[idx + 3] = alpha * 255 | 0;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
ctx.putImageData(imgData, 0, 0);
|
|
220
|
+
_softDotTex = new THREE.CanvasTexture(canvas);
|
|
221
|
+
_softDotTex.needsUpdate = true;
|
|
222
|
+
return _softDotTex;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Shared hitbox material for anchor/tag PlaneGeometry hitboxes (~50-100 total) ──
|
|
226
|
+
let _sharedHitboxMat = null;
|
|
227
|
+
function getHitboxMaterial() {
|
|
228
|
+
if (!_sharedHitboxMat) {
|
|
229
|
+
_sharedHitboxMat = new THREE.MeshBasicMaterial({
|
|
230
|
+
transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return _sharedHitboxMat;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ═══════════════════════════════════════════
|
|
237
|
+
// INSTANCED SHADER MATERIALS
|
|
238
|
+
// ═══════════════════════════════════════════
|
|
239
|
+
|
|
240
|
+
function createWireInstanceMaterial() {
|
|
241
|
+
return new THREE.ShaderMaterial({
|
|
242
|
+
wireframe: false,
|
|
243
|
+
transparent: true,
|
|
244
|
+
depthWrite: true,
|
|
245
|
+
side: THREE.DoubleSide,
|
|
246
|
+
uniforms: {
|
|
247
|
+
uTime: { value: 0.0 },
|
|
248
|
+
},
|
|
249
|
+
vertexShader: `
|
|
250
|
+
attribute vec3 instanceColor;
|
|
251
|
+
attribute float instanceOpacity;
|
|
252
|
+
varying vec3 vColor;
|
|
253
|
+
varying float vOpacity;
|
|
254
|
+
varying vec3 vNormal;
|
|
255
|
+
varying vec3 vViewPos;
|
|
256
|
+
void main() {
|
|
257
|
+
vColor = instanceColor;
|
|
258
|
+
vOpacity = instanceOpacity;
|
|
259
|
+
vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
|
|
260
|
+
vNormal = normalize(normalMatrix * mat3(instanceMatrix) * normal);
|
|
261
|
+
vViewPos = mvPosition.xyz;
|
|
262
|
+
gl_Position = projectionMatrix * mvPosition;
|
|
263
|
+
}
|
|
264
|
+
`,
|
|
265
|
+
fragmentShader: `
|
|
266
|
+
uniform float uTime;
|
|
267
|
+
varying vec3 vColor;
|
|
268
|
+
varying float vOpacity;
|
|
269
|
+
varying vec3 vNormal;
|
|
270
|
+
varying vec3 vViewPos;
|
|
271
|
+
void main() {
|
|
272
|
+
if (vOpacity < 0.003) discard;
|
|
273
|
+
// Fresnel rim glow — brighter at edges, subtler at center
|
|
274
|
+
float fresnel = 1.0 - abs(dot(normalize(vNormal), normalize(-vViewPos)));
|
|
275
|
+
fresnel = pow(fresnel, 1.5);
|
|
276
|
+
float rim = 0.7 + fresnel * 0.3;
|
|
277
|
+
// Subtle shimmer
|
|
278
|
+
float shimmer = 1.0 + sin(uTime * 1.5 + vViewPos.x * 0.1) * 0.05;
|
|
279
|
+
gl_FragColor = vec4(vColor * 1.8 * rim * shimmer, 1.0);
|
|
280
|
+
}
|
|
281
|
+
`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function createGlowPointsMaterial() {
|
|
286
|
+
return new THREE.ShaderMaterial({
|
|
287
|
+
transparent: true,
|
|
288
|
+
depthWrite: false,
|
|
289
|
+
uniforms: {
|
|
290
|
+
uTexture: { value: getSoftDotTexture() },
|
|
291
|
+
},
|
|
292
|
+
vertexShader: `
|
|
293
|
+
attribute vec3 glowColor;
|
|
294
|
+
attribute float glowOpacity;
|
|
295
|
+
attribute float glowSize;
|
|
296
|
+
varying vec3 vColor;
|
|
297
|
+
varying float vOpacity;
|
|
298
|
+
void main() {
|
|
299
|
+
vColor = glowColor;
|
|
300
|
+
vOpacity = glowOpacity;
|
|
301
|
+
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
|
302
|
+
gl_PointSize = glowSize * (300.0 / -mvPosition.z);
|
|
303
|
+
gl_PointSize = clamp(gl_PointSize, 1.0, 128.0);
|
|
304
|
+
gl_Position = projectionMatrix * mvPosition;
|
|
305
|
+
}
|
|
306
|
+
`,
|
|
307
|
+
fragmentShader: `
|
|
308
|
+
uniform sampler2D uTexture;
|
|
309
|
+
varying vec3 vColor;
|
|
310
|
+
varying float vOpacity;
|
|
311
|
+
void main() {
|
|
312
|
+
if (vOpacity < 0.003) discard;
|
|
313
|
+
vec4 texel = texture2D(uTexture, gl_PointCoord);
|
|
314
|
+
gl_FragColor = vec4(vColor * 1.8, texel.a * vOpacity * 2.0);
|
|
315
|
+
}
|
|
316
|
+
`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ═══════════════════════════════════════════
|
|
321
|
+
// CATEGORY LOGOS — preloaded images for anchor nodes
|
|
322
|
+
// ═══════════════════════════════════════════
|
|
323
|
+
const _categoryLogos = new Map();
|
|
324
|
+
|
|
325
|
+
let _logoRefreshTimer = null;
|
|
326
|
+
export function preloadCategoryLogos(categories) {
|
|
327
|
+
const names = new Set(categories.map(c => c.name));
|
|
328
|
+
for (const [k] of _categoryLogos) { if (!names.has(k)) _categoryLogos.delete(k); }
|
|
329
|
+
for (const cat of categories) {
|
|
330
|
+
if (!cat.logo) continue;
|
|
331
|
+
const existing = _categoryLogos.get(cat.name);
|
|
332
|
+
if (existing && existing.src === cat.logo) continue;
|
|
333
|
+
const img = new Image();
|
|
334
|
+
img.crossOrigin = 'anonymous';
|
|
335
|
+
const catName = cat.name;
|
|
336
|
+
const entry = { img, ready: false, src: cat.logo };
|
|
337
|
+
img.onload = () => {
|
|
338
|
+
entry.ready = true;
|
|
339
|
+
// Remove existing anchor so applyGraphData recreates it with the logo
|
|
340
|
+
for (const [id, obj] of _anchorTagObjects) {
|
|
341
|
+
if (obj.userData.anchorCategory === catName) {
|
|
342
|
+
_scene.remove(obj);
|
|
343
|
+
_anchorTagObjects.delete(id);
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Debounced graph refresh so logos appear on anchors once loaded
|
|
348
|
+
clearTimeout(_logoRefreshTimer);
|
|
349
|
+
_logoRefreshTimer = setTimeout(() => { if (graph) applyGraphData(); }, 200);
|
|
350
|
+
};
|
|
351
|
+
img.onerror = () => { _categoryLogos.delete(cat.name); };
|
|
352
|
+
img.src = cat.logo;
|
|
353
|
+
_categoryLogos.set(cat.name, entry);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Legacy fallback: hardcoded OpenClaw logo
|
|
358
|
+
const _openclawLogo = new Image();
|
|
359
|
+
_openclawLogo.src = '/openclaw-logo-text.png';
|
|
360
|
+
let _openclawLogoReady = false;
|
|
361
|
+
_openclawLogo.onload = () => { _openclawLogoReady = true; };
|
|
362
|
+
|
|
363
|
+
// ═══════════════════════════════════════════
|
|
364
|
+
// INSTANCED RENDERING — init + index management
|
|
365
|
+
// ═══════════════════════════════════════════
|
|
366
|
+
|
|
367
|
+
function initInstancedRendering() {
|
|
368
|
+
const scene = graph.scene();
|
|
369
|
+
|
|
370
|
+
// 3 InstancedMesh for wireframes (one per LOD tier)
|
|
371
|
+
const geoLow = new THREE.IcosahedronGeometry(1, 0);
|
|
372
|
+
const geoMed = new THREE.IcosahedronGeometry(1, 1);
|
|
373
|
+
const geoHigh = new THREE.IcosahedronGeometry(1, 2);
|
|
374
|
+
|
|
375
|
+
_wireInstLow = new THREE.InstancedMesh(geoLow, createWireInstanceMaterial(), _MAX_MEMORY_NODES);
|
|
376
|
+
_wireInstMed = new THREE.InstancedMesh(geoMed, createWireInstanceMaterial(), _MAX_MEMORY_NODES);
|
|
377
|
+
_wireInstHigh = new THREE.InstancedMesh(geoHigh, createWireInstanceMaterial(), _MAX_MEMORY_NODES);
|
|
378
|
+
|
|
379
|
+
for (const inst of [_wireInstLow, _wireInstMed, _wireInstHigh]) {
|
|
380
|
+
inst.count = 0;
|
|
381
|
+
inst.frustumCulled = false;
|
|
382
|
+
|
|
383
|
+
const colorAttr = new THREE.InstancedBufferAttribute(
|
|
384
|
+
new Float32Array(_MAX_MEMORY_NODES * 3), 3
|
|
385
|
+
);
|
|
386
|
+
colorAttr.setUsage(THREE.DynamicDrawUsage);
|
|
387
|
+
inst.geometry.setAttribute('instanceColor', colorAttr);
|
|
388
|
+
|
|
389
|
+
const opacityAttr = new THREE.InstancedBufferAttribute(
|
|
390
|
+
new Float32Array(_MAX_MEMORY_NODES), 1
|
|
391
|
+
);
|
|
392
|
+
opacityAttr.setUsage(THREE.DynamicDrawUsage);
|
|
393
|
+
inst.geometry.setAttribute('instanceOpacity', opacityAttr);
|
|
394
|
+
|
|
395
|
+
scene.add(inst);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 1 Points mesh for glows
|
|
399
|
+
_glowPointsGeo = new THREE.BufferGeometry();
|
|
400
|
+
|
|
401
|
+
const posAttr = new THREE.BufferAttribute(new Float32Array(_MAX_MEMORY_NODES * 3), 3);
|
|
402
|
+
posAttr.setUsage(THREE.DynamicDrawUsage);
|
|
403
|
+
_glowPointsGeo.setAttribute('position', posAttr);
|
|
404
|
+
|
|
405
|
+
const glowColorAttr = new THREE.BufferAttribute(new Float32Array(_MAX_MEMORY_NODES * 3), 3);
|
|
406
|
+
glowColorAttr.setUsage(THREE.DynamicDrawUsage);
|
|
407
|
+
_glowPointsGeo.setAttribute('glowColor', glowColorAttr);
|
|
408
|
+
|
|
409
|
+
const glowOpAttr = new THREE.BufferAttribute(new Float32Array(_MAX_MEMORY_NODES), 1);
|
|
410
|
+
glowOpAttr.setUsage(THREE.DynamicDrawUsage);
|
|
411
|
+
_glowPointsGeo.setAttribute('glowOpacity', glowOpAttr);
|
|
412
|
+
|
|
413
|
+
const glowSizeAttr = new THREE.BufferAttribute(new Float32Array(_MAX_MEMORY_NODES), 1);
|
|
414
|
+
glowSizeAttr.setUsage(THREE.DynamicDrawUsage);
|
|
415
|
+
_glowPointsGeo.setAttribute('glowSize', glowSizeAttr);
|
|
416
|
+
|
|
417
|
+
_glowPointsGeo.setDrawRange(0, 0);
|
|
418
|
+
|
|
419
|
+
_glowPoints = new THREE.Points(_glowPointsGeo, createGlowPointsMaterial());
|
|
420
|
+
_glowPoints.frustumCulled = false;
|
|
421
|
+
scene.add(_glowPoints);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function rebuildInstanceMap() {
|
|
425
|
+
_nodeInstanceMap.clear();
|
|
426
|
+
_tierNodes[0].length = 0;
|
|
427
|
+
_tierNodes[1].length = 0;
|
|
428
|
+
_tierNodes[2].length = 0;
|
|
429
|
+
_glowNodeOrder.length = 0;
|
|
430
|
+
|
|
431
|
+
if (!graph) return;
|
|
432
|
+
const gd = graph.graphData();
|
|
433
|
+
|
|
434
|
+
// First pass: assign nodes to tiers (cap at effective limit to prevent buffer overflow)
|
|
435
|
+
const effectiveLimit = _nodeLimit > 0 ? Math.min(_nodeLimit, _MAX_MEMORY_NODES) : _MAX_MEMORY_NODES;
|
|
436
|
+
let memCount = 0;
|
|
437
|
+
for (let i = 0; i < gd.nodes.length; i++) {
|
|
438
|
+
const node = gd.nodes[i];
|
|
439
|
+
if (!node.payload || node.payload._isAnchor || node.payload._isTag) continue;
|
|
440
|
+
if (memCount >= effectiveLimit) break;
|
|
441
|
+
|
|
442
|
+
const importance = node.payload.importance || 5;
|
|
443
|
+
const tier = importance >= 8 ? 2 : importance >= 5 ? 1 : 0;
|
|
444
|
+
|
|
445
|
+
_tierNodes[tier].push(node);
|
|
446
|
+
_nodeInstanceMap.set(node.id, { tier, index: _tierNodes[tier].length - 1 });
|
|
447
|
+
memCount++;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Build _glowNodeOrder in tier order (tier 0, then tier 1, then tier 2)
|
|
451
|
+
// so global indices match Phase 2's globalOffset mapping
|
|
452
|
+
for (let t = 0; t < 3; t++) {
|
|
453
|
+
for (let i = 0; i < _tierNodes[t].length; i++) {
|
|
454
|
+
_glowNodeOrder.push(_tierNodes[t][i]);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Initialize animation state for each node (now in tier order)
|
|
459
|
+
for (let gi = 0; gi < _glowNodeOrder.length; gi++) {
|
|
460
|
+
const node = _glowNodeOrder[gi];
|
|
461
|
+
const importance = node.payload.importance || 5;
|
|
462
|
+
const color = catColor(node.payload.category);
|
|
463
|
+
const hex = new THREE.Color(color);
|
|
464
|
+
const mutedHex = hex.clone();
|
|
465
|
+
const radius = 6 + (importance - 1) * 0.8;
|
|
466
|
+
const baseOpacity = 0.75 + (importance - 1) * 0.03;
|
|
467
|
+
|
|
468
|
+
_nodeBaseOpacity[gi] = baseOpacity;
|
|
469
|
+
_nodeBaseColorR[gi] = mutedHex.r;
|
|
470
|
+
_nodeBaseColorG[gi] = mutedHex.g;
|
|
471
|
+
_nodeBaseColorB[gi] = mutedHex.b;
|
|
472
|
+
_nodeColorR[gi] = mutedHex.r;
|
|
473
|
+
_nodeColorG[gi] = mutedHex.g;
|
|
474
|
+
_nodeColorB[gi] = mutedHex.b;
|
|
475
|
+
_nodeOpacity[gi] = baseOpacity;
|
|
476
|
+
_nodeGlowOpacity[gi] = baseOpacity * 0.85;
|
|
477
|
+
_nodeScale[gi] = 1.0;
|
|
478
|
+
_nodeImportance[gi] = importance;
|
|
479
|
+
_nodeBaseRadius[gi] = radius;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
_instancesDirty = true;
|
|
483
|
+
|
|
484
|
+
// Set instance counts
|
|
485
|
+
if (_wireInstLow) _wireInstLow.count = _tierNodes[0].length;
|
|
486
|
+
if (_wireInstMed) _wireInstMed.count = _tierNodes[1].length;
|
|
487
|
+
if (_wireInstHigh) _wireInstHigh.count = _tierNodes[2].length;
|
|
488
|
+
|
|
489
|
+
// Set glow draw range
|
|
490
|
+
if (_glowPointsGeo) _glowPointsGeo.setDrawRange(0, _glowNodeOrder.length);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// GFX preset resets — handle instanced memory nodes + individual anchor/tag nodes
|
|
494
|
+
on('gfx:preset-applied', () => {
|
|
495
|
+
// Reset instanced memory node opacities to base values
|
|
496
|
+
for (let gi = 0; gi < _glowNodeOrder.length; gi++) {
|
|
497
|
+
_nodeOpacity[gi] = _nodeBaseOpacity[gi];
|
|
498
|
+
_nodeGlowOpacity[gi] = _nodeBaseOpacity[gi] * 0.85;
|
|
499
|
+
}
|
|
500
|
+
_instancesDirty = true;
|
|
501
|
+
// Reset individual anchor/tag nodes
|
|
502
|
+
for (const [, obj] of _anchorTagObjects) {
|
|
503
|
+
if (!obj || !obj.userData) continue;
|
|
504
|
+
const dot = obj.userData.dot;
|
|
505
|
+
if (dot && dot.material) {
|
|
506
|
+
dot.material.opacity = obj.userData.baseOpacity || 0.1;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Node limit change — save to storage and rebuild
|
|
512
|
+
on('search:apply', () => { _instancesDirty = true; });
|
|
513
|
+
on('search:clear', () => { _instancesDirty = true; });
|
|
514
|
+
on('categories-changed', () => { _instancesDirty = true; });
|
|
515
|
+
|
|
516
|
+
on('node-limit-changed', (limit) => {
|
|
517
|
+
_nodeLimit = limit;
|
|
518
|
+
storage.setItem(_NODE_LIMIT_KEY, String(limit));
|
|
519
|
+
if (graph) {
|
|
520
|
+
rebuildInstanceMap();
|
|
521
|
+
_instancesDirty = true;
|
|
522
|
+
emit('graph:refresh');
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
// ═══════════════════════════════════════════
|
|
528
|
+
// NODE CREATION — Anchor (parent category)
|
|
529
|
+
// ═══════════════════════════════════════════
|
|
530
|
+
function createAnchorObject(node) {
|
|
531
|
+
const parentName = node.payload._anchorCategory;
|
|
532
|
+
const color = catColor(parentName);
|
|
533
|
+
const hex = new THREE.Color(color);
|
|
534
|
+
const dotSize = 20;
|
|
535
|
+
|
|
536
|
+
const group = new THREE.Group();
|
|
537
|
+
|
|
538
|
+
// Label — logo or text on canvas
|
|
539
|
+
const _logoEntry = _categoryLogos.get(parentName);
|
|
540
|
+
const useLogo = (_logoEntry && _logoEntry.ready) || (parentName === 'openclaw' && _openclawLogoReady);
|
|
541
|
+
const logoImage = (_logoEntry && _logoEntry.ready) ? _logoEntry.img : _openclawLogo;
|
|
542
|
+
if (_logoEntry) console.log(`[anchor] ${parentName}: logo entry found, ready=${_logoEntry.ready}, src=${_logoEntry.src}`);
|
|
543
|
+
else console.log(`[anchor] ${parentName}: no logo entry in _categoryLogos (map size: ${_categoryLogos.size})`);
|
|
544
|
+
|
|
545
|
+
let contentW, contentH;
|
|
546
|
+
const PAD = 16;
|
|
547
|
+
|
|
548
|
+
if (useLogo) {
|
|
549
|
+
const aspect = logoImage.naturalWidth / (logoImage.naturalHeight || 1);
|
|
550
|
+
const maxW = 380, maxH = 100;
|
|
551
|
+
if (aspect >= maxW / maxH) { contentW = maxW; contentH = maxW / aspect; }
|
|
552
|
+
else { contentH = maxH; contentW = maxH * aspect; }
|
|
553
|
+
} else {
|
|
554
|
+
const measureCanvas = document.createElement('canvas');
|
|
555
|
+
const measureCtx = measureCanvas.getContext('2d');
|
|
556
|
+
measureCtx.font = '600 48px "Space Grotesk", "Inter", system-ui, sans-serif';
|
|
557
|
+
const metrics = measureCtx.measureText(parentName.toUpperCase());
|
|
558
|
+
contentW = Math.ceil(metrics.width);
|
|
559
|
+
contentH = 58;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const canvasW = Math.ceil(contentW + PAD * 2);
|
|
563
|
+
const canvasH = Math.ceil(contentH + PAD * 2);
|
|
564
|
+
|
|
565
|
+
const labelCanvas = document.createElement('canvas');
|
|
566
|
+
const labelCtx = labelCanvas.getContext('2d');
|
|
567
|
+
labelCanvas.width = canvasW;
|
|
568
|
+
labelCanvas.height = canvasH;
|
|
569
|
+
labelCtx.clearRect(0, 0, canvasW, canvasH);
|
|
570
|
+
|
|
571
|
+
if (useLogo) {
|
|
572
|
+
labelCtx.globalAlpha = 1.0;
|
|
573
|
+
labelCtx.drawImage(logoImage, PAD, PAD, contentW, contentH);
|
|
574
|
+
labelCtx.globalAlpha = 1.0;
|
|
575
|
+
} else {
|
|
576
|
+
labelCtx.font = '600 48px "Space Grotesk", "Inter", system-ui, sans-serif';
|
|
577
|
+
labelCtx.textAlign = 'center';
|
|
578
|
+
labelCtx.textBaseline = 'middle';
|
|
579
|
+
labelCtx.fillStyle = 'rgba(255,255,255,1.0)';
|
|
580
|
+
labelCtx.fillText(parentName.toUpperCase(), canvasW / 2, canvasH / 2);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const labelTex = new THREE.CanvasTexture(labelCanvas);
|
|
584
|
+
labelTex.colorSpace = THREE.SRGBColorSpace;
|
|
585
|
+
|
|
586
|
+
// Billboard mesh with custom ShaderMaterial — bypasses SpriteMaterial pipeline entirely
|
|
587
|
+
const SCALE = 0.3;
|
|
588
|
+
const labelW = canvasW * SCALE;
|
|
589
|
+
const labelH = canvasH * SCALE;
|
|
590
|
+
const labelGeo = new THREE.PlaneGeometry(1, 1); // 1x1 base — scale set in animate loop via baseLabelScale
|
|
591
|
+
const labelShaderMat = new THREE.ShaderMaterial({
|
|
592
|
+
transparent: true,
|
|
593
|
+
depthTest: false,
|
|
594
|
+
depthWrite: false,
|
|
595
|
+
fog: false,
|
|
596
|
+
uniforms: {
|
|
597
|
+
uTexture: { value: labelTex },
|
|
598
|
+
uOpacity: { value: 1.0 },
|
|
599
|
+
},
|
|
600
|
+
vertexShader: `
|
|
601
|
+
varying vec2 vUv;
|
|
602
|
+
void main() {
|
|
603
|
+
vUv = uv;
|
|
604
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
605
|
+
}
|
|
606
|
+
`,
|
|
607
|
+
fragmentShader: `
|
|
608
|
+
uniform sampler2D uTexture;
|
|
609
|
+
uniform float uOpacity;
|
|
610
|
+
varying vec2 vUv;
|
|
611
|
+
void main() {
|
|
612
|
+
vec4 tex = texture2D(uTexture, vUv);
|
|
613
|
+
if (tex.a < 0.01) discard;
|
|
614
|
+
gl_FragColor = vec4(tex.rgb, tex.a * uOpacity);
|
|
615
|
+
}
|
|
616
|
+
`,
|
|
617
|
+
});
|
|
618
|
+
const label = new THREE.Mesh(labelGeo, labelShaderMat);
|
|
619
|
+
label.renderOrder = 999;
|
|
620
|
+
label.position.set(0, 0, 0.5);
|
|
621
|
+
group.add(label);
|
|
622
|
+
|
|
623
|
+
// Flat plane hitbox (shared material)
|
|
624
|
+
const hitGeo = new THREE.PlaneGeometry(labelW, labelH);
|
|
625
|
+
const hitbox = new THREE.Mesh(hitGeo, getHitboxMaterial());
|
|
626
|
+
hitbox.position.set(0, 0, 0.5);
|
|
627
|
+
group.add(hitbox);
|
|
628
|
+
|
|
629
|
+
group.userData.nodeId = node.id;
|
|
630
|
+
group.userData.isAnchor = true;
|
|
631
|
+
group.userData.anchorCategory = parentName;
|
|
632
|
+
group.userData.dot = null;
|
|
633
|
+
group.userData.label = label;
|
|
634
|
+
group.userData.baseLabelScale = { x: labelW, y: labelH };
|
|
635
|
+
group.userData.baseLabelY = 0;
|
|
636
|
+
group.userData.baseRadius = dotSize;
|
|
637
|
+
group.userData.category = parentName;
|
|
638
|
+
group.userData.phase = parentName.length * 0.7;
|
|
639
|
+
group.userData.importance = 10;
|
|
640
|
+
group.userData.baseOpacity = 1.0;
|
|
641
|
+
|
|
642
|
+
group.userData._currentScale = 0.01;
|
|
643
|
+
group.scale.setScalar(0.01);
|
|
644
|
+
|
|
645
|
+
return group;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ═══════════════════════════════════════════
|
|
649
|
+
// NODE CREATION — Tag (child category)
|
|
650
|
+
// ═══════════════════════════════════════════
|
|
651
|
+
function createTagObject(node) {
|
|
652
|
+
const catName = node.payload._tagCategory;
|
|
653
|
+
const color = catColor(catName);
|
|
654
|
+
const hex = new THREE.Color(color);
|
|
655
|
+
const dotSize = 10;
|
|
656
|
+
|
|
657
|
+
const group = new THREE.Group();
|
|
658
|
+
|
|
659
|
+
// Visible dot sprite
|
|
660
|
+
const dotMat = new THREE.SpriteMaterial({
|
|
661
|
+
map: getSoftDotTexture(),
|
|
662
|
+
color: hex,
|
|
663
|
+
transparent: true,
|
|
664
|
+
opacity: 1.0,
|
|
665
|
+
blending: THREE.NormalBlending,
|
|
666
|
+
depthWrite: false,
|
|
667
|
+
fog: false,
|
|
668
|
+
});
|
|
669
|
+
const dot = new THREE.Sprite(dotMat);
|
|
670
|
+
dot.scale.set(dotSize, dotSize, 1);
|
|
671
|
+
dot.raycast = () => {};
|
|
672
|
+
group.add(dot);
|
|
673
|
+
|
|
674
|
+
// Small text label
|
|
675
|
+
const measureCanvas = document.createElement('canvas');
|
|
676
|
+
const measureCtx = measureCanvas.getContext('2d');
|
|
677
|
+
measureCtx.font = '500 38px "JetBrains Mono", "Space Grotesk", monospace';
|
|
678
|
+
const tagMetrics = measureCtx.measureText(catName);
|
|
679
|
+
const tagTextW = Math.ceil(tagMetrics.width);
|
|
680
|
+
const tagTextH = 48;
|
|
681
|
+
const tagPad = 12;
|
|
682
|
+
const tagCanvasW = tagTextW + tagPad * 2;
|
|
683
|
+
const tagCanvasH = tagTextH + tagPad * 2;
|
|
684
|
+
|
|
685
|
+
const labelCanvas = document.createElement('canvas');
|
|
686
|
+
const labelCtx = labelCanvas.getContext('2d');
|
|
687
|
+
labelCanvas.width = tagCanvasW;
|
|
688
|
+
labelCanvas.height = tagCanvasH;
|
|
689
|
+
labelCtx.clearRect(0, 0, tagCanvasW, tagCanvasH);
|
|
690
|
+
labelCtx.font = '500 38px "JetBrains Mono", "Space Grotesk", monospace';
|
|
691
|
+
labelCtx.textAlign = 'center';
|
|
692
|
+
labelCtx.textBaseline = 'middle';
|
|
693
|
+
labelCtx.fillStyle = color;
|
|
694
|
+
labelCtx.fillText(catName, tagCanvasW / 2, tagCanvasH / 2);
|
|
695
|
+
|
|
696
|
+
const labelTex = new THREE.CanvasTexture(labelCanvas);
|
|
697
|
+
labelTex.colorSpace = THREE.SRGBColorSpace;
|
|
698
|
+
|
|
699
|
+
// Billboard mesh with custom ShaderMaterial — bypasses SpriteMaterial pipeline
|
|
700
|
+
const TAG_SCALE = 0.25;
|
|
701
|
+
const tagLabelW = tagCanvasW * TAG_SCALE;
|
|
702
|
+
const tagLabelH = tagCanvasH * TAG_SCALE;
|
|
703
|
+
const tagLabelGeo = new THREE.PlaneGeometry(1, 1); // 1x1 base — scale set in animate loop via baseLabelScale
|
|
704
|
+
const tagLabelShaderMat = new THREE.ShaderMaterial({
|
|
705
|
+
transparent: true,
|
|
706
|
+
depthTest: false,
|
|
707
|
+
depthWrite: false,
|
|
708
|
+
fog: false,
|
|
709
|
+
uniforms: {
|
|
710
|
+
uTexture: { value: labelTex },
|
|
711
|
+
uOpacity: { value: 1.0 },
|
|
712
|
+
},
|
|
713
|
+
vertexShader: `
|
|
714
|
+
varying vec2 vUv;
|
|
715
|
+
void main() {
|
|
716
|
+
vUv = uv;
|
|
717
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
718
|
+
}
|
|
719
|
+
`,
|
|
720
|
+
fragmentShader: `
|
|
721
|
+
uniform sampler2D uTexture;
|
|
722
|
+
uniform float uOpacity;
|
|
723
|
+
varying vec2 vUv;
|
|
724
|
+
void main() {
|
|
725
|
+
vec4 tex = texture2D(uTexture, vUv);
|
|
726
|
+
if (tex.a < 0.01) discard;
|
|
727
|
+
gl_FragColor = vec4(tex.rgb, tex.a * uOpacity);
|
|
728
|
+
}
|
|
729
|
+
`,
|
|
730
|
+
});
|
|
731
|
+
const label = new THREE.Mesh(tagLabelGeo, tagLabelShaderMat);
|
|
732
|
+
label.renderOrder = 999;
|
|
733
|
+
label.position.set(0, dotSize * 0.8, 0);
|
|
734
|
+
group.add(label);
|
|
735
|
+
|
|
736
|
+
// Flat plane hitbox (shared material)
|
|
737
|
+
const hitW = Math.max(tagLabelW, dotSize);
|
|
738
|
+
const hitH = tagLabelH + dotSize;
|
|
739
|
+
const hitGeo = new THREE.PlaneGeometry(hitW, hitH);
|
|
740
|
+
const hitbox = new THREE.Mesh(hitGeo, getHitboxMaterial());
|
|
741
|
+
hitbox.position.set(0, dotSize * 0.4, 0);
|
|
742
|
+
group.add(hitbox);
|
|
743
|
+
|
|
744
|
+
group.userData.nodeId = node.id;
|
|
745
|
+
group.userData.isTag = true;
|
|
746
|
+
group.userData.tagCategory = catName;
|
|
747
|
+
group.userData.dot = dot;
|
|
748
|
+
group.userData.label = label;
|
|
749
|
+
group.userData.baseLabelScale = { x: tagLabelW, y: tagLabelH };
|
|
750
|
+
group.userData.baseLabelY = dotSize * 0.8;
|
|
751
|
+
group.userData.baseRadius = dotSize;
|
|
752
|
+
group.userData.category = catName;
|
|
753
|
+
group.userData.phase = catName.split('').reduce((a, c) => a + c.charCodeAt(0), 0) * 0.3;
|
|
754
|
+
group.userData.importance = 8;
|
|
755
|
+
group.userData.baseOpacity = 1.0;
|
|
756
|
+
|
|
757
|
+
group.userData._currentScale = 0.01;
|
|
758
|
+
group.scale.setScalar(0.01);
|
|
759
|
+
|
|
760
|
+
return group;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ═══════════════════════════════════════════
|
|
764
|
+
// MOUSE 3D POSITION — raycasting for plexus
|
|
765
|
+
// ═══════════════════════════════════════════
|
|
766
|
+
|
|
767
|
+
function _initMouseTracking() {
|
|
768
|
+
document.addEventListener('mousemove', (e) => {
|
|
769
|
+
if (_focusPaused) return;
|
|
770
|
+
// Use container bounds for correct raycasting when explorer sidebar shifts the graph
|
|
771
|
+
const rect = _graphContainer
|
|
772
|
+
? _graphContainer.getBoundingClientRect()
|
|
773
|
+
: { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
|
|
774
|
+
_mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
775
|
+
_mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
776
|
+
_mouse3DValid = true;
|
|
777
|
+
_mouseMoved = true;
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// Wheel-during-drag: move node along camera→node line of sight.
|
|
781
|
+
// Uses capture phase on the container so we intercept BEFORE OrbitControls.
|
|
782
|
+
// Also directly moves the Three.js mesh for immediate visual feedback
|
|
783
|
+
// (onNodeDrag only fires on mousemove, not on wheel scroll).
|
|
784
|
+
const wheelTarget = _graphContainer || document;
|
|
785
|
+
wheelTarget.addEventListener('wheel', (e) => {
|
|
786
|
+
if (_focusPaused) return;
|
|
787
|
+
if (!_drag.active || !_drag.node || !graph) return;
|
|
788
|
+
|
|
789
|
+
e.preventDefault();
|
|
790
|
+
e.stopImmediatePropagation();
|
|
791
|
+
|
|
792
|
+
const cam = graph.camera();
|
|
793
|
+
const node = _drag.node;
|
|
794
|
+
|
|
795
|
+
// Direction from camera to node
|
|
796
|
+
_dragDepthVec.set(node.x || 0, node.y || 0, node.z || 0)
|
|
797
|
+
.sub(cam.position).normalize();
|
|
798
|
+
|
|
799
|
+
// 5% of distance per scroll tick, floor of 5 units
|
|
800
|
+
const camDist = cam.position.distanceTo(node);
|
|
801
|
+
const step = Math.max(5, camDist * 0.05);
|
|
802
|
+
const delta = (e.deltaY < 0 ? 1 : -1) * step;
|
|
803
|
+
|
|
804
|
+
// Don't let node pass behind the camera (stop ~30 units in front)
|
|
805
|
+
const MIN_CAM_DIST = 30;
|
|
806
|
+
if (delta < 0 && camDist <= MIN_CAM_DIST) return;
|
|
807
|
+
const clampedDelta = delta < 0 ? Math.max(delta, -(camDist - MIN_CAM_DIST)) : delta;
|
|
808
|
+
|
|
809
|
+
// Compute the offset for this tick
|
|
810
|
+
const ox = _dragDepthVec.x * clampedDelta;
|
|
811
|
+
const oy = _dragDepthVec.y * clampedDelta;
|
|
812
|
+
const oz = _dragDepthVec.z * clampedDelta;
|
|
813
|
+
|
|
814
|
+
// Accumulate for drag (adds depth offset on next drag move)
|
|
815
|
+
_drag.depthOffset.x += ox;
|
|
816
|
+
_drag.depthOffset.y += oy;
|
|
817
|
+
_drag.depthOffset.z += oz;
|
|
818
|
+
|
|
819
|
+
// Directly move node data + Three.js mesh for immediate visual feedback
|
|
820
|
+
const gd = graph.graphData();
|
|
821
|
+
|
|
822
|
+
node.x = (node.x || 0) + ox;
|
|
823
|
+
node.y = (node.y || 0) + oy;
|
|
824
|
+
node.z = (node.z || 0) + oz;
|
|
825
|
+
node.fx = node.x; node.fy = node.y; node.fz = node.z;
|
|
826
|
+
const nodeObj = _anchorTagObjects.get(node.id);
|
|
827
|
+
if (nodeObj) nodeObj.position.set(node.x, node.y, node.z);
|
|
828
|
+
|
|
829
|
+
// Move rigid-body group
|
|
830
|
+
for (const n of gd.nodes) {
|
|
831
|
+
if (n === node) continue;
|
|
832
|
+
if (_drag.dragSet.has(n.id)) {
|
|
833
|
+
n.x = (n.x || 0) + ox;
|
|
834
|
+
n.y = (n.y || 0) + oy;
|
|
835
|
+
n.z = (n.z || 0) + oz;
|
|
836
|
+
n.fx = n.x; n.fy = n.y; n.fz = n.z;
|
|
837
|
+
const nObj = _anchorTagObjects.get(n.id);
|
|
838
|
+
if (nObj) nObj.position.set(n.x, n.y, n.z);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Keep prevPos in sync + mark dirty
|
|
843
|
+
_drag.prevPos = { x: node.x, y: node.y, z: node.z };
|
|
844
|
+
_linkPosDirty = true;
|
|
845
|
+
_instancesDirty = true;
|
|
846
|
+
}, { capture: true, passive: false });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function updateMouse3D() {
|
|
850
|
+
if (!graph || !_mouse3DValid) return;
|
|
851
|
+
const cam = graph.camera();
|
|
852
|
+
_raycaster.setFromCamera(_mouse, cam);
|
|
853
|
+
const controls = graph.controls();
|
|
854
|
+
const target = controls && controls.target ? controls.target : _mouseDir.set(0, 0, 0);
|
|
855
|
+
_mousePlane.setFromNormalAndCoplanarPoint(
|
|
856
|
+
cam.getWorldDirection(_mouseDir).negate(),
|
|
857
|
+
target
|
|
858
|
+
);
|
|
859
|
+
_raycaster.ray.intersectPlane(_mousePlane, _mouse3D);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ═══════════════════════════════════════════
|
|
863
|
+
// NODE POSITION PERSISTENCE
|
|
864
|
+
// ═══════════════════════════════════════════
|
|
865
|
+
let _posSaveTimer = null;
|
|
866
|
+
|
|
867
|
+
export function saveNodePositions() {
|
|
868
|
+
if (!graph) return;
|
|
869
|
+
const gd = graph.graphData();
|
|
870
|
+
const positions = {};
|
|
871
|
+
for (const n of gd.nodes) {
|
|
872
|
+
if (n.x != null) {
|
|
873
|
+
positions[n.id] = { x: n.x, y: n.y, z: n.z };
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
try { storage.setItem(_posStorageKey, JSON.stringify(positions)); } catch (_) {}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function restoreNodePositions(nodes) {
|
|
880
|
+
try {
|
|
881
|
+
const saved = JSON.parse(storage.getItem(_posStorageKey) || '{}');
|
|
882
|
+
if (!Object.keys(saved).length) return;
|
|
883
|
+
for (const n of nodes) {
|
|
884
|
+
if (saved[n.id]) {
|
|
885
|
+
const pos = saved[n.id];
|
|
886
|
+
n.x = pos.x; n.y = pos.y; n.z = pos.z;
|
|
887
|
+
n.fx = pos.x; n.fy = pos.y; n.fz = pos.z;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
} catch (_) {}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function schedulePositionSave() {
|
|
894
|
+
if (_posSaveTimer) clearTimeout(_posSaveTimer);
|
|
895
|
+
_posSaveTimer = setTimeout(saveNodePositions, 2000);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ═══════════════════════════════════════════
|
|
899
|
+
// BATCH LINK RENDERER — single draw call
|
|
900
|
+
// ═══════════════════════════════════════════
|
|
901
|
+
function initLinkBatch() {
|
|
902
|
+
if (!graph) return;
|
|
903
|
+
_linkBatchGeo = new THREE.BufferGeometry();
|
|
904
|
+
_linkBatchGeo.setAttribute('position', new THREE.BufferAttribute(_linkBatchPositions, 3));
|
|
905
|
+
_linkBatchGeo.setAttribute('color', new THREE.BufferAttribute(_linkBatchColors, 3));
|
|
906
|
+
_linkBatchGeo.setDrawRange(0, 0);
|
|
907
|
+
const mat = new THREE.LineBasicMaterial({
|
|
908
|
+
vertexColors: true, transparent: true,
|
|
909
|
+
opacity: gfx.linkOpacity || 0.12, depthWrite: false,
|
|
910
|
+
});
|
|
911
|
+
_linkBatch = new THREE.LineSegments(_linkBatchGeo, mat);
|
|
912
|
+
_linkBatch.frustumCulled = false;
|
|
913
|
+
_linkBatch.renderOrder = -1;
|
|
914
|
+
graph.scene().add(_linkBatch);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ═══════════════════════════════════════════
|
|
918
|
+
// DRAG SET BUILDER — determines rigid-body group
|
|
919
|
+
// ═══════════════════════════════════════════
|
|
920
|
+
function _buildDragSet(node, allNodes) {
|
|
921
|
+
const dragSet = new Set();
|
|
922
|
+
dragSet.add(node.id);
|
|
923
|
+
|
|
924
|
+
if (node.payload && node.payload._isAnchor) {
|
|
925
|
+
const anchorCat = node.payload._anchorCategory;
|
|
926
|
+
const childCats = Object.entries(state.categoryMetadata)
|
|
927
|
+
.filter(([, m]) => m.parent === anchorCat)
|
|
928
|
+
.map(([name]) => name);
|
|
929
|
+
const treeCats = new Set([anchorCat, ...childCats]);
|
|
930
|
+
for (const n of allNodes) {
|
|
931
|
+
if (n === node) continue;
|
|
932
|
+
if (n.payload._isTag && treeCats.has(n.payload._tagCategory)) dragSet.add(n.id);
|
|
933
|
+
if (!n.payload._isAnchor && !n.payload._isTag && treeCats.has(n.payload.category)) dragSet.add(n.id);
|
|
934
|
+
}
|
|
935
|
+
return { type: 'anchor', dragSet };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (node.payload && node.payload._isTag) {
|
|
939
|
+
const tagCat = node.payload._tagCategory;
|
|
940
|
+
for (const n of allNodes) {
|
|
941
|
+
if (!n.payload._isAnchor && !n.payload._isTag && n.payload.category === tagCat) {
|
|
942
|
+
dragSet.add(n.id);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return { type: 'tag', dragSet };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Multi-select drag
|
|
949
|
+
if (state.multiSelected.size > 0 && state.multiSelected.has(node.id)) {
|
|
950
|
+
for (const id of state.multiSelected) dragSet.add(id);
|
|
951
|
+
return { type: 'multi', dragSet };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return { type: 'memory', dragSet };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ═══════════════════════════════════════════
|
|
958
|
+
// LAYOUT COMPUTATION — geodesic sphere packing
|
|
959
|
+
// ═══════════════════════════════════════════
|
|
960
|
+
function computeLayout(nodes) {
|
|
961
|
+
const anchors = nodes.filter(n => n.payload._isAnchor);
|
|
962
|
+
const tags = nodes.filter(n => n.payload._isTag);
|
|
963
|
+
const memories = nodes.filter(n => !n.payload._isAnchor && !n.payload._isTag);
|
|
964
|
+
const placed = new Set();
|
|
965
|
+
|
|
966
|
+
const GOLDEN = (1 + Math.sqrt(5)) / 2;
|
|
967
|
+
|
|
968
|
+
function fibSphere(n, radius, center) {
|
|
969
|
+
const points = [];
|
|
970
|
+
for (let i = 0; i < n; i++) {
|
|
971
|
+
const y = 1 - (2 * i + 1) / n;
|
|
972
|
+
const r = Math.sqrt(1 - y * y);
|
|
973
|
+
const theta = 2 * Math.PI * i / GOLDEN;
|
|
974
|
+
points.push({
|
|
975
|
+
x: center.x + Math.cos(theta) * r * radius,
|
|
976
|
+
y: center.y + y * radius,
|
|
977
|
+
z: center.z + Math.sin(theta) * r * radius,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
return points;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// 1. Anchors in a wide ring
|
|
984
|
+
const anchorRadius = anchors.length > 1 ? 700 + anchors.length * 200 : 0;
|
|
985
|
+
const ANCHOR_Y = 500;
|
|
986
|
+
const TAG_Y = ANCHOR_Y - 220;
|
|
987
|
+
const SPHERE_GAP = 160;
|
|
988
|
+
|
|
989
|
+
anchors.forEach((anchor, i) => {
|
|
990
|
+
const angle = (i / anchors.length) * Math.PI * 2 - Math.PI / 2;
|
|
991
|
+
anchor.x = Math.cos(angle) * anchorRadius;
|
|
992
|
+
anchor.y = ANCHOR_Y;
|
|
993
|
+
anchor.z = Math.sin(angle) * anchorRadius;
|
|
994
|
+
anchor.fx = anchor.x; anchor.fy = anchor.y; anchor.fz = anchor.z;
|
|
995
|
+
placed.add(anchor.id);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// 2. For each anchor: child tags in rings, each with memory sphere
|
|
999
|
+
anchors.forEach(anchor => {
|
|
1000
|
+
const cat = anchor.payload._anchorCategory;
|
|
1001
|
+
const childTags = tags.filter(t => t.payload._parentAnchor === cat);
|
|
1002
|
+
const directMems = memories.filter(m => m.payload.category === cat && !placed.has(m.id));
|
|
1003
|
+
|
|
1004
|
+
if (childTags.length) {
|
|
1005
|
+
const tagRingRadius = Math.max(380, childTags.length * 160);
|
|
1006
|
+
|
|
1007
|
+
childTags.forEach((tag, i) => {
|
|
1008
|
+
const angle = (i / childTags.length) * Math.PI * 2;
|
|
1009
|
+
tag.x = anchor.x + Math.cos(angle) * tagRingRadius;
|
|
1010
|
+
tag.y = TAG_Y;
|
|
1011
|
+
tag.z = anchor.z + Math.sin(angle) * tagRingRadius;
|
|
1012
|
+
tag.fx = tag.x; tag.fy = tag.y; tag.fz = tag.z;
|
|
1013
|
+
placed.add(tag.id);
|
|
1014
|
+
|
|
1015
|
+
const tagCat = tag.payload._tagCategory;
|
|
1016
|
+
const tagMems = memories.filter(m => m.payload.category === tagCat && !placed.has(m.id));
|
|
1017
|
+
if (tagMems.length) {
|
|
1018
|
+
const r = 90 + Math.sqrt(tagMems.length) * 18;
|
|
1019
|
+
const sphereCenter = { x: tag.x, y: tag.y - r - SPHERE_GAP, z: tag.z };
|
|
1020
|
+
const positions = fibSphere(tagMems.length, r, sphereCenter);
|
|
1021
|
+
tagMems.forEach((mem, j) => {
|
|
1022
|
+
mem.x = positions[j].x;
|
|
1023
|
+
mem.y = positions[j].y;
|
|
1024
|
+
mem.z = positions[j].z;
|
|
1025
|
+
mem.fx = mem.x; mem.fy = mem.y; mem.fz = mem.z;
|
|
1026
|
+
placed.add(mem.id);
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Direct-parent memories
|
|
1033
|
+
if (directMems.length) {
|
|
1034
|
+
const r = 90 + Math.sqrt(directMems.length) * 18;
|
|
1035
|
+
const sphereCenter = { x: anchor.x, y: TAG_Y - r - SPHERE_GAP, z: anchor.z };
|
|
1036
|
+
const positions = fibSphere(directMems.length, r, sphereCenter);
|
|
1037
|
+
directMems.forEach((mem, i) => {
|
|
1038
|
+
mem.x = positions[i].x;
|
|
1039
|
+
mem.y = positions[i].y;
|
|
1040
|
+
mem.z = positions[i].z;
|
|
1041
|
+
mem.fx = mem.x; mem.fy = mem.y; mem.fz = mem.z;
|
|
1042
|
+
placed.add(mem.id);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// 3. Orphan memories
|
|
1048
|
+
const orphans = memories.filter(m => !placed.has(m.id));
|
|
1049
|
+
if (orphans.length) {
|
|
1050
|
+
const catGroups = {};
|
|
1051
|
+
orphans.forEach(m => {
|
|
1052
|
+
const c = m.payload.category || 'uncategorized';
|
|
1053
|
+
if (!catGroups[c]) catGroups[c] = [];
|
|
1054
|
+
catGroups[c].push(m);
|
|
1055
|
+
});
|
|
1056
|
+
const groupNames = Object.keys(catGroups);
|
|
1057
|
+
const orphanBase = anchorRadius + 400;
|
|
1058
|
+
|
|
1059
|
+
groupNames.forEach((c, gi) => {
|
|
1060
|
+
const angle = (gi / Math.max(groupNames.length, 1)) * Math.PI * 2;
|
|
1061
|
+
const cx = Math.cos(angle) * orphanBase;
|
|
1062
|
+
const cz = Math.sin(angle) * orphanBase;
|
|
1063
|
+
const mems = catGroups[c];
|
|
1064
|
+
const r = 90 + Math.sqrt(mems.length) * 18;
|
|
1065
|
+
const positions = fibSphere(mems.length, r, { x: cx, y: 0, z: cz });
|
|
1066
|
+
|
|
1067
|
+
mems.forEach((mem, i) => {
|
|
1068
|
+
mem.x = positions[i].x;
|
|
1069
|
+
mem.y = positions[i].y;
|
|
1070
|
+
mem.z = positions[i].z;
|
|
1071
|
+
mem.fx = mem.x; mem.fy = mem.y; mem.fz = mem.z;
|
|
1072
|
+
placed.add(mem.id);
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Floor clamp
|
|
1078
|
+
const NODE_FLOOR = FLOOR_Y + 10;
|
|
1079
|
+
for (const n of nodes) {
|
|
1080
|
+
if (n.y < NODE_FLOOR) {
|
|
1081
|
+
n.y = NODE_FLOOR;
|
|
1082
|
+
if (n.fy !== undefined) n.fy = NODE_FLOOR;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ═══════════════════════════════════════════
|
|
1088
|
+
// APPLY GRAPH DATA — inject anchors/tags, compute layout
|
|
1089
|
+
// ═══════════════════════════════════════════
|
|
1090
|
+
export function applyGraphData() {
|
|
1091
|
+
if (!graph) return;
|
|
1092
|
+
|
|
1093
|
+
let visibleNodes = state.allNodes.filter(n => state.activeCategories.has(n.payload.category));
|
|
1094
|
+
let visibleIds = new Set(visibleNodes.map(n => n.id));
|
|
1095
|
+
const nodeCatMap = {};
|
|
1096
|
+
for (const n of visibleNodes) nodeCatMap[n.id] = n.payload.category;
|
|
1097
|
+
|
|
1098
|
+
// Separate same-category links from cross-category
|
|
1099
|
+
const sameCatLinks = [];
|
|
1100
|
+
_crossCategoryLinks = [];
|
|
1101
|
+
for (const l of state.allLinks) {
|
|
1102
|
+
const srcId = l.source.id || l.source;
|
|
1103
|
+
const tgtId = l.target.id || l.target;
|
|
1104
|
+
if (!visibleIds.has(srcId) || !visibleIds.has(tgtId)) continue;
|
|
1105
|
+
if (nodeCatMap[srcId] === nodeCatMap[tgtId]) {
|
|
1106
|
+
sameCatLinks.push(l);
|
|
1107
|
+
} else {
|
|
1108
|
+
_crossCategoryLinks.push({ ...l, _crossCategory: true });
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
_visualLinks = state.linkMode === 'all'
|
|
1113
|
+
? [...sameCatLinks, ..._crossCategoryLinks]
|
|
1114
|
+
: [...sameCatLinks];
|
|
1115
|
+
let visibleLinks = [];
|
|
1116
|
+
|
|
1117
|
+
// Anchor / Tag hierarchy injection
|
|
1118
|
+
const injectedNodes = [];
|
|
1119
|
+
const injectedLinks = [];
|
|
1120
|
+
const parentCats = Object.entries(state.categoryMetadata).filter(([, meta]) => meta.is_parent);
|
|
1121
|
+
|
|
1122
|
+
const nodesByCat = new Map();
|
|
1123
|
+
for (const n of visibleNodes) {
|
|
1124
|
+
const cat = n.payload.category;
|
|
1125
|
+
let arr = nodesByCat.get(cat);
|
|
1126
|
+
if (!arr) { arr = []; nodesByCat.set(cat, arr); }
|
|
1127
|
+
arr.push(n);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
for (const [parentName] of parentCats) {
|
|
1131
|
+
const childCats = Object.entries(state.categoryMetadata)
|
|
1132
|
+
.filter(([, m]) => m.parent === parentName)
|
|
1133
|
+
.map(([name]) => name);
|
|
1134
|
+
|
|
1135
|
+
const parentMems = nodesByCat.get(parentName) || [];
|
|
1136
|
+
let hasTreeMemories = parentMems.length > 0;
|
|
1137
|
+
if (!hasTreeMemories) {
|
|
1138
|
+
for (const c of childCats) { if (nodesByCat.has(c)) { hasTreeMemories = true; break; } }
|
|
1139
|
+
}
|
|
1140
|
+
if (!hasTreeMemories) continue;
|
|
1141
|
+
|
|
1142
|
+
const anchorId = `_anchor_${parentName}`;
|
|
1143
|
+
injectedNodes.push({
|
|
1144
|
+
id: anchorId,
|
|
1145
|
+
payload: { category: parentName, _isAnchor: true, _anchorCategory: parentName, importance: 10, content: parentName }
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
for (const childName of childCats) {
|
|
1149
|
+
const childMemories = nodesByCat.get(childName) || [];
|
|
1150
|
+
if (childMemories.length === 0) continue;
|
|
1151
|
+
|
|
1152
|
+
const tagId = `_tag_${childName}`;
|
|
1153
|
+
injectedNodes.push({
|
|
1154
|
+
id: tagId,
|
|
1155
|
+
payload: { category: childName, _isTag: true, _tagCategory: childName, _parentAnchor: parentName, importance: 8, content: childName }
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
injectedLinks.push({ source: anchorId, target: tagId, strength: 0.3, _isStructural: true });
|
|
1159
|
+
|
|
1160
|
+
for (const mem of childMemories) {
|
|
1161
|
+
injectedLinks.push({ source: tagId, target: mem.id, strength: 0.5, _isStructural: true });
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
for (const mem of parentMems) {
|
|
1166
|
+
injectedLinks.push({ source: anchorId, target: mem.id, strength: 0.4, _isStructural: true });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
visibleNodes = [...injectedNodes, ...visibleNodes];
|
|
1171
|
+
visibleIds = new Set(visibleNodes.map(n => n.id));
|
|
1172
|
+
visibleLinks = injectedLinks;
|
|
1173
|
+
|
|
1174
|
+
// Build node lookup for batch link renderer
|
|
1175
|
+
_nodeById = new Map();
|
|
1176
|
+
for (const n of visibleNodes) _nodeById.set(n.id, n);
|
|
1177
|
+
|
|
1178
|
+
// Focus mode
|
|
1179
|
+
if (state.focusedNodeId && visibleIds.has(state.focusedNodeId)) {
|
|
1180
|
+
const neighborIds = new Set([state.focusedNodeId]);
|
|
1181
|
+
const allLinksForFocus = [...visibleLinks, ..._visualLinks];
|
|
1182
|
+
allLinksForFocus.forEach(l => {
|
|
1183
|
+
const srcId = l.source.id || l.source;
|
|
1184
|
+
const tgtId = l.target.id || l.target;
|
|
1185
|
+
if (srcId === state.focusedNodeId) neighborIds.add(tgtId);
|
|
1186
|
+
if (tgtId === state.focusedNodeId) neighborIds.add(srcId);
|
|
1187
|
+
});
|
|
1188
|
+
visibleNodes = visibleNodes.filter(n => neighborIds.has(n.id));
|
|
1189
|
+
visibleIds = neighborIds;
|
|
1190
|
+
visibleLinks = visibleLinks.filter(l => {
|
|
1191
|
+
const srcId = l.source.id || l.source;
|
|
1192
|
+
const tgtId = l.target.id || l.target;
|
|
1193
|
+
return neighborIds.has(srcId) && neighborIds.has(tgtId);
|
|
1194
|
+
});
|
|
1195
|
+
_visualLinks = _visualLinks.filter(l => {
|
|
1196
|
+
const srcId = l.source.id || l.source;
|
|
1197
|
+
const tgtId = l.target.id || l.target;
|
|
1198
|
+
return neighborIds.has(srcId) && neighborIds.has(tgtId);
|
|
1199
|
+
});
|
|
1200
|
+
_nodeById = new Map();
|
|
1201
|
+
for (const n of visibleNodes) _nodeById.set(n.id, n);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Restore saved positions or compute layout
|
|
1205
|
+
const savedPositions = JSON.parse(storage.getItem(_posStorageKey) || '{}');
|
|
1206
|
+
const hasSaved = Object.keys(savedPositions).length > 0;
|
|
1207
|
+
|
|
1208
|
+
if (hasSaved) {
|
|
1209
|
+
restoreNodePositions(visibleNodes);
|
|
1210
|
+
} else {
|
|
1211
|
+
computeLayout(visibleNodes);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
graph.graphData({ nodes: visibleNodes, links: visibleLinks });
|
|
1215
|
+
|
|
1216
|
+
// ── Sync anchor/tag Three.js objects with scene ──
|
|
1217
|
+
const currentIds = new Set(visibleNodes.map(n => n.id));
|
|
1218
|
+
|
|
1219
|
+
// Remove objects for nodes no longer present
|
|
1220
|
+
for (const [id, obj] of _anchorTagObjects) {
|
|
1221
|
+
if (!currentIds.has(id)) {
|
|
1222
|
+
_scene.remove(obj);
|
|
1223
|
+
// Dispose textures from sprites
|
|
1224
|
+
obj.traverse(child => {
|
|
1225
|
+
if (child.material) {
|
|
1226
|
+
if (child.material.map) child.material.map.dispose();
|
|
1227
|
+
child.material.dispose();
|
|
1228
|
+
}
|
|
1229
|
+
if (child.geometry) child.geometry.dispose();
|
|
1230
|
+
});
|
|
1231
|
+
_anchorTagObjects.delete(id);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Add/update anchor/tag objects
|
|
1236
|
+
for (const n of visibleNodes) {
|
|
1237
|
+
if (!n.payload || (!n.payload._isAnchor && !n.payload._isTag)) continue;
|
|
1238
|
+
|
|
1239
|
+
const existing = _anchorTagObjects.get(n.id);
|
|
1240
|
+
if (existing) {
|
|
1241
|
+
// Already exists — update position
|
|
1242
|
+
existing.position.set(n.x || 0, n.y || 0, n.z || 0);
|
|
1243
|
+
n.__threeObj = existing; // keep compat for animate loop
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Create new anchor/tag Three.js object
|
|
1248
|
+
let obj;
|
|
1249
|
+
if (n.payload._isAnchor) obj = createAnchorObject(n);
|
|
1250
|
+
else if (n.payload._isTag) obj = createTagObject(n);
|
|
1251
|
+
|
|
1252
|
+
if (obj) {
|
|
1253
|
+
obj.position.set(n.x || 0, n.y || 0, n.z || 0);
|
|
1254
|
+
_scene.add(obj);
|
|
1255
|
+
_anchorTagObjects.set(n.id, obj);
|
|
1256
|
+
n.__threeObj = obj; // compat for animate loop
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Rebuild flat array for fast iteration in animate()
|
|
1261
|
+
_anchorTagArray = Array.from(_anchorTagObjects.values());
|
|
1262
|
+
|
|
1263
|
+
// Rebuild instance mapping for instanced memory node rendering
|
|
1264
|
+
rebuildInstanceMap();
|
|
1265
|
+
|
|
1266
|
+
_linkBatchDirty = true;
|
|
1267
|
+
_linkPosDirty = true;
|
|
1268
|
+
|
|
1269
|
+
// Pin any unpositioned nodes
|
|
1270
|
+
for (const n of visibleNodes) {
|
|
1271
|
+
if (n.fx == null && n.x != null) { n.fx = n.x; n.fy = n.y; n.fz = n.z; }
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
schedulePositionSave();
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// ═══════════════════════════════════════════
|
|
1278
|
+
// LINK VISIBILITY
|
|
1279
|
+
// ═══════════════════════════════════════════
|
|
1280
|
+
export function applyLinkVisibility() {
|
|
1281
|
+
_linkBatchDirty = true;
|
|
1282
|
+
_linkPosDirty = true;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
let _linksFetching = false;
|
|
1286
|
+
export async function setLinkMode(mode) {
|
|
1287
|
+
state.linkMode = mode;
|
|
1288
|
+
if (mode === 'off') {
|
|
1289
|
+
noLinksScaleBoost = 1.4;
|
|
1290
|
+
applyLinkVisibility();
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
noLinksScaleBoost = 1.0;
|
|
1295
|
+
|
|
1296
|
+
// Lazy-load links from server on first enable
|
|
1297
|
+
if (state.allLinks.length === 0 && !_linksFetching) {
|
|
1298
|
+
_linksFetching = true;
|
|
1299
|
+
try {
|
|
1300
|
+
const data = await fetchLinks();
|
|
1301
|
+
if (data.links) {
|
|
1302
|
+
state.allLinks = data.links;
|
|
1303
|
+
emit('stats-changed');
|
|
1304
|
+
}
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
console.error('Failed to fetch links:', err);
|
|
1307
|
+
} finally {
|
|
1308
|
+
_linksFetching = false;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
applyGraphData();
|
|
1313
|
+
applyLinkVisibility();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export function setLinkTypeFilter(filter) {
|
|
1317
|
+
state.linkTypeFilter = filter;
|
|
1318
|
+
applyLinkVisibility();
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// ═══════════════════════════════════════════
|
|
1322
|
+
// GRAPH REMOVAL SCHEDULING
|
|
1323
|
+
// ═══════════════════════════════════════════
|
|
1324
|
+
export function scheduleGraphRemoval(delay = 600) {
|
|
1325
|
+
_linkBatchDirty = true;
|
|
1326
|
+
_linkPosDirty = true;
|
|
1327
|
+
if (graphRemovalTimer) clearTimeout(graphRemovalTimer);
|
|
1328
|
+
graphRemovalTimer = setTimeout(() => {
|
|
1329
|
+
graphRemovalTimer = null;
|
|
1330
|
+
applyGraphData();
|
|
1331
|
+
emit('stats-changed');
|
|
1332
|
+
}, delay);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
export function cancelScheduledRemoval() {
|
|
1336
|
+
if (graphRemovalTimer) {
|
|
1337
|
+
clearTimeout(graphRemovalTimer);
|
|
1338
|
+
graphRemovalTimer = null;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ═══════════════════════════════════════════
|
|
1343
|
+
// INTERACTIONS — hover, click, drag
|
|
1344
|
+
// ═══════════════════════════════════════════
|
|
1345
|
+
|
|
1346
|
+
function _initUIGuards() {
|
|
1347
|
+
document.querySelectorAll('.glass, #title-bar, #search-container, #stats-bar').forEach(el => {
|
|
1348
|
+
el.addEventListener('pointerenter', () => { _pointerOverUI = true; }, true);
|
|
1349
|
+
el.addEventListener('pointerleave', () => { _pointerOverUI = false; }, true);
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// ═══════════════════════════════════════════
|
|
1354
|
+
// CUSTOM POINTER EVENTS + RAYCASTING
|
|
1355
|
+
// Replaces ForceGraph3D's onNodeHover/Click/Drag
|
|
1356
|
+
// ═══════════════════════════════════════════
|
|
1357
|
+
|
|
1358
|
+
function _raycastMemoryNodes(ray) {
|
|
1359
|
+
// O(N) ray-sphere intersection against memory node positions.
|
|
1360
|
+
// No scene graph objects — pure math. Returns { node, dist } or null.
|
|
1361
|
+
let bestDist = Infinity;
|
|
1362
|
+
let bestNode = null;
|
|
1363
|
+
const ox = ray.origin.x, oy = ray.origin.y, oz = ray.origin.z;
|
|
1364
|
+
const dx = ray.direction.x, dy = ray.direction.y, dz = ray.direction.z;
|
|
1365
|
+
|
|
1366
|
+
for (let gi = 0, len = _glowNodeOrder.length; gi < len; gi++) {
|
|
1367
|
+
if (_nodeScale[gi] <= 0.01) continue; // hidden
|
|
1368
|
+
const node = _glowNodeOrder[gi];
|
|
1369
|
+
const radius = _nodeBaseRadius[gi] * _nodeScale[gi] * 1.2; // 1.2 = hit margin
|
|
1370
|
+
const cx = node.x || 0, cy = node.y || 0, cz = node.z || 0;
|
|
1371
|
+
|
|
1372
|
+
// Ray-sphere intersection (algebraic form)
|
|
1373
|
+
const ecx = ox - cx, ecy = oy - cy, ecz = oz - cz;
|
|
1374
|
+
const b = ecx * dx + ecy * dy + ecz * dz;
|
|
1375
|
+
const c = ecx * ecx + ecy * ecy + ecz * ecz - radius * radius;
|
|
1376
|
+
const disc = b * b - c;
|
|
1377
|
+
if (disc < 0) continue;
|
|
1378
|
+
const dist = -b - Math.sqrt(disc);
|
|
1379
|
+
if (dist < 0 || dist >= bestDist) continue;
|
|
1380
|
+
bestDist = dist;
|
|
1381
|
+
bestNode = node;
|
|
1382
|
+
}
|
|
1383
|
+
return bestNode ? { node: bestNode, dist: bestDist } : null;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function _pickNode(mouseNDC) {
|
|
1387
|
+
// Two-pass picking: anchor/tag objects first (standard Three.js), then memory nodes (ray-sphere)
|
|
1388
|
+
_raycaster.setFromCamera(mouseNDC, _camera);
|
|
1389
|
+
const ray = _raycaster.ray;
|
|
1390
|
+
|
|
1391
|
+
// Pass 1: anchor/tag objects (~50-100 Three.js Groups with PlaneGeometry hitboxes)
|
|
1392
|
+
if (_anchorTagArray.length > 0) {
|
|
1393
|
+
const hits = _raycaster.intersectObjects(_anchorTagArray, true);
|
|
1394
|
+
if (hits.length > 0) {
|
|
1395
|
+
let obj = hits[0].object;
|
|
1396
|
+
while (obj && !obj.userData.nodeId) obj = obj.parent;
|
|
1397
|
+
if (obj && obj.userData.nodeId) {
|
|
1398
|
+
const node = _nodeById.get(obj.userData.nodeId);
|
|
1399
|
+
if (node) return { node, dist: hits[0].distance };
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Pass 2: memory nodes (ray-sphere math — no scene objects)
|
|
1405
|
+
return _raycastMemoryNodes(ray);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
let _prevHoveredNode = null;
|
|
1409
|
+
|
|
1410
|
+
function _onPointerMove(e) {
|
|
1411
|
+
if (_focusPaused || _pointerOverUI) return;
|
|
1412
|
+
|
|
1413
|
+
const rect = _graphContainer
|
|
1414
|
+
? _graphContainer.getBoundingClientRect()
|
|
1415
|
+
: { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
|
|
1416
|
+
_mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
1417
|
+
_mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
1418
|
+
|
|
1419
|
+
if (_drag.active) {
|
|
1420
|
+
_handleDragMove(e);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Hover detection
|
|
1425
|
+
const hit = _pickNode(_mouse);
|
|
1426
|
+
const hoveredNode = hit ? hit.node : null;
|
|
1427
|
+
if (hoveredNode !== _prevHoveredNode) {
|
|
1428
|
+
handleNodeHover(hoveredNode, _prevHoveredNode);
|
|
1429
|
+
_prevHoveredNode = hoveredNode;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function _onPointerDown(e) {
|
|
1434
|
+
if (_focusPaused || e.button !== 0 || _pointerOverUI) return;
|
|
1435
|
+
_pointerDownPos = { x: e.clientX, y: e.clientY };
|
|
1436
|
+
_pointerDownTime = performance.now();
|
|
1437
|
+
|
|
1438
|
+
const hit = _pickNode(_mouse);
|
|
1439
|
+
_pointerDownNode = hit ? hit.node : null;
|
|
1440
|
+
|
|
1441
|
+
if (_pointerDownNode) {
|
|
1442
|
+
_startDrag(_pointerDownNode, e);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function _onPointerUp(e) {
|
|
1447
|
+
if (_focusPaused || e.button !== 0) return;
|
|
1448
|
+
|
|
1449
|
+
if (_drag.active) {
|
|
1450
|
+
const wasDrag = _pointerDownPos
|
|
1451
|
+
? Math.sqrt(
|
|
1452
|
+
(e.clientX - _pointerDownPos.x) ** 2 +
|
|
1453
|
+
(e.clientY - _pointerDownPos.y) ** 2
|
|
1454
|
+
) >= 5
|
|
1455
|
+
: true;
|
|
1456
|
+
|
|
1457
|
+
_endDrag();
|
|
1458
|
+
|
|
1459
|
+
// If pointer barely moved, treat as click on the originally picked node
|
|
1460
|
+
if (!wasDrag && _pointerDownNode) {
|
|
1461
|
+
handleNodeClick(_pointerDownNode, e);
|
|
1462
|
+
}
|
|
1463
|
+
_pointerDownPos = null;
|
|
1464
|
+
_pointerDownNode = null;
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
if (!_pointerDownPos) return;
|
|
1469
|
+
const dx = e.clientX - _pointerDownPos.x;
|
|
1470
|
+
const dy = e.clientY - _pointerDownPos.y;
|
|
1471
|
+
const dt = performance.now() - _pointerDownTime;
|
|
1472
|
+
const isClick = Math.sqrt(dx * dx + dy * dy) < 5 && dt < 500;
|
|
1473
|
+
|
|
1474
|
+
if (isClick) {
|
|
1475
|
+
const hit = _pickNode(_mouse);
|
|
1476
|
+
if (hit) {
|
|
1477
|
+
handleNodeClick(hit.node, e);
|
|
1478
|
+
} else {
|
|
1479
|
+
handleBackgroundClick();
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
_pointerDownPos = null;
|
|
1483
|
+
_pointerDownNode = null;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function _startDrag(node, e) {
|
|
1487
|
+
// Drag plane: perpendicular to camera, passing through node position
|
|
1488
|
+
const nodePos = _dragHit.set(node.x || 0, node.y || 0, node.z || 0);
|
|
1489
|
+
const camDir = _camera.getWorldDirection(_mouseDir);
|
|
1490
|
+
_dragPlane.setFromNormalAndCoplanarPoint(camDir, nodePos);
|
|
1491
|
+
|
|
1492
|
+
const { type, dragSet } = _buildDragSet(node, _graphNodes);
|
|
1493
|
+
_drag.active = true;
|
|
1494
|
+
_drag.node = node;
|
|
1495
|
+
_drag.type = type;
|
|
1496
|
+
_drag.dragSet = dragSet;
|
|
1497
|
+
_drag.depthOffset.set(0, 0, 0);
|
|
1498
|
+
_drag.prevPos = { x: node.x || 0, y: node.y || 0, z: node.z || 0 };
|
|
1499
|
+
_instancesDirty = true;
|
|
1500
|
+
document.body.style.cursor = 'grabbing';
|
|
1501
|
+
_controls.enabled = false; // Disable orbit during drag
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function _handleDragMove(e) {
|
|
1505
|
+
if (!_drag.active || !_drag.node) return;
|
|
1506
|
+
|
|
1507
|
+
_raycaster.setFromCamera(_mouse, _camera);
|
|
1508
|
+
if (!_raycaster.ray.intersectPlane(_dragPlane, _dragHit)) return;
|
|
1509
|
+
|
|
1510
|
+
const newX = _dragHit.x + _drag.depthOffset.x;
|
|
1511
|
+
const newY = _dragHit.y + _drag.depthOffset.y;
|
|
1512
|
+
const newZ = _dragHit.z + _drag.depthOffset.z;
|
|
1513
|
+
const node = _drag.node;
|
|
1514
|
+
|
|
1515
|
+
if (_drag.prevPos) {
|
|
1516
|
+
const ddx = newX - _drag.prevPos.x;
|
|
1517
|
+
const ddy = newY - _drag.prevPos.y;
|
|
1518
|
+
const ddz = newZ - _drag.prevPos.z;
|
|
1519
|
+
|
|
1520
|
+
if (ddx !== 0 || ddy !== 0 || ddz !== 0) {
|
|
1521
|
+
for (let i = 0, len = _graphNodes.length; i < len; i++) {
|
|
1522
|
+
const n = _graphNodes[i];
|
|
1523
|
+
if (n === node || !_drag.dragSet.has(n.id)) continue;
|
|
1524
|
+
n.x = (n.x || 0) + ddx;
|
|
1525
|
+
n.y = (n.y || 0) + ddy;
|
|
1526
|
+
n.z = (n.z || 0) + ddz;
|
|
1527
|
+
n.fx = n.x; n.fy = n.y; n.fz = n.z;
|
|
1528
|
+
// Move anchor/tag Three.js object if exists
|
|
1529
|
+
const obj = _anchorTagObjects.get(n.id);
|
|
1530
|
+
if (obj) obj.position.set(n.x, n.y, n.z);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
node.x = newX; node.y = newY; node.z = newZ;
|
|
1536
|
+
node.fx = node.x; node.fy = node.y; node.fz = node.z;
|
|
1537
|
+
const nodeObj = _anchorTagObjects.get(node.id);
|
|
1538
|
+
if (nodeObj) nodeObj.position.set(node.x, node.y, node.z);
|
|
1539
|
+
|
|
1540
|
+
_drag.prevPos = { x: newX, y: newY, z: newZ };
|
|
1541
|
+
_linkPosDirty = true;
|
|
1542
|
+
_instancesDirty = true;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function _endDrag() {
|
|
1546
|
+
if (!_drag.active) return;
|
|
1547
|
+
const node = _drag.node;
|
|
1548
|
+
if (node) { node.fx = node.x; node.fy = node.y; node.fz = node.z; }
|
|
1549
|
+
document.body.style.cursor = state.hoveredNodeId ? 'grab' : 'default';
|
|
1550
|
+
_drag.active = false;
|
|
1551
|
+
_drag.node = null;
|
|
1552
|
+
_drag.type = null;
|
|
1553
|
+
_drag.dragSet = new Set();
|
|
1554
|
+
_drag.prevPos = null;
|
|
1555
|
+
_drag.depthOffset.set(0, 0, 0);
|
|
1556
|
+
_controls.enabled = true; // Re-enable orbit
|
|
1557
|
+
_linkPosDirty = true;
|
|
1558
|
+
_instancesDirty = true;
|
|
1559
|
+
schedulePositionSave();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function _initPointerEvents(canvas) {
|
|
1563
|
+
canvas.addEventListener('pointermove', _onPointerMove);
|
|
1564
|
+
canvas.addEventListener('pointerdown', _onPointerDown);
|
|
1565
|
+
canvas.addEventListener('pointerup', _onPointerUp);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ═══════════════════════════════════════════
|
|
1569
|
+
|
|
1570
|
+
function handleNodeHover(node, prevNode) {
|
|
1571
|
+
const $tooltip = document.getElementById('tooltip');
|
|
1572
|
+
if (_pointerOverUI) {
|
|
1573
|
+
$tooltip.classList.remove('visible');
|
|
1574
|
+
if ($tooltip._moveHandler) {
|
|
1575
|
+
document.removeEventListener('mousemove', $tooltip._moveHandler);
|
|
1576
|
+
}
|
|
1577
|
+
document.body.style.cursor = 'default';
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
if (_drag.active) {
|
|
1581
|
+
document.body.style.cursor = 'grabbing';
|
|
1582
|
+
} else {
|
|
1583
|
+
document.body.style.cursor = node ? 'grab' : 'default';
|
|
1584
|
+
}
|
|
1585
|
+
state.hoveredNodeId = node ? node.id : null;
|
|
1586
|
+
_instancesDirty = true;
|
|
1587
|
+
|
|
1588
|
+
if (node) {
|
|
1589
|
+
const cat = node.payload.category;
|
|
1590
|
+
|
|
1591
|
+
if (node.payload._isAnchor) {
|
|
1592
|
+
document.getElementById('tooltip-category').textContent = cat.toUpperCase();
|
|
1593
|
+
document.getElementById('tooltip-category').style.color = catColor(cat);
|
|
1594
|
+
const childCount = Object.entries(state.categoryMetadata).filter(([, m]) => m.parent === cat).length;
|
|
1595
|
+
document.getElementById('tooltip-preview').textContent = `Parent category \u2022 ${childCount} sub-categories`;
|
|
1596
|
+
document.getElementById('tooltip-importance').textContent = '\u2606';
|
|
1597
|
+
} else if (node.payload._isTag) {
|
|
1598
|
+
document.getElementById('tooltip-category').textContent = cat;
|
|
1599
|
+
document.getElementById('tooltip-category').style.color = catColor(cat);
|
|
1600
|
+
const memCount = state.allNodes.filter(n => n.payload.category === cat).length;
|
|
1601
|
+
document.getElementById('tooltip-preview').textContent = `${memCount} memories`;
|
|
1602
|
+
document.getElementById('tooltip-importance').textContent = '\u25cf';
|
|
1603
|
+
} else {
|
|
1604
|
+
const imp = node.payload.importance || 5;
|
|
1605
|
+
const preview = (node.payload.content || '').length > 100
|
|
1606
|
+
? node.payload.content.slice(0, 100) + '...'
|
|
1607
|
+
: (node.payload.content || '');
|
|
1608
|
+
const catText = node.payload._isOpenClaw ? '[OC] ' + cat : cat;
|
|
1609
|
+
document.getElementById('tooltip-category').textContent = catText;
|
|
1610
|
+
document.getElementById('tooltip-category').style.color = catColor(cat);
|
|
1611
|
+
document.getElementById('tooltip-preview').textContent = preview;
|
|
1612
|
+
document.getElementById('tooltip-importance').textContent = '\u2605'.repeat(Math.min(imp, 10));
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
const moveTooltip = (e) => {
|
|
1616
|
+
$tooltip.style.left = (e.clientX + 16) + 'px';
|
|
1617
|
+
$tooltip.style.top = (e.clientY + 16) + 'px';
|
|
1618
|
+
};
|
|
1619
|
+
document.addEventListener('mousemove', moveTooltip);
|
|
1620
|
+
$tooltip._moveHandler = moveTooltip;
|
|
1621
|
+
$tooltip.classList.add('visible');
|
|
1622
|
+
} else {
|
|
1623
|
+
$tooltip.classList.remove('visible');
|
|
1624
|
+
if ($tooltip._moveHandler) {
|
|
1625
|
+
document.removeEventListener('mousemove', $tooltip._moveHandler);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function handleNodeClick(node, event) {
|
|
1631
|
+
if (!node || _pointerOverUI) return;
|
|
1632
|
+
|
|
1633
|
+
// Anchor nodes: zoom to fit entire category cluster
|
|
1634
|
+
if (node.payload._isAnchor) {
|
|
1635
|
+
const parentName = node.payload._anchorCategory;
|
|
1636
|
+
const gd = graph.graphData();
|
|
1637
|
+
const childCats = Object.entries(state.categoryMetadata)
|
|
1638
|
+
.filter(([, m]) => m.parent === parentName)
|
|
1639
|
+
.map(([name]) => name);
|
|
1640
|
+
const treeCats = new Set([parentName, ...childCats]);
|
|
1641
|
+
const treeNodes = gd.nodes.filter(n =>
|
|
1642
|
+
n.payload && (treeCats.has(n.payload.category) || n.payload._anchorCategory === parentName || (n.payload._tagCategory && treeCats.has(n.payload._tagCategory)))
|
|
1643
|
+
);
|
|
1644
|
+
if (treeNodes.length === 0) return;
|
|
1645
|
+
|
|
1646
|
+
let minX = Infinity, maxX = -Infinity;
|
|
1647
|
+
let minY = Infinity, maxY = -Infinity;
|
|
1648
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
1649
|
+
for (const n of treeNodes) {
|
|
1650
|
+
const x = n.x || 0, y = n.y || 0, z = n.z || 0;
|
|
1651
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
|
1652
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
1653
|
+
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
|
|
1654
|
+
}
|
|
1655
|
+
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2;
|
|
1656
|
+
const span = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 30);
|
|
1657
|
+
const dist = span * 1.2 + 40;
|
|
1658
|
+
graph.cameraPosition(
|
|
1659
|
+
{ x: cx + dist * 0.5, y: cy + dist * 0.35, z: cz + dist * 0.5 },
|
|
1660
|
+
{ x: cx, y: cy, z: cz },
|
|
1661
|
+
1500
|
|
1662
|
+
);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Tag nodes: zoom to show tag + its memory sphere
|
|
1667
|
+
if (node.payload._isTag) {
|
|
1668
|
+
const tagCat = node.payload._tagCategory;
|
|
1669
|
+
const gd = graph.graphData();
|
|
1670
|
+
const tagMems = gd.nodes.filter(n => n.payload && n.payload.category === tagCat && !n.payload._isAnchor && !n.payload._isTag);
|
|
1671
|
+
if (tagMems.length > 0) {
|
|
1672
|
+
let minX = node.x, maxX = node.x, minY = node.y, maxY = node.y, minZ = node.z, maxZ = node.z;
|
|
1673
|
+
for (const n of tagMems) {
|
|
1674
|
+
const x = n.x || 0, y = n.y || 0, z = n.z || 0;
|
|
1675
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
|
1676
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
1677
|
+
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
|
|
1678
|
+
}
|
|
1679
|
+
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2;
|
|
1680
|
+
const span = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 30);
|
|
1681
|
+
const dist = span * 1.1 + 30;
|
|
1682
|
+
graph.cameraPosition(
|
|
1683
|
+
{ x: cx + dist * 0.5, y: cy + dist * 0.3, z: cz + dist * 0.5 },
|
|
1684
|
+
{ x: cx, y: cy, z: cz },
|
|
1685
|
+
1500
|
|
1686
|
+
);
|
|
1687
|
+
} else {
|
|
1688
|
+
const dist = gfx.gentleZoom;
|
|
1689
|
+
graph.cameraPosition(
|
|
1690
|
+
{ x: node.x + dist, y: node.y + dist * 0.4, z: node.z + dist },
|
|
1691
|
+
{ x: node.x, y: node.y, z: node.z },
|
|
1692
|
+
1500
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// Ctrl+Click: toggle multi-select
|
|
1699
|
+
if (event && (event.ctrlKey || event.metaKey)) {
|
|
1700
|
+
if (state.multiSelected.has(node.id)) {
|
|
1701
|
+
state.multiSelected.delete(node.id);
|
|
1702
|
+
} else {
|
|
1703
|
+
state.multiSelected.add(node.id);
|
|
1704
|
+
}
|
|
1705
|
+
_instancesDirty = true;
|
|
1706
|
+
emit('multiselect:update');
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// Regular click: clear multi-select, navigate
|
|
1711
|
+
if (state.multiSelected.size > 0) {
|
|
1712
|
+
state.multiSelected.clear();
|
|
1713
|
+
_instancesDirty = true;
|
|
1714
|
+
emit('multiselect:update');
|
|
1715
|
+
}
|
|
1716
|
+
navigateToNode(node);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
export function navigateToNode(node, { zoom = 'close' } = {}) {
|
|
1720
|
+
if (!node) return;
|
|
1721
|
+
if (node.payload && (node.payload._isAnchor || node.payload._isTag)) return;
|
|
1722
|
+
state.selectedNodeId = node.id;
|
|
1723
|
+
_instancesDirty = true;
|
|
1724
|
+
storage.setItem(KEYS.SELECTED_NODE, node.id);
|
|
1725
|
+
|
|
1726
|
+
// Ensure category is visible
|
|
1727
|
+
if (!state.activeCategories.has(node.payload.category)) {
|
|
1728
|
+
state.activeCategories.add(node.payload.category);
|
|
1729
|
+
emit('categories-changed');
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// If focus mode is on, switch focus to the new node
|
|
1733
|
+
if (state.focusedNodeId !== null) {
|
|
1734
|
+
state.focusedNodeId = node.id;
|
|
1735
|
+
applyGraphData();
|
|
1736
|
+
emit('stats-changed');
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Camera transition
|
|
1740
|
+
const dist = zoom === 'gentle' ? gfx.gentleZoom : gfx.clickZoom;
|
|
1741
|
+
graph.cameraPosition(
|
|
1742
|
+
{ x: node.x + dist, y: node.y + dist * 0.4, z: node.z + dist },
|
|
1743
|
+
{ x: node.x, y: node.y, z: node.z },
|
|
1744
|
+
1500
|
|
1745
|
+
);
|
|
1746
|
+
|
|
1747
|
+
emit('node-selected', node);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function handleBackgroundClick() {
|
|
1751
|
+
// Don't close detail panel on background click
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// ═══════════════════════════════════════════
|
|
1755
|
+
// INSTANCED MEMORY NODES — static, dirty-flag
|
|
1756
|
+
// ═══════════════════════════════════════════
|
|
1757
|
+
|
|
1758
|
+
function _updateMemoryNodesInstanced() {
|
|
1759
|
+
const nodeCount = _glowNodeOrder.length;
|
|
1760
|
+
if (nodeCount === 0) return;
|
|
1761
|
+
if (!_instancesDirty) return;
|
|
1762
|
+
_instancesDirty = false;
|
|
1763
|
+
|
|
1764
|
+
// Phase 1: compute final state directly (no animation, no lerp)
|
|
1765
|
+
for (let gi = 0; gi < nodeCount; gi++) {
|
|
1766
|
+
const node = _glowNodeOrder[gi];
|
|
1767
|
+
const nodeId = node.id;
|
|
1768
|
+
|
|
1769
|
+
const isHovered = nodeId === state.hoveredNodeId;
|
|
1770
|
+
const isSelected = nodeId === state.selectedNodeId;
|
|
1771
|
+
const isDragMember = _drag.active && _drag.dragSet.has(nodeId);
|
|
1772
|
+
const isMultiSelected = state.multiSelected.has(nodeId);
|
|
1773
|
+
const isCatHidden = !state.activeCategories.has(node.payload.category);
|
|
1774
|
+
const isSearchMatch = state.searchResults !== null && state.searchResults.has(nodeId);
|
|
1775
|
+
const isSearchMiss = state.searchResults !== null && !isSearchMatch;
|
|
1776
|
+
|
|
1777
|
+
// Opacity — direct target, no interpolation
|
|
1778
|
+
let opacity = _nodeBaseOpacity[gi];
|
|
1779
|
+
if (isSearchMatch) opacity = 0.85;
|
|
1780
|
+
else if (isSearchMiss) opacity = 0;
|
|
1781
|
+
if (isCatHidden) opacity = 0;
|
|
1782
|
+
if (_drag.active && !isCatHidden) {
|
|
1783
|
+
if (isDragMember) opacity = Math.max(opacity, 0.6);
|
|
1784
|
+
else opacity *= 0.35;
|
|
1785
|
+
}
|
|
1786
|
+
if (isHovered) opacity = 0.75;
|
|
1787
|
+
if (isSelected) opacity = 0.85;
|
|
1788
|
+
if (isMultiSelected) opacity = 0.7;
|
|
1789
|
+
_nodeOpacity[gi] = opacity;
|
|
1790
|
+
|
|
1791
|
+
// Glow opacity
|
|
1792
|
+
const glowMult = isSearchMatch ? 0.95 : 0.85;
|
|
1793
|
+
_nodeGlowOpacity[gi] = opacity * glowMult;
|
|
1794
|
+
|
|
1795
|
+
// Color — direct set, no interpolation
|
|
1796
|
+
if (isMultiSelected) {
|
|
1797
|
+
_nodeColorR[gi] = _MULTI_SELECT_BLUE.r;
|
|
1798
|
+
_nodeColorG[gi] = _MULTI_SELECT_BLUE.g;
|
|
1799
|
+
_nodeColorB[gi] = _MULTI_SELECT_BLUE.b;
|
|
1800
|
+
} else {
|
|
1801
|
+
_nodeColorR[gi] = _nodeBaseColorR[gi];
|
|
1802
|
+
_nodeColorG[gi] = _nodeBaseColorG[gi];
|
|
1803
|
+
_nodeColorB[gi] = _nodeBaseColorB[gi];
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// Scale — direct target, no interpolation
|
|
1807
|
+
let scale = 1.0;
|
|
1808
|
+
if (isCatHidden || isSearchMiss) scale = 0;
|
|
1809
|
+
else if (isSearchMatch) scale = 1.8;
|
|
1810
|
+
if (isSelected) scale = 2.0;
|
|
1811
|
+
else if (isMultiSelected) scale = 1.5;
|
|
1812
|
+
else if (isHovered) scale = 1.6;
|
|
1813
|
+
scale *= noLinksScaleBoost;
|
|
1814
|
+
_nodeScale[gi] = scale;
|
|
1815
|
+
|
|
1816
|
+
// Memory nodes have no Three.js object — raycasting uses _nodeScale[] directly
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Phase 2: compose instance matrices and upload GPU attributes
|
|
1820
|
+
const tiers = [_wireInstLow, _wireInstMed, _wireInstHigh];
|
|
1821
|
+
let globalOffset = 0;
|
|
1822
|
+
|
|
1823
|
+
for (let t = 0; t < 3; t++) {
|
|
1824
|
+
const inst = tiers[t];
|
|
1825
|
+
const tierLen = _tierNodes[t].length;
|
|
1826
|
+
if (!inst || tierLen === 0) { globalOffset += tierLen; continue; }
|
|
1827
|
+
|
|
1828
|
+
const colorArr = inst.geometry.attributes.instanceColor.array;
|
|
1829
|
+
const opacityArr = inst.geometry.attributes.instanceOpacity.array;
|
|
1830
|
+
|
|
1831
|
+
for (let i = 0; i < tierLen; i++) {
|
|
1832
|
+
const gi = globalOffset + i;
|
|
1833
|
+
const node = _tierNodes[t][i];
|
|
1834
|
+
const scale = _nodeScale[gi] * _nodeBaseRadius[gi];
|
|
1835
|
+
|
|
1836
|
+
// Compose matrix: position + uniform scale (no rotation)
|
|
1837
|
+
_instPos.set(node.x || 0, node.y || 0, node.z || 0);
|
|
1838
|
+
_instScaleV.set(scale, scale, scale);
|
|
1839
|
+
_instMatrix.compose(_instPos, _identityQuat, _instScaleV);
|
|
1840
|
+
inst.setMatrixAt(i, _instMatrix);
|
|
1841
|
+
|
|
1842
|
+
// Color
|
|
1843
|
+
const ci3 = i * 3;
|
|
1844
|
+
colorArr[ci3] = _nodeColorR[gi];
|
|
1845
|
+
colorArr[ci3 + 1] = _nodeColorG[gi];
|
|
1846
|
+
colorArr[ci3 + 2] = _nodeColorB[gi];
|
|
1847
|
+
|
|
1848
|
+
// Opacity
|
|
1849
|
+
opacityArr[i] = _nodeOpacity[gi];
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
inst.instanceMatrix.needsUpdate = true;
|
|
1853
|
+
inst.geometry.attributes.instanceColor.needsUpdate = true;
|
|
1854
|
+
inst.geometry.attributes.instanceOpacity.needsUpdate = true;
|
|
1855
|
+
|
|
1856
|
+
globalOffset += tierLen;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Glow Points: write position + color + opacity + size
|
|
1860
|
+
if (_glowPoints && nodeCount > 0) {
|
|
1861
|
+
const posArr = _glowPointsGeo.attributes.position.array;
|
|
1862
|
+
const colArr = _glowPointsGeo.attributes.glowColor.array;
|
|
1863
|
+
const opArr = _glowPointsGeo.attributes.glowOpacity.array;
|
|
1864
|
+
const sizeArr = _glowPointsGeo.attributes.glowSize.array;
|
|
1865
|
+
|
|
1866
|
+
for (let gi = 0; gi < nodeCount; gi++) {
|
|
1867
|
+
const node = _glowNodeOrder[gi];
|
|
1868
|
+
const gi3 = gi * 3;
|
|
1869
|
+
|
|
1870
|
+
posArr[gi3] = node.x || 0;
|
|
1871
|
+
posArr[gi3 + 1] = node.y || 0;
|
|
1872
|
+
posArr[gi3 + 2] = node.z || 0;
|
|
1873
|
+
|
|
1874
|
+
colArr[gi3] = _nodeColorR[gi];
|
|
1875
|
+
colArr[gi3 + 1] = _nodeColorG[gi];
|
|
1876
|
+
colArr[gi3 + 2] = _nodeColorB[gi];
|
|
1877
|
+
|
|
1878
|
+
opArr[gi] = _nodeGlowOpacity[gi];
|
|
1879
|
+
sizeArr[gi] = _nodeBaseRadius[gi] * _nodeScale[gi] * 1.4;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
_glowPointsGeo.attributes.position.needsUpdate = true;
|
|
1883
|
+
_glowPointsGeo.attributes.glowColor.needsUpdate = true;
|
|
1884
|
+
_glowPointsGeo.attributes.glowOpacity.needsUpdate = true;
|
|
1885
|
+
_glowPointsGeo.attributes.glowSize.needsUpdate = true;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
// ═══════════════════════════════════════════
|
|
1891
|
+
// ANIMATION LOOP
|
|
1892
|
+
// ═══════════════════════════════════════════
|
|
1893
|
+
function animate() {
|
|
1894
|
+
animFrameId = requestAnimationFrame(animate);
|
|
1895
|
+
const time = performance.now() * 0.001;
|
|
1896
|
+
|
|
1897
|
+
TWEEN.update();
|
|
1898
|
+
|
|
1899
|
+
// Update wireframe orb shader time uniform (Fresnel shimmer)
|
|
1900
|
+
if (_wireInstLow) _wireInstLow.material.uniforms.uTime.value = time;
|
|
1901
|
+
if (_wireInstMed) _wireInstMed.material.uniforms.uTime.value = time;
|
|
1902
|
+
if (_wireInstHigh) _wireInstHigh.material.uniforms.uTime.value = time;
|
|
1903
|
+
|
|
1904
|
+
// WASD camera movement — delegated to camera module via event
|
|
1905
|
+
emit('camera-tick');
|
|
1906
|
+
|
|
1907
|
+
// Update 3D mouse position via raycasting (skip when mouse hasn't moved)
|
|
1908
|
+
if (_mouseMoved) {
|
|
1909
|
+
updateMouse3D();
|
|
1910
|
+
_mouseMoved = false;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// Floor effect uniforms (updated by background module listening to 'animate-tick')
|
|
1914
|
+
emit('animate-tick', { time, graph });
|
|
1915
|
+
|
|
1916
|
+
// ── Node pass ──
|
|
1917
|
+
if (graph) {
|
|
1918
|
+
const _cam = _camera;
|
|
1919
|
+
|
|
1920
|
+
// Build frustum once per frame
|
|
1921
|
+
_cam.updateMatrixWorld();
|
|
1922
|
+
_projScreenMatrix.multiplyMatrices(_cam.projectionMatrix, _cam.matrixWorldInverse);
|
|
1923
|
+
_frustum.setFromProjectionMatrix(_projScreenMatrix);
|
|
1924
|
+
_frustumPadded.copy(_frustum);
|
|
1925
|
+
for (let _pi = 0; _pi < 6; _pi++) _frustumPadded.planes[_pi].constant += 60;
|
|
1926
|
+
|
|
1927
|
+
// ── Pass 1: Anchor/Tag nodes only (~50-100 objects, not 3500) ──
|
|
1928
|
+
for (let _ni = 0, _nlen = _anchorTagArray.length; _ni < _nlen; _ni++) {
|
|
1929
|
+
const obj = _anchorTagArray[_ni];
|
|
1930
|
+
if (!obj || !obj.userData || !obj.userData.nodeId) continue;
|
|
1931
|
+
|
|
1932
|
+
const dot = obj.userData.dot;
|
|
1933
|
+
const label = obj.userData.label;
|
|
1934
|
+
const phase = obj.userData.phase || 0;
|
|
1935
|
+
const isCatHidden = !state.activeCategories.has(obj.userData.category);
|
|
1936
|
+
const isHovered = obj.userData.nodeId === state.hoveredNodeId;
|
|
1937
|
+
const isSelected = obj.userData.nodeId === state.selectedNodeId;
|
|
1938
|
+
const isMultiSelected = state.multiSelected.has(obj.userData.nodeId);
|
|
1939
|
+
|
|
1940
|
+
// Frustum culling
|
|
1941
|
+
obj.getWorldPosition(_worldPos);
|
|
1942
|
+
let _cullZone = 0;
|
|
1943
|
+
if (!_frustum.containsPoint(_worldPos)) {
|
|
1944
|
+
_cullZone = _frustumPadded.containsPoint(_worldPos) ? 1 : 2;
|
|
1945
|
+
}
|
|
1946
|
+
if (isHovered || isSelected || (_drag.active && _drag.dragSet.has(obj.userData.nodeId))) {
|
|
1947
|
+
_cullZone = 0;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// Zone 2: fully outside — decay to invisible, then skip entirely
|
|
1951
|
+
if (_cullZone === 2) {
|
|
1952
|
+
// Already fully faded — skip all work
|
|
1953
|
+
if (obj.userData._currentScale <= 0.01) {
|
|
1954
|
+
const dotOp = dot ? dot.material.opacity : 0;
|
|
1955
|
+
const labelOp = label ? (label.material.uniforms ? label.material.uniforms.uOpacity.value : label.material.opacity) : 0;
|
|
1956
|
+
const glowOp = obj.userData.glow ? obj.userData.glow.material.opacity : 0;
|
|
1957
|
+
if (dotOp === 0 && labelOp === 0 && glowOp === 0) continue;
|
|
1958
|
+
}
|
|
1959
|
+
if (dot) {
|
|
1960
|
+
dot.material.opacity *= 0.85;
|
|
1961
|
+
if (dot.material.opacity < 0.005) dot.material.opacity = 0;
|
|
1962
|
+
}
|
|
1963
|
+
if (label) {
|
|
1964
|
+
if (label.material.uniforms) {
|
|
1965
|
+
label.material.uniforms.uOpacity.value *= 0.85;
|
|
1966
|
+
if (label.material.uniforms.uOpacity.value < 0.005) label.material.uniforms.uOpacity.value = 0;
|
|
1967
|
+
} else {
|
|
1968
|
+
label.material.opacity *= 0.85;
|
|
1969
|
+
if (label.material.opacity < 0.005) label.material.opacity = 0;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
const _glowCull = obj.userData.glow;
|
|
1973
|
+
if (_glowCull) {
|
|
1974
|
+
_glowCull.material.opacity *= 0.85;
|
|
1975
|
+
if (_glowCull.material.opacity < 0.005) _glowCull.material.opacity = 0;
|
|
1976
|
+
}
|
|
1977
|
+
if (obj.userData._currentScale > 0.01) {
|
|
1978
|
+
obj.userData._currentScale *= 0.9;
|
|
1979
|
+
obj.scale.setScalar(obj.userData._currentScale);
|
|
1980
|
+
}
|
|
1981
|
+
continue;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// Mouse proximity
|
|
1985
|
+
let mouseProx = 0;
|
|
1986
|
+
if (_cullZone === 0 && _mouse3DValid && _mouse3D.lengthSq() > 0) {
|
|
1987
|
+
const mouseDist = _mouse3D.distanceTo(_worldPos);
|
|
1988
|
+
mouseProx = 1 - Math.min(mouseDist / PLEXUS_RADIUS, 1);
|
|
1989
|
+
mouseProx = mouseProx * mouseProx;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
const breathe = 1 + Math.sin(time * 0.5 + phase) * 0.03;
|
|
1993
|
+
|
|
1994
|
+
// ── Anchor nodes ──
|
|
1995
|
+
if (obj.userData.isAnchor) {
|
|
1996
|
+
const baseOp = obj.userData.baseOpacity;
|
|
1997
|
+
let dotTarget = baseOp + mouseProx * 0.2;
|
|
1998
|
+
let labelTarget = 1.0;
|
|
1999
|
+
if (!state.labelsVisible) labelTarget = 0;
|
|
2000
|
+
if (isCatHidden) { dotTarget = 0; labelTarget = 0; }
|
|
2001
|
+
else if (state.searchResults !== null && !state.searchResults.has(obj.userData.nodeId)) {
|
|
2002
|
+
dotTarget = 0; labelTarget = 0;
|
|
2003
|
+
}
|
|
2004
|
+
if (_drag.active && !isCatHidden) {
|
|
2005
|
+
if (_drag.dragSet.has(obj.userData.nodeId)) dotTarget = Math.max(dotTarget, 0.9);
|
|
2006
|
+
else { dotTarget *= 0.5; labelTarget *= 0.85; }
|
|
2007
|
+
}
|
|
2008
|
+
if (isHovered) { dotTarget = 1.0; labelTarget = 1.0; }
|
|
2009
|
+
if (isSelected) dotTarget = 1.0;
|
|
2010
|
+
if (_cullZone === 1) { dotTarget *= 0.5; }
|
|
2011
|
+
|
|
2012
|
+
const fade = isCatHidden ? 0.15 : 0.2;
|
|
2013
|
+
if (dot) dot.material.opacity += (dotTarget * breathe - dot.material.opacity) * fade;
|
|
2014
|
+
if (label) {
|
|
2015
|
+
if (label.material.uniforms) {
|
|
2016
|
+
label.material.uniforms.uOpacity.value += (labelTarget - label.material.uniforms.uOpacity.value) * fade;
|
|
2017
|
+
// Floor: visible labels never go below 0.9 opacity
|
|
2018
|
+
if (_cullZone === 0 && labelTarget > 0 && label.material.uniforms.uOpacity.value < 0.9) {
|
|
2019
|
+
label.material.uniforms.uOpacity.value = 0.9;
|
|
2020
|
+
}
|
|
2021
|
+
} else {
|
|
2022
|
+
label.material.opacity += (labelTarget - label.material.opacity) * fade;
|
|
2023
|
+
}
|
|
2024
|
+
// Billboard: face camera
|
|
2025
|
+
label.quaternion.copy(_cam.quaternion);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
if (label && obj.userData.baseLabelScale) {
|
|
2029
|
+
const camDist = _cam.position.distanceTo(_worldPos);
|
|
2030
|
+
const scaleFactor = Math.max(1.5, Math.min(8.0, camDist / 80)) * state.labelSizeMultiplier;
|
|
2031
|
+
const bls = obj.userData.baseLabelScale;
|
|
2032
|
+
label.scale.set(bls.x * scaleFactor, bls.y * scaleFactor, 1);
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
let targetScale = isCatHidden ? 0 : breathe;
|
|
2036
|
+
if (isHovered) targetScale *= 1.15;
|
|
2037
|
+
if (obj.userData._currentScale == null) obj.userData._currentScale = 0.01;
|
|
2038
|
+
obj.userData._currentScale += (targetScale - obj.userData._currentScale) * 0.1;
|
|
2039
|
+
obj.scale.setScalar(obj.userData._currentScale);
|
|
2040
|
+
continue;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// ── Tag nodes ──
|
|
2044
|
+
if (obj.userData.isTag) {
|
|
2045
|
+
const baseOp = obj.userData.baseOpacity;
|
|
2046
|
+
let dotTarget = baseOp + mouseProx * 0.3;
|
|
2047
|
+
let labelTarget = 1.0;
|
|
2048
|
+
if (!state.labelsVisible) labelTarget = 0;
|
|
2049
|
+
|
|
2050
|
+
if (isCatHidden) { dotTarget = 0; labelTarget = 0; }
|
|
2051
|
+
else if (state.searchResults !== null && !state.searchResults.has(obj.userData.nodeId)) {
|
|
2052
|
+
dotTarget = 0; labelTarget = 0;
|
|
2053
|
+
}
|
|
2054
|
+
if (_drag.active && !isCatHidden) {
|
|
2055
|
+
if (_drag.dragSet.has(obj.userData.nodeId)) dotTarget = Math.max(dotTarget, 0.8);
|
|
2056
|
+
else { dotTarget *= 0.4; labelTarget *= 0.85; }
|
|
2057
|
+
}
|
|
2058
|
+
if (isHovered) { dotTarget = 1.0; labelTarget = 1.0; }
|
|
2059
|
+
if (isSelected) dotTarget = 1.0;
|
|
2060
|
+
if (_cullZone === 1) { dotTarget *= 0.5; }
|
|
2061
|
+
|
|
2062
|
+
const fade = isCatHidden ? 0.15 : 0.2;
|
|
2063
|
+
if (dot) dot.material.opacity += (dotTarget * breathe - dot.material.opacity) * fade;
|
|
2064
|
+
if (label) {
|
|
2065
|
+
if (label.material.uniforms) {
|
|
2066
|
+
label.material.uniforms.uOpacity.value += (labelTarget - label.material.uniforms.uOpacity.value) * fade;
|
|
2067
|
+
// Floor: visible labels never go below 0.9 opacity
|
|
2068
|
+
if (_cullZone === 0 && labelTarget > 0 && label.material.uniforms.uOpacity.value < 0.9) {
|
|
2069
|
+
label.material.uniforms.uOpacity.value = 0.9;
|
|
2070
|
+
}
|
|
2071
|
+
} else {
|
|
2072
|
+
label.material.opacity += (labelTarget - label.material.opacity) * fade;
|
|
2073
|
+
}
|
|
2074
|
+
// Billboard: face camera
|
|
2075
|
+
label.quaternion.copy(_cam.quaternion);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
if (label && obj.userData.baseLabelScale) {
|
|
2079
|
+
const camDist = _cam.position.distanceTo(_worldPos);
|
|
2080
|
+
const scaleFactor = Math.max(1.2, Math.min(6.0, camDist / 60)) * state.labelSizeMultiplier;
|
|
2081
|
+
const bls = obj.userData.baseLabelScale;
|
|
2082
|
+
label.scale.set(bls.x * scaleFactor, bls.y * scaleFactor, 1);
|
|
2083
|
+
label.position.y = (obj.userData.baseLabelY || obj.userData.baseRadius * 1.0) * scaleFactor;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
let targetScale = isCatHidden ? 0 : breathe * (1 + mouseProx * 0.1);
|
|
2087
|
+
if (isHovered) targetScale *= 1.1;
|
|
2088
|
+
if (obj.userData._currentScale == null) obj.userData._currentScale = 0.01;
|
|
2089
|
+
obj.userData._currentScale += (targetScale - obj.userData._currentScale) * 0.1;
|
|
2090
|
+
obj.scale.setScalar(obj.userData._currentScale);
|
|
2091
|
+
continue;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// ── Pass 2: Memory nodes (instanced rendering) ──
|
|
2097
|
+
_updateMemoryNodesInstanced();
|
|
2098
|
+
|
|
2099
|
+
// ── LINK BATCH — single draw call ──
|
|
2100
|
+
if (_linkBatch) {
|
|
2101
|
+
if (_linkBatchDirty && _nodeById.size > 0) {
|
|
2102
|
+
_batchLinks.length = 0;
|
|
2103
|
+
if (state.linkMode !== 'off') {
|
|
2104
|
+
const grey = { r: 0.45, g: 0.45, b: 0.45 };
|
|
2105
|
+
const colorCache = new Map();
|
|
2106
|
+
function _getMuted(cat) {
|
|
2107
|
+
let c = colorCache.get(cat);
|
|
2108
|
+
if (!c) {
|
|
2109
|
+
const base = new THREE.Color(catColor(cat));
|
|
2110
|
+
c = { r: base.r + (grey.r - base.r) * 0.5, g: base.g + (grey.g - base.g) * 0.5, b: base.b + (grey.b - base.b) * 0.5 };
|
|
2111
|
+
colorCache.set(cat, c);
|
|
2112
|
+
}
|
|
2113
|
+
return c;
|
|
2114
|
+
}
|
|
2115
|
+
const cols = _linkBatchColors;
|
|
2116
|
+
let ci = 0;
|
|
2117
|
+
for (let li = 0, ll = _visualLinks.length; li < ll; li++) {
|
|
2118
|
+
const link = _visualLinks[li];
|
|
2119
|
+
if (state.linkMode === 'intra' && link._crossCategory) continue;
|
|
2120
|
+
if (state.linkTypeFilter !== 'all' && (!link.types || !link.types.includes(state.linkTypeFilter))) continue;
|
|
2121
|
+
const srcId = link.source.id || link.source;
|
|
2122
|
+
const tgtId = link.target.id || link.target;
|
|
2123
|
+
const srcNode = _nodeById.get(srcId);
|
|
2124
|
+
const tgtNode = _nodeById.get(tgtId);
|
|
2125
|
+
if (!srcNode || !tgtNode) continue;
|
|
2126
|
+
const srcCat = srcNode.payload ? srcNode.payload.category : null;
|
|
2127
|
+
const tgtCat = tgtNode.payload ? tgtNode.payload.category : null;
|
|
2128
|
+
if (srcCat && !state.activeCategories.has(srcCat)) continue;
|
|
2129
|
+
if (tgtCat && !state.activeCategories.has(tgtCat)) continue;
|
|
2130
|
+
// Hide links where both endpoints are not search matches
|
|
2131
|
+
if (state.searchResults !== null) {
|
|
2132
|
+
const srcMatch = state.searchResults.has(srcId);
|
|
2133
|
+
const tgtMatch = state.searchResults.has(tgtId);
|
|
2134
|
+
if (!srcMatch && !tgtMatch) continue;
|
|
2135
|
+
}
|
|
2136
|
+
if (ci + 6 > cols.length) break;
|
|
2137
|
+
link._srcNode = srcNode;
|
|
2138
|
+
link._tgtNode = tgtNode;
|
|
2139
|
+
_batchLinks.push(link);
|
|
2140
|
+
const sc = _getMuted(srcNode.payload ? srcNode.payload.category : '_fallback');
|
|
2141
|
+
const tc = _getMuted(tgtNode.payload ? tgtNode.payload.category : '_fallback');
|
|
2142
|
+
cols[ci] = sc.r; cols[ci + 1] = sc.g; cols[ci + 2] = sc.b;
|
|
2143
|
+
cols[ci + 3] = tc.r; cols[ci + 4] = tc.g; cols[ci + 5] = tc.b;
|
|
2144
|
+
ci += 6;
|
|
2145
|
+
}
|
|
2146
|
+
_linkBatchGeo.attributes.color.needsUpdate = true;
|
|
2147
|
+
}
|
|
2148
|
+
_linkBatchDirty = false;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
// Update positions only when nodes have moved (drag, layout change)
|
|
2152
|
+
if (_linkPosDirty) {
|
|
2153
|
+
const bl = _batchLinks;
|
|
2154
|
+
const blen = bl.length;
|
|
2155
|
+
if (blen > 0) {
|
|
2156
|
+
const pos = _linkBatchPositions;
|
|
2157
|
+
let pi = 0;
|
|
2158
|
+
for (let i = 0; i < blen; i++) {
|
|
2159
|
+
const s = bl[i]._srcNode, t = bl[i]._tgtNode;
|
|
2160
|
+
pos[pi] = s.x; pos[pi + 1] = s.y; pos[pi + 2] = s.z;
|
|
2161
|
+
pos[pi + 3] = t.x; pos[pi + 4] = t.y; pos[pi + 5] = t.z;
|
|
2162
|
+
pi += 6;
|
|
2163
|
+
}
|
|
2164
|
+
_linkBatchGeo.attributes.position.needsUpdate = true;
|
|
2165
|
+
_linkBatchGeo.setDrawRange(0, blen * 2);
|
|
2166
|
+
} else {
|
|
2167
|
+
_linkBatchGeo.setDrawRange(0, 0);
|
|
2168
|
+
}
|
|
2169
|
+
_linkPosDirty = false;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// Sync opacity from GFX slider
|
|
2173
|
+
const targetOp = gfx.linkOpacity || 0.12;
|
|
2174
|
+
if (_linkBatch.material.opacity !== targetOp) _linkBatch.material.opacity = targetOp;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// ── Render ── (single render loop — no more ForceGraph3D internal loop)
|
|
2179
|
+
if (_controls) _controls.update(); // damping
|
|
2180
|
+
if (_composer) {
|
|
2181
|
+
_composer.render();
|
|
2182
|
+
} else if (_renderer && _scene && _camera) {
|
|
2183
|
+
_renderer.render(_scene, _camera);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// ═══════════════════════════════════════════
|
|
2188
|
+
// GRAPH INITIALIZATION
|
|
2189
|
+
// ═══════════════════════════════════════════
|
|
2190
|
+
|
|
2191
|
+
/**
|
|
2192
|
+
* Initialize the 3D graph with raw Three.js (no ForceGraph3D dependency).
|
|
2193
|
+
* @param {HTMLElement} container The DOM element to render into (e.g. #graph-container)
|
|
2194
|
+
* @param {Object} [options] Optional callbacks
|
|
2195
|
+
* @param {Function} [options.onApplyBgTheme] Called after graph is built to apply background
|
|
2196
|
+
* @returns {Object} The graph proxy object
|
|
2197
|
+
*/
|
|
2198
|
+
export function initGraph(container, options = {}) {
|
|
2199
|
+
_graphContainer = container;
|
|
2200
|
+
|
|
2201
|
+
// Initialize mouse tracking and UI guards
|
|
2202
|
+
_initMouseTracking();
|
|
2203
|
+
_initUIGuards();
|
|
2204
|
+
|
|
2205
|
+
// ── 1. Renderer ──
|
|
2206
|
+
_renderer = new THREE.WebGLRenderer({
|
|
2207
|
+
antialias: true,
|
|
2208
|
+
powerPreference: 'high-performance',
|
|
2209
|
+
alpha: false,
|
|
2210
|
+
});
|
|
2211
|
+
_renderer.setPixelRatio(window.devicePixelRatio);
|
|
2212
|
+
_renderer.setSize(container.offsetWidth, container.offsetHeight);
|
|
2213
|
+
_renderer.setClearColor(new THREE.Color('#050505'));
|
|
2214
|
+
_renderer.toneMapping = THREE.NoToneMapping;
|
|
2215
|
+
_renderer.sortObjects = false;
|
|
2216
|
+
_renderer.info.autoReset = false;
|
|
2217
|
+
container.appendChild(_renderer.domElement);
|
|
2218
|
+
|
|
2219
|
+
// ── 2. Scene ──
|
|
2220
|
+
_scene = new THREE.Scene();
|
|
2221
|
+
|
|
2222
|
+
// ── 3. Camera ──
|
|
2223
|
+
_camera = new THREE.PerspectiveCamera(
|
|
2224
|
+
70,
|
|
2225
|
+
container.offsetWidth / container.offsetHeight,
|
|
2226
|
+
0.1,
|
|
2227
|
+
50000,
|
|
2228
|
+
);
|
|
2229
|
+
_camera.position.set(0, 600, 1400);
|
|
2230
|
+
|
|
2231
|
+
// ── 4. OrbitControls ──
|
|
2232
|
+
_controls = new OrbitControls(_camera, _renderer.domElement);
|
|
2233
|
+
_controls.enableDamping = true;
|
|
2234
|
+
_controls.dampingFactor = 0.08;
|
|
2235
|
+
|
|
2236
|
+
// ── 5. EffectComposer + RenderPass ──
|
|
2237
|
+
_composer = new EffectComposer(_renderer);
|
|
2238
|
+
_composer.addPass(new RenderPass(_scene, _camera));
|
|
2239
|
+
|
|
2240
|
+
// ── 6. Resize handler ──
|
|
2241
|
+
window.addEventListener('resize', _onResize);
|
|
2242
|
+
|
|
2243
|
+
// ── 7. Assign proxy as `graph` ──
|
|
2244
|
+
graph = graphProxy;
|
|
2245
|
+
|
|
2246
|
+
// ── 8. Pointer events for hover/click/drag (custom raycasting) ──
|
|
2247
|
+
_initPointerEvents(_renderer.domElement);
|
|
2248
|
+
|
|
2249
|
+
// Init instanced rendering for memory nodes (MUST be before applyGraphData
|
|
2250
|
+
// so rebuildInstanceMap() can set instance counts on the already-created meshes)
|
|
2251
|
+
initInstancedRendering();
|
|
2252
|
+
|
|
2253
|
+
// Apply data (layout is computed, positions are pinned)
|
|
2254
|
+
applyGraphData();
|
|
2255
|
+
|
|
2256
|
+
// One-time reset: clear saved positions for new layout version
|
|
2257
|
+
if (storage.getItem(KEYS.LAYOUT_VERSION) !== 'v6-polished') {
|
|
2258
|
+
try { storage.removeItem(_posStorageKey); } catch (_) {}
|
|
2259
|
+
storage.setItem(KEYS.LAYOUT_VERSION, 'v6-polished');
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// Apply link visibility
|
|
2263
|
+
applyLinkVisibility();
|
|
2264
|
+
|
|
2265
|
+
// Bloom post-processing (half resolution for performance)
|
|
2266
|
+
if (gfx.bloomEnabled !== false) {
|
|
2267
|
+
_bloomPass = new UnrealBloomPass(
|
|
2268
|
+
new THREE.Vector2(Math.floor(container.offsetWidth / 2), Math.floor(container.offsetHeight / 2)),
|
|
2269
|
+
0.5, // strength
|
|
2270
|
+
0.4, // radius
|
|
2271
|
+
1.0 // threshold — only orbs (2.5x output) bloom, not labels
|
|
2272
|
+
);
|
|
2273
|
+
_composer.addPass(_bloomPass);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// OutputPass: converts linear→sRGB for correct display brightness
|
|
2277
|
+
_composer.addPass(new OutputPass());
|
|
2278
|
+
|
|
2279
|
+
// Apply background theme
|
|
2280
|
+
if (options.onApplyBgTheme) {
|
|
2281
|
+
options.onApplyBgTheme(gfx.bgTheme || 'deep-grid');
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Init batch link renderer
|
|
2285
|
+
initLinkBatch();
|
|
2286
|
+
|
|
2287
|
+
// Start animation loop
|
|
2288
|
+
animate();
|
|
2289
|
+
|
|
2290
|
+
return graph;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
// ═══════════════════════════════════════════
|
|
2294
|
+
// RESIZE HANDLER
|
|
2295
|
+
// ═══════════════════════════════════════════
|
|
2296
|
+
function _onResize() {
|
|
2297
|
+
if (!_graphContainer || !_camera || !_renderer || !_composer) return;
|
|
2298
|
+
const w = _graphContainer.offsetWidth;
|
|
2299
|
+
const h = _graphContainer.offsetHeight;
|
|
2300
|
+
_camera.aspect = w / h;
|
|
2301
|
+
_camera.updateProjectionMatrix();
|
|
2302
|
+
_renderer.setSize(w, h);
|
|
2303
|
+
_composer.setSize(w, h);
|
|
2304
|
+
if (_bloomPass) {
|
|
2305
|
+
_bloomPass.resolution.set(Math.floor(w / 2), Math.floor(h / 2));
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// ═══════════════════════════════════════════
|
|
2310
|
+
// PUBLIC API
|
|
2311
|
+
// ═══════════════════════════════════════════
|
|
2312
|
+
|
|
2313
|
+
/** Returns the graph proxy (or null before init). */
|
|
2314
|
+
export function getGraph() {
|
|
2315
|
+
return graph;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
/** Returns the bloom pass (or null before init). */
|
|
2319
|
+
export function getBloomPass() {
|
|
2320
|
+
return _bloomPass;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/** Returns current visual links array. */
|
|
2324
|
+
export function getVisualLinks() {
|
|
2325
|
+
return _visualLinks;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
/** Returns the node-by-id Map. */
|
|
2329
|
+
export function getNodeById() {
|
|
2330
|
+
return _nodeById;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/** Mark the link batch as dirty (force rebuild on next frame). */
|
|
2334
|
+
export function markLinksDirty() {
|
|
2335
|
+
_linkBatchDirty = true;
|
|
2336
|
+
_linkPosDirty = true;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/** Returns the drag state object (read-only). */
|
|
2340
|
+
export function getDragState() {
|
|
2341
|
+
return _drag;
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
/** Stop the animation loop. */
|
|
2345
|
+
export function stopAnimation() {
|
|
2346
|
+
if (animFrameId) {
|
|
2347
|
+
cancelAnimationFrame(animFrameId);
|
|
2348
|
+
animFrameId = null;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/** Restart the animation loop after stopping. */
|
|
2353
|
+
export function startAnimation() {
|
|
2354
|
+
if (!animFrameId) animate();
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/** Pause all interaction (focus mode — zero resource consumption). */
|
|
2358
|
+
export function pauseInteraction() {
|
|
2359
|
+
_focusPaused = true;
|
|
2360
|
+
if (_controls) _controls.enabled = false;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/** Resume interaction after focus mode exit. */
|
|
2364
|
+
export function resumeInteraction() {
|
|
2365
|
+
_focusPaused = false;
|
|
2366
|
+
if (_controls) _controls.enabled = true;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/** Clear saved node positions from localStorage. */
|
|
2370
|
+
export function clearSavedPositions() {
|
|
2371
|
+
try { storage.removeItem(_posStorageKey); } catch (_) {}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/** Recompute layout from scratch (no saved positions). */
|
|
2375
|
+
export function resetLayout() {
|
|
2376
|
+
clearSavedPositions();
|
|
2377
|
+
applyGraphData();
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/** Update bloom pass parameters. */
|
|
2381
|
+
export function setBloomParams(strength, threshold, radius) {
|
|
2382
|
+
if (_bloomPass) {
|
|
2383
|
+
if (strength !== undefined) _bloomPass.strength = strength;
|
|
2384
|
+
if (threshold !== undefined) _bloomPass.threshold = threshold;
|
|
2385
|
+
if (radius !== undefined) _bloomPass.radius = radius;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
/** Enable or disable bloom post-processing at runtime. */
|
|
2390
|
+
export function setBloomEnabled(enabled) {
|
|
2391
|
+
if (_bloomPass) {
|
|
2392
|
+
_bloomPass.enabled = enabled;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/** Returns the noLinksScaleBoost value (used by external animation code). */
|
|
2397
|
+
export function getNoLinksScaleBoost() {
|
|
2398
|
+
return noLinksScaleBoost;
|
|
2399
|
+
}
|