@kage-core/kage-graph-mcp 1.1.23 → 1.1.25

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/dist/daemon.js CHANGED
@@ -232,6 +232,7 @@ async function startViewer(projectDir, options = {}) {
232
232
  const host = options.host ?? DEFAULT_HOST;
233
233
  const port = options.port ?? DEFAULT_VIEWER_PORT;
234
234
  const viewerDir = (0, node_path_1.resolve)(__dirname, "..", "viewer");
235
+ const threeDir = (0, node_path_1.resolve)(__dirname, "..", "node_modules", "three");
235
236
  const projectRoot = (0, node_path_1.resolve)(projectDir);
236
237
  const graphPath = (0, node_path_1.join)(projectRoot, ".agent_memory", "graph", "graph.json");
237
238
  const codePath = (0, node_path_1.join)(projectRoot, ".agent_memory", "code_graph", "graph.json");
@@ -262,13 +263,16 @@ async function startViewer(projectDir, options = {}) {
262
263
  else if (requestUrl.pathname.startsWith("/viewer/")) {
263
264
  filePath = (0, node_path_1.join)(viewerDir, (0, node_path_1.normalize)(requestUrl.pathname.replace(/^\/viewer\//, "")));
264
265
  }
266
+ else if (requestUrl.pathname.startsWith("/vendor/three/")) {
267
+ filePath = (0, node_path_1.join)(threeDir, (0, node_path_1.normalize)(requestUrl.pathname.replace(/^\/vendor\/three\//, "")));
268
+ }
265
269
  else {
266
270
  const decoded = decodeURIComponent(requestUrl.pathname);
267
271
  filePath = (0, node_path_1.resolve)(decoded);
268
272
  if (!isInside(projectRoot, filePath))
269
273
  filePath = null;
270
274
  }
271
- if (!filePath || (!isInside(viewerDir, filePath) && !isInside(projectRoot, filePath)) || !(0, node_fs_1.existsSync)(filePath)) {
275
+ if (!filePath || (!isInside(viewerDir, filePath) && !isInside(projectRoot, filePath) && !isInside(threeDir, filePath)) || !(0, node_fs_1.existsSync)(filePath)) {
272
276
  json(res, 404, { ok: false, error: "not_found" });
273
277
  return;
274
278
  }
package/dist/kernel.js CHANGED
@@ -460,6 +460,12 @@ function estimateTokens(text) {
460
460
  function packetText(packet) {
461
461
  return `${packet.title}\n${packet.summary}\n${packet.body}\n${packet.type}\n${packet.tags.join(" ")}\n${packet.paths.join(" ")}`;
462
462
  }
463
+ function isGeneratedChangeMemory(packet) {
464
+ return packet.type === "workflow"
465
+ && packet.tags.includes("change-memory")
466
+ && packet.tags.includes("diff-proposal")
467
+ && packet.source_refs.some((ref) => ref.kind === "git_diff");
468
+ }
463
469
  function tokenSet(text) {
464
470
  return new Set(tokenize(text).filter((term) => term.length > 2));
465
471
  }
@@ -476,6 +482,7 @@ function duplicateCandidates(projectDir, packet, threshold = 0.58) {
476
482
  const current = tokenSet(packetText(packet));
477
483
  return [...loadApprovedPackets(projectDir), ...loadPendingPackets(projectDir)]
478
484
  .filter((candidate) => candidate.id !== packet.id)
485
+ .filter((candidate) => !(isGeneratedChangeMemory(packet) && isGeneratedChangeMemory(candidate)))
479
486
  .map((candidate) => ({ packet: candidate, score: jaccard(current, tokenSet(packetText(candidate))) }))
480
487
  .filter((entry) => entry.score >= threshold)
481
488
  .sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title))
@@ -888,6 +895,30 @@ function readGit(projectDir, args) {
888
895
  return null;
889
896
  }
890
897
  }
898
+ function safeStat(path) {
899
+ try {
900
+ return (0, node_fs_1.statSync)(path);
901
+ }
902
+ catch {
903
+ return null;
904
+ }
905
+ }
906
+ function safeLstat(path) {
907
+ try {
908
+ return (0, node_fs_1.lstatSync)(path);
909
+ }
910
+ catch {
911
+ return null;
912
+ }
913
+ }
914
+ function safeReadText(path) {
915
+ try {
916
+ return (0, node_fs_1.readFileSync)(path, "utf8");
917
+ }
918
+ catch {
919
+ return null;
920
+ }
921
+ }
891
922
  function gitBranch(projectDir) {
892
923
  return readGit(projectDir, ["branch", "--show-current"]) || readGit(projectDir, ["rev-parse", "--short", "HEAD"]);
893
924
  }
@@ -901,6 +932,12 @@ function gitMergeBase(projectDir) {
901
932
  return readGit(projectDir, ["merge-base", "HEAD", "origin/main"])
902
933
  || readGit(projectDir, ["merge-base", "HEAD", "origin/master"]);
903
934
  }
935
+ function gitProjectPrefix(projectDir) {
936
+ const prefix = readGit(projectDir, ["rev-parse", "--show-prefix"]);
937
+ if (prefix === null)
938
+ return null;
939
+ return prefix.replace(/\\/g, "/").replace(/\/+$/, "");
940
+ }
904
941
  // Directories that are never meaningful in change-memory packets.
905
942
  // These are typically generated, vendored, or ephemeral — any project can
906
943
  // accumulate thousands of files here that bury real signal.
@@ -934,12 +971,26 @@ function isNoisePath(filePath) {
934
971
  return false;
935
972
  return NOISE_PATH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
936
973
  }
937
- function parsePorcelainStatus(status) {
974
+ function gitPathToProjectRelative(projectDir, path) {
975
+ const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
976
+ const projectPrefix = gitProjectPrefix(projectDir);
977
+ if (projectPrefix === null || projectPrefix === "")
978
+ return normalized;
979
+ if (normalized === projectPrefix)
980
+ return "";
981
+ const prefix = `${projectPrefix}/`;
982
+ if (!normalized.startsWith(prefix)) {
983
+ return (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, normalized)) ? normalized : null;
984
+ }
985
+ return normalized.slice(prefix.length);
986
+ }
987
+ function parsePorcelainStatus(projectDir, status) {
938
988
  return unique(status
939
989
  .split(/\r?\n/)
940
990
  .map(parsePorcelainPath)
941
991
  .map((path) => path.replace(/^.* -> /, ""))
942
- .filter(Boolean)
992
+ .map((path) => gitPathToProjectRelative(projectDir, path))
993
+ .filter((path) => Boolean(path))
943
994
  .filter((path) => !shouldSkipRepoMemoryPath(path))).sort();
944
995
  }
945
996
  function parsePorcelainPath(line) {
@@ -948,13 +999,14 @@ function parsePorcelainPath(line) {
948
999
  }
949
1000
  function branchDiffStat(projectDir, changedFiles) {
950
1001
  const diffStats = [
951
- readGit(projectDir, ["diff", "--stat"]),
952
- readGit(projectDir, ["diff", "--cached", "--stat"]),
1002
+ readGit(projectDir, ["diff", "--stat", "--relative"]),
1003
+ readGit(projectDir, ["diff", "--cached", "--stat", "--relative"]),
953
1004
  ].filter(Boolean).join("\n").trim();
954
1005
  const untracked = new Set((readGit(projectDir, ["ls-files", "--others", "--exclude-standard"]) ?? "")
955
1006
  .split(/\r?\n/)
956
1007
  .map((path) => path.trim())
957
- .filter(Boolean)
1008
+ .map((path) => gitPathToProjectRelative(projectDir, path))
1009
+ .filter((path) => Boolean(path))
958
1010
  .filter((path) => changedFiles.includes(path)));
959
1011
  const untrackedStats = [...untracked]
960
1012
  .filter((file) => !diffStats.includes(file))
@@ -1016,8 +1068,9 @@ function createRepoOverviewPacket(projectDir) {
1016
1068
  if (deps.stripe)
1017
1069
  tags.push("stripe");
1018
1070
  }
1019
- if ((0, node_fs_1.existsSync)(readmePath)) {
1020
- const readme = (0, node_fs_1.readFileSync)(readmePath, "utf8").slice(0, 1000);
1071
+ const readmeText = (0, node_fs_1.existsSync)(readmePath) ? safeReadText(readmePath) : null;
1072
+ if (readmeText) {
1073
+ const readme = readmeText.slice(0, 1000);
1021
1074
  bodyParts.push(`README excerpt:\n${readme}`);
1022
1075
  }
1023
1076
  const createdAt = nowIso();
@@ -1038,7 +1091,7 @@ function createRepoOverviewPacket(projectDir) {
1038
1091
  stack,
1039
1092
  source_refs: [
1040
1093
  ...((0, node_fs_1.existsSync)(packagePath) ? [{ kind: "file", path: "package.json" }] : []),
1041
- ...((0, node_fs_1.existsSync)(readmePath) ? [{ kind: "file", path: "README.md" }] : []),
1094
+ ...(readmeText ? [{ kind: "file", path: "README.md" }] : []),
1042
1095
  ],
1043
1096
  context: {
1044
1097
  fact: "Generated repo overview summarizes package metadata and the README as a navigation aid for agent startup.",
@@ -1748,7 +1801,20 @@ function scanStructuralFiles(projectDir) {
1748
1801
  ignore("kageignore");
1749
1802
  continue;
1750
1803
  }
1751
- const stats = (0, node_fs_1.statSync)(absolutePath);
1804
+ const linkStats = safeLstat(absolutePath);
1805
+ if (!linkStats) {
1806
+ ignore("unreadable_path");
1807
+ continue;
1808
+ }
1809
+ if (linkStats.isSymbolicLink()) {
1810
+ ignore("symlink");
1811
+ continue;
1812
+ }
1813
+ const stats = safeStat(absolutePath);
1814
+ if (!stats) {
1815
+ ignore("unreadable_path");
1816
+ continue;
1817
+ }
1752
1818
  if (stats.isDirectory()) {
1753
1819
  visit(absolutePath);
1754
1820
  continue;
@@ -5282,10 +5348,14 @@ function baselineDiscoveryFiles(projectDir, task) {
5282
5348
  const absolute = (0, node_path_1.join)(projectDir, path);
5283
5349
  if (!(0, node_fs_1.existsSync)(absolute))
5284
5350
  return null;
5285
- const stats = (0, node_fs_1.statSync)(absolute);
5351
+ const stats = safeStat(absolute);
5352
+ if (!stats)
5353
+ return null;
5286
5354
  if (!stats.isFile() || stats.size > 240_000)
5287
5355
  return null;
5288
- const text = (0, node_fs_1.readFileSync)(absolute, "utf8");
5356
+ const text = safeReadText(absolute);
5357
+ if (text === null)
5358
+ return null;
5289
5359
  const score = scoreText(terms, `${path}\n${text.slice(0, 8000)}`, [path]);
5290
5360
  const alwaysUseful = ["README.md", "AGENTS.md", "CLAUDE.md", "package.json"].includes(path);
5291
5361
  if (score <= 0 && !alwaysUseful)
@@ -6421,7 +6491,7 @@ function proposeFromDiff(projectDir) {
6421
6491
  const status = readGit(projectDir, ["status", "--porcelain", "-uall"]);
6422
6492
  if (status === null)
6423
6493
  return { ok: false, changedFiles: [], errors: ["Not a git repository or git is unavailable."] };
6424
- const changedFiles = parsePorcelainStatus(status);
6494
+ const changedFiles = parsePorcelainStatus(projectDir, status);
6425
6495
  if (changedFiles.length === 0)
6426
6496
  return { ok: false, changedFiles: [], errors: ["No changed files found."] };
6427
6497
  const stat = branchDiffStat(projectDir, changedFiles);
@@ -6470,7 +6540,7 @@ function buildBranchOverlay(projectDir) {
6470
6540
  branch: gitBranch(projectDir),
6471
6541
  head: gitHead(projectDir),
6472
6542
  merge_base: gitMergeBase(projectDir),
6473
- changed_files: parsePorcelainStatus(status),
6543
+ changed_files: parsePorcelainStatus(projectDir, status),
6474
6544
  pending_packet_ids: loadPendingPackets(projectDir).map((packet) => packet.id).sort(),
6475
6545
  generated_at: nowIso(),
6476
6546
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "1.1.23",
3
+ "version": "1.1.25",
4
4
  "description": "Local-first repo memory, code graph, and recall MCP server for coding agents",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -37,6 +37,7 @@
37
37
  "license": "GPL-3.0-only",
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.10.2",
40
+ "three": "^0.184.0",
40
41
  "typescript": "^5.0.0"
41
42
  },
42
43
  "devDependencies": {
package/viewer/app.js CHANGED
@@ -37,6 +37,32 @@
37
37
  idleFrames: 0,
38
38
  adjacency: new Map()
39
39
  },
40
+ three: {
41
+ THREE: null,
42
+ loading: null,
43
+ failed: false,
44
+ renderer: null,
45
+ scene: null,
46
+ camera: null,
47
+ root: null,
48
+ nodeGroup: null,
49
+ edgeGroup: null,
50
+ nodeById: new Map(),
51
+ edgeRefs: [],
52
+ nodeTextureCache: new Map(),
53
+ physicsTick: 0,
54
+ physicsIdle: 0,
55
+ hoverNode: null,
56
+ pointer: null,
57
+ drag: null,
58
+ raycaster: null,
59
+ raf: null,
60
+ distance: 850,
61
+ target: { x: 0, y: 0, z: 0 },
62
+ rotationX: -0.20,
63
+ rotationY: 0.24,
64
+ lastPointerEvent: null
65
+ },
40
66
  pan: null
41
67
  };
42
68
 
@@ -84,6 +110,7 @@
84
110
  selectionStatus: document.getElementById("selectionStatus"),
85
111
  searchInput: document.getElementById("searchInput"),
86
112
  viewMode: document.getElementById("viewMode"),
113
+ renderMode: document.getElementById("renderMode"),
87
114
  typeFilter: document.getElementById("typeFilter"),
88
115
  relationFilter: document.getElementById("relationFilter"),
89
116
  scopeFilter: document.getElementById("scopeFilter"),
@@ -93,7 +120,10 @@
93
120
  zoomOut: document.getElementById("zoomOut"),
94
121
  zoomIn: document.getElementById("zoomIn"),
95
122
  fitView: document.getElementById("fitView"),
123
+ interactionHint: document.getElementById("interactionHint"),
124
+ graphWrap: document.getElementById("graphCanvasWrap"),
96
125
  canvas: document.getElementById("graphCanvas"),
126
+ threeGraph: document.getElementById("threeGraph"),
97
127
  tooltip: document.getElementById("graphTooltip"),
98
128
  svg: document.getElementById("graphSvg"),
99
129
  nodeLayer: document.getElementById("nodeLayer"),
@@ -122,33 +152,42 @@
122
152
  els.graphFile.addEventListener("change", handleFile);
123
153
  els.searchInput.addEventListener("input", scheduleRender);
124
154
  els.viewMode.addEventListener("change", render);
155
+ els.renderMode.addEventListener("change", function () {
156
+ state.lastVisibleSignature = "";
157
+ render();
158
+ });
125
159
  els.typeFilter.addEventListener("change", render);
126
160
  els.relationFilter.addEventListener("change", render);
127
161
  els.scopeFilter.addEventListener("change", render);
128
162
  els.maxNodes.addEventListener("change", render);
129
163
  els.showDependencies.addEventListener("change", render);
130
- els.zoomOut.addEventListener("click", function () { zoomCanvas(0.82); });
131
- els.zoomIn.addEventListener("click", function () { zoomCanvas(1.22); });
132
- els.fitView.addEventListener("click", function () { fitCanvas(); drawCanvasGraph(); });
164
+ els.zoomOut.addEventListener("click", function () { zoomGraph(0.82); });
165
+ els.zoomIn.addEventListener("click", function () { zoomGraph(1.22); });
166
+ els.fitView.addEventListener("click", fitActiveGraph);
133
167
  els.canvas.addEventListener("mousedown", startCanvasPointer);
134
168
  els.canvas.addEventListener("mousemove", moveCanvasPointer);
135
169
  els.canvas.addEventListener("mouseup", endCanvasPointer);
136
170
  els.canvas.addEventListener("mouseleave", leaveCanvasPointer);
137
171
  els.canvas.addEventListener("wheel", handleCanvasWheel, { passive: false });
138
172
  els.canvas.addEventListener("dblclick", handleCanvasDoubleClick);
173
+ els.threeGraph.addEventListener("mousedown", startThreePointer);
174
+ els.threeGraph.addEventListener("mousemove", moveThreePointer);
175
+ els.threeGraph.addEventListener("mouseup", endThreePointer);
176
+ els.threeGraph.addEventListener("mouseleave", leaveThreePointer);
177
+ els.threeGraph.addEventListener("wheel", handleThreeWheel, { passive: false });
178
+ els.threeGraph.addEventListener("dblclick", handleThreeDoubleClick);
139
179
  els.svg.addEventListener("mousedown", startPan);
140
180
  els.svg.addEventListener("click", handleSvgClick);
141
181
  els.svg.addEventListener("wheel", handleWheelZoom, { passive: false });
142
182
  window.addEventListener("mousemove", continuePan);
143
183
  window.addEventListener("mouseup", endPan);
144
184
  window.addEventListener("resize", function () {
145
- resizeCanvas();
146
- fitCanvas();
147
- drawCanvasGraph();
185
+ resizeActiveGraph();
148
186
  });
149
187
  els.resetView.addEventListener("click", function () {
150
188
  els.searchInput.value = "";
151
189
  els.viewMode.value = "combined";
190
+ els.renderMode.value = "2d";
152
191
  els.typeFilter.value = "";
153
192
  els.relationFilter.value = "";
154
193
  els.scopeFilter.value = "signal";
@@ -227,6 +266,7 @@
227
266
  function loadFromUrlParams() {
228
267
  var params = new URLSearchParams(window.location.search);
229
268
  applyRequestedView(params.get("view") || params.get("mode"));
269
+ applyRequestedRenderMode(params.get("render") || params.get("graphMode"));
230
270
  var memoryGraphPaths = splitParamValues(params.getAll("graph"));
231
271
  var codeGraphPaths = splitParamValues(params.getAll("code"));
232
272
  var graphPaths = memoryGraphPaths.concat(codeGraphPaths);
@@ -829,7 +869,7 @@
829
869
  var graphChanged = nextSignature !== state.lastVisibleSignature;
830
870
  state.lastVisibleSignature = nextSignature;
831
871
 
832
- renderCanvasGraph(graphChanged);
872
+ renderActiveGraph(graphChanged);
833
873
  renderLists();
834
874
  renderDetails();
835
875
  renderMetrics();
@@ -1088,9 +1128,69 @@
1088
1128
  }));
1089
1129
  }
1090
1130
 
1091
- function renderCanvasGraph(graphChanged) {
1092
- resizeCanvas();
1131
+ function activeRenderMode() {
1132
+ return els.renderMode && els.renderMode.value === "3d" ? "3d" : "2d";
1133
+ }
1134
+
1135
+ function renderActiveGraph(graphChanged) {
1136
+ var mode = activeRenderMode();
1137
+ updateGraphSurfaceMode(mode);
1093
1138
  syncSimulationGraph(graphChanged);
1139
+ if (mode === "3d") {
1140
+ stopSimulation();
1141
+ renderThreeGraph(graphChanged);
1142
+ return;
1143
+ }
1144
+ stopThreeGraph();
1145
+ renderCanvasGraph(graphChanged, true);
1146
+ }
1147
+
1148
+ function updateGraphSurfaceMode(mode) {
1149
+ if (els.graphWrap) {
1150
+ els.graphWrap.classList.toggle("mode-3d", mode === "3d");
1151
+ els.graphWrap.classList.toggle("mode-2d", mode !== "3d");
1152
+ }
1153
+ if (els.interactionHint) {
1154
+ els.interactionHint.textContent = mode === "3d"
1155
+ ? "drag orb or node / wheel zoom / click node"
1156
+ : "drag canvas / wheel zoom / click node";
1157
+ }
1158
+ if (els.tooltip) els.tooltip.classList.remove("visible");
1159
+ }
1160
+
1161
+ function resizeActiveGraph() {
1162
+ if (activeRenderMode() === "3d") {
1163
+ resizeThreeGraph();
1164
+ renderThreeFrame();
1165
+ return;
1166
+ }
1167
+ resizeCanvas();
1168
+ fitCanvas();
1169
+ drawCanvasGraph();
1170
+ }
1171
+
1172
+ function fitActiveGraph() {
1173
+ if (activeRenderMode() === "3d") {
1174
+ fitThreeGraph();
1175
+ renderThreeFrame();
1176
+ return;
1177
+ }
1178
+ fitCanvas();
1179
+ drawCanvasGraph();
1180
+ }
1181
+
1182
+ function zoomGraph(factor) {
1183
+ if (activeRenderMode() === "3d") {
1184
+ zoomThreeGraph(factor);
1185
+ renderThreeFrame();
1186
+ return;
1187
+ }
1188
+ zoomCanvas(factor);
1189
+ }
1190
+
1191
+ function renderCanvasGraph(graphChanged, alreadySynced) {
1192
+ resizeCanvas();
1193
+ if (!alreadySynced) syncSimulationGraph(graphChanged);
1094
1194
  if (graphChanged) fitCanvas();
1095
1195
  startSimulation();
1096
1196
  drawCanvasGraph();
@@ -1817,6 +1917,15 @@
1817
1917
  els.viewMode.value = normalized;
1818
1918
  }
1819
1919
 
1920
+ function applyRequestedRenderMode(mode) {
1921
+ if (!mode) return;
1922
+ var normalized = String(mode).toLowerCase();
1923
+ if (normalized === "canvas") normalized = "2d";
1924
+ if (normalized === "space") normalized = "3d";
1925
+ if (["2d", "3d"].indexOf(normalized) === -1) return;
1926
+ els.renderMode.value = normalized;
1927
+ }
1928
+
1820
1929
  function isMemoryCodeEdge(edge) {
1821
1930
  return Boolean(edge && (edge.memory_code_link || isMemoryCodeRelation(edge.relation)));
1822
1931
  }
@@ -2317,6 +2426,579 @@
2317
2426
  state.sim.panY = height / 2 - ((minY + maxY) / 2) * state.sim.zoom;
2318
2427
  }
2319
2428
 
2429
+ function ensureThree() {
2430
+ if (state.three.THREE) return Promise.resolve(state.three.THREE);
2431
+ if (state.three.loading) return state.three.loading;
2432
+ state.three.failed = false;
2433
+ state.three.loading = import("/vendor/three/build/three.module.min.js")
2434
+ .catch(function () {
2435
+ return import("https://unpkg.com/three@0.184.0/build/three.module.min.js");
2436
+ })
2437
+ .then(function (mod) {
2438
+ state.three.THREE = mod;
2439
+ return mod;
2440
+ })
2441
+ .catch(function (error) {
2442
+ state.three.failed = true;
2443
+ throw error;
2444
+ });
2445
+ return state.three.loading;
2446
+ }
2447
+
2448
+ function renderThreeGraph(graphChanged) {
2449
+ drawThreeStatus("Loading 3D graph...");
2450
+ ensureThree().then(function () {
2451
+ if (activeRenderMode() !== "3d") return;
2452
+ setupThreeScene();
2453
+ rebuildThreeScene();
2454
+ if (graphChanged || state.three.distance <= 0) fitThreeGraph();
2455
+ startThreeGraph();
2456
+ renderThreeFrame();
2457
+ }).catch(function () {
2458
+ drawThreeStatus("3D renderer unavailable. Falling back to 2D canvas.");
2459
+ if (els.renderMode) els.renderMode.value = "2d";
2460
+ updateGraphSurfaceMode("2d");
2461
+ renderCanvasGraph(true);
2462
+ });
2463
+ }
2464
+
2465
+ function drawThreeStatus(message) {
2466
+ if (!els.threeGraph || state.three.renderer) return;
2467
+ els.threeGraph.innerHTML = "<div class=\"three-status\"></div>";
2468
+ var status = els.threeGraph.querySelector(".three-status");
2469
+ if (status) status.textContent = message;
2470
+ }
2471
+
2472
+ function setupThreeScene() {
2473
+ if (state.three.renderer) {
2474
+ resizeThreeGraph();
2475
+ return;
2476
+ }
2477
+ var THREE = state.three.THREE;
2478
+ els.threeGraph.textContent = "";
2479
+ state.three.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
2480
+ state.three.renderer.setClearColor(new THREE.Color(graphPalette.background), 1);
2481
+ state.three.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
2482
+ els.threeGraph.appendChild(state.three.renderer.domElement);
2483
+ state.three.scene = new THREE.Scene();
2484
+ state.three.scene.fog = new THREE.FogExp2(new THREE.Color(graphPalette.background), 0.00016);
2485
+ state.three.camera = new THREE.PerspectiveCamera(50, 1, 1, 5000);
2486
+ state.three.root = new THREE.Group();
2487
+ state.three.nodeGroup = new THREE.Group();
2488
+ state.three.edgeGroup = new THREE.Group();
2489
+ state.three.root.add(state.three.edgeGroup);
2490
+ state.three.root.add(state.three.nodeGroup);
2491
+ state.three.scene.add(state.three.root);
2492
+ state.three.scene.add(new THREE.AmbientLight(0x8fffb1, 0.54));
2493
+ var key = new THREE.PointLight(0x6ad7ff, 1.2, 2200);
2494
+ key.position.set(280, 360, 520);
2495
+ state.three.scene.add(key);
2496
+ var fill = new THREE.PointLight(0xb88cff, 0.72, 1800);
2497
+ fill.position.set(-420, -220, 360);
2498
+ state.three.scene.add(fill);
2499
+ state.three.raycaster = new THREE.Raycaster();
2500
+ state.three.pointer = new THREE.Vector2();
2501
+ resizeThreeGraph();
2502
+ }
2503
+
2504
+ function rebuildThreeScene() {
2505
+ var THREE = state.three.THREE;
2506
+ clearThreeGroup(state.three.nodeGroup);
2507
+ clearThreeGroup(state.three.edgeGroup);
2508
+ state.three.nodeById = new Map();
2509
+ state.three.edgeRefs = [];
2510
+ state.three.physicsTick = 0;
2511
+ state.three.physicsIdle = 0;
2512
+
2513
+ state.sim.nodes.forEach(function (node, index) {
2514
+ var entity = node.entity;
2515
+ var selected = state.selected && state.selected.kind === "entity" && state.selected.id === node.id;
2516
+ var material = new THREE.SpriteMaterial({
2517
+ map: threeNodeTexture(entity, selected),
2518
+ transparent: true,
2519
+ opacity: isDependencyEntity(entity) ? 0.70 : 1,
2520
+ depthWrite: false
2521
+ });
2522
+ var mesh = new THREE.Sprite(material);
2523
+ var position = threePosition(node, index);
2524
+ mesh.position.set(position.x, position.y, position.z);
2525
+ var size = threeNodeSize(node, selected);
2526
+ mesh.scale.set(size, size, 1);
2527
+ mesh.userData.node = node;
2528
+ state.three.nodeGroup.add(mesh);
2529
+ state.three.nodeById.set(node.id, { node: node, mesh: mesh, vx: 0, vy: 0, vz: 0 });
2530
+ });
2531
+
2532
+ state.sim.edges.forEach(function (edge) {
2533
+ var from = state.three.nodeById.get(edge.from);
2534
+ var to = state.three.nodeById.get(edge.to);
2535
+ if (!from || !to) return;
2536
+ var fromEntity = from.node.entity;
2537
+ var toEntity = to.node.entity;
2538
+ var connected = state.selected && state.selected.kind === "entity" && (state.selected.id === edge.from || state.selected.id === edge.to);
2539
+ var geometry = new THREE.BufferGeometry().setFromPoints([from.mesh.position, to.mesh.position]);
2540
+ var material = new THREE.LineBasicMaterial({
2541
+ color: new THREE.Color(edgeThemeColor(edge, fromEntity, toEntity)),
2542
+ transparent: true,
2543
+ opacity: threeEdgeOpacity(edge, fromEntity, toEntity, connected),
2544
+ depthWrite: false,
2545
+ depthTest: false
2546
+ });
2547
+ var line = new THREE.Line(geometry, material);
2548
+ line.userData.edge = edge;
2549
+ line.userData.from = edge.from;
2550
+ line.userData.to = edge.to;
2551
+ state.three.edgeGroup.add(line);
2552
+ state.three.edgeRefs.push(line);
2553
+ });
2554
+
2555
+ if (state.three.root) {
2556
+ state.three.root.rotation.x = state.three.rotationX;
2557
+ state.three.root.rotation.y = state.three.rotationY;
2558
+ }
2559
+ }
2560
+
2561
+ function clearThreeGroup(group) {
2562
+ if (!group) return;
2563
+ while (group.children.length) {
2564
+ var child = group.children.pop();
2565
+ if (!child) continue;
2566
+ if (child.geometry && child.geometry.dispose) child.geometry.dispose();
2567
+ if (child.material) {
2568
+ if (Array.isArray(child.material)) child.material.forEach(function (material) { if (material.dispose) material.dispose(); });
2569
+ else if (child.material.dispose) child.material.dispose();
2570
+ }
2571
+ }
2572
+ }
2573
+
2574
+ function threeNodeTexture(entity, selected) {
2575
+ var THREE = state.three.THREE;
2576
+ var color = nodeThemeColor(entity);
2577
+ var key = [color, entity.graph_kind || "", entity.type || "", selected ? "selected" : "normal", isDependencyEntity(entity) ? "dependency" : "primary"].join("|");
2578
+ if (state.three.nodeTextureCache.has(key)) return state.three.nodeTextureCache.get(key);
2579
+ var canvas = document.createElement("canvas");
2580
+ canvas.width = 128;
2581
+ canvas.height = 128;
2582
+ var ctx = canvas.getContext("2d");
2583
+ var rgb = hexToRgb(color);
2584
+ var fill = nodeFillColor(entity);
2585
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2586
+ var halo = ctx.createRadialGradient(64, 64, 8, 64, 64, 58);
2587
+ halo.addColorStop(0, "rgba(" + rgb.r + "," + rgb.g + "," + rgb.b + "," + (selected ? 0.62 : 0.38) + ")");
2588
+ halo.addColorStop(0.52, "rgba(" + rgb.r + "," + rgb.g + "," + rgb.b + "," + (selected ? 0.24 : 0.13) + ")");
2589
+ halo.addColorStop(1, "rgba(" + rgb.r + "," + rgb.g + "," + rgb.b + ",0)");
2590
+ ctx.fillStyle = halo;
2591
+ ctx.beginPath();
2592
+ ctx.arc(64, 64, 60, 0, Math.PI * 2);
2593
+ ctx.fill();
2594
+ ctx.fillStyle = fill;
2595
+ ctx.beginPath();
2596
+ ctx.arc(64, 64, selected ? 36 : 31, 0, Math.PI * 2);
2597
+ ctx.fill();
2598
+ ctx.strokeStyle = color;
2599
+ ctx.lineWidth = selected ? 8 : 5;
2600
+ ctx.beginPath();
2601
+ ctx.arc(64, 64, selected ? 38 : 33, 0, Math.PI * 2);
2602
+ ctx.stroke();
2603
+ if (entity.graph_kind === "memory") {
2604
+ ctx.fillStyle = color;
2605
+ ctx.globalAlpha = 0.95;
2606
+ ctx.beginPath();
2607
+ ctx.arc(64, 64, 8, 0, Math.PI * 2);
2608
+ ctx.fill();
2609
+ ctx.globalAlpha = 1;
2610
+ }
2611
+ var texture = new THREE.CanvasTexture(canvas);
2612
+ texture.needsUpdate = true;
2613
+ state.three.nodeTextureCache.set(key, texture);
2614
+ return texture;
2615
+ }
2616
+
2617
+ function threeNodeSize(node, selected) {
2618
+ var entity = node.entity;
2619
+ var size = clamp(19 + degreeOf(node.id) * 1.65, 22, entity.graph_kind === "memory" ? 46 : 40);
2620
+ if (entity.type === "file" || entity.type === "repo") size += 5;
2621
+ if (selected) size *= 1.22;
2622
+ return size;
2623
+ }
2624
+
2625
+ function threeEdgeOpacity(edge, fromEntity, toEntity, connected) {
2626
+ if (connected) return 0.48;
2627
+ if (edge.memory_code_link || isMemoryCodeRelation(edge.relation)) return 0.34;
2628
+ if (fromEntity.graph_kind === "code" && toEntity.graph_kind === "code") return 0.20;
2629
+ if (fromEntity.graph_kind === "memory" && toEntity.graph_kind === "memory") return 0.12;
2630
+ return 0.15;
2631
+ }
2632
+
2633
+ function threePosition(node, index) {
2634
+ var entity = node.entity;
2635
+ var total = Math.max(1, state.sim.nodes.length);
2636
+ var rank = index + 0.5;
2637
+ var golden = Math.PI * (3 - Math.sqrt(5));
2638
+ var theta = golden * rank + (hashString(node.id || index) % 360) * 0.004;
2639
+ var yUnit = 1 - (2 * rank) / total;
2640
+ var ring = Math.sqrt(Math.max(0, 1 - yUnit * yUnit));
2641
+ var radius = threeOrbRadius(total);
2642
+ var inner = threeOrbScale(entity);
2643
+ var kindOffset = threeKindDepthBias(entity);
2644
+ return {
2645
+ x: Math.cos(theta) * ring * radius * inner,
2646
+ y: yUnit * radius * inner,
2647
+ z: (Math.sin(theta) * ring + kindOffset) * radius * inner
2648
+ };
2649
+ }
2650
+
2651
+ function threeOrbRadius(total) {
2652
+ return clamp(245 + Math.max(1, total) * 2.1, 280, 455);
2653
+ }
2654
+
2655
+ function threeOrbScale(entity) {
2656
+ if (entity.graph_kind === "memory") return 0.88;
2657
+ if (entity.type === "file" || entity.type === "repo") return 0.76;
2658
+ if (isDependencyEntity(entity)) return 1.08;
2659
+ return 1;
2660
+ }
2661
+
2662
+ function threeKindDepthBias(entity) {
2663
+ if (entity.graph_kind === "memory") return -0.22;
2664
+ if (entity.graph_kind === "code") return 0.18;
2665
+ return 0;
2666
+ }
2667
+
2668
+ function resizeThreeGraph() {
2669
+ if (!state.three.renderer || !els.threeGraph) return;
2670
+ var rect = els.threeGraph.getBoundingClientRect();
2671
+ var width = Math.max(320, rect.width);
2672
+ var height = Math.max(360, rect.height);
2673
+ state.three.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
2674
+ state.three.renderer.setSize(width, height, false);
2675
+ state.three.camera.aspect = width / height;
2676
+ state.three.camera.updateProjectionMatrix();
2677
+ }
2678
+
2679
+ function fitThreeGraph() {
2680
+ var entries = Array.from(state.three.nodeById.values());
2681
+ if (!entries.length) {
2682
+ state.three.distance = 850;
2683
+ return;
2684
+ }
2685
+ var maxRadius = entries.reduce(function (max, entry) {
2686
+ var p = entry.mesh.position;
2687
+ return Math.max(max, Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z));
2688
+ }, 260);
2689
+ state.three.distance = clamp(maxRadius * 2.35, 760, 2600);
2690
+ }
2691
+
2692
+ function zoomThreeGraph(factor) {
2693
+ state.three.distance = clamp(state.three.distance / factor, 320, 3200);
2694
+ }
2695
+
2696
+ function startThreeGraph() {
2697
+ if (state.three.raf) return;
2698
+ function frame() {
2699
+ if (activeRenderMode() !== "3d" || !state.three.renderer) {
2700
+ state.three.raf = null;
2701
+ return;
2702
+ }
2703
+ if (!state.three.drag && state.three.root) {
2704
+ state.three.rotationY += 0.0017;
2705
+ state.three.root.rotation.y = state.three.rotationY;
2706
+ }
2707
+ stepThreePhysics();
2708
+ renderThreeFrame();
2709
+ state.three.raf = window.requestAnimationFrame(frame);
2710
+ }
2711
+ state.three.raf = window.requestAnimationFrame(frame);
2712
+ }
2713
+
2714
+ function stopThreeGraph() {
2715
+ if (state.three.raf) window.cancelAnimationFrame(state.three.raf);
2716
+ state.three.raf = null;
2717
+ if (els.tooltip) els.tooltip.classList.remove("visible");
2718
+ }
2719
+
2720
+ function renderThreeFrame() {
2721
+ if (!state.three.renderer || !state.three.scene || !state.three.camera) return;
2722
+ resizeThreeGraph();
2723
+ state.three.camera.position.set(state.three.target.x, state.three.target.y, state.three.distance);
2724
+ state.three.camera.lookAt(state.three.target.x, state.three.target.y, state.three.target.z);
2725
+ updateThreeEdges();
2726
+ state.three.renderer.render(state.three.scene, state.three.camera);
2727
+ }
2728
+
2729
+ function stepThreePhysics() {
2730
+ if (!state.three.nodeById || state.three.physicsTick > 180) return;
2731
+ if (state.three.drag && state.three.drag.type === "node") return;
2732
+ var entries = Array.from(state.three.nodeById.values());
2733
+ var count = entries.length;
2734
+ if (!count) return;
2735
+ var forces = new Map(entries.map(function (entry) {
2736
+ return [entry.node.id, { x: 0, y: 0, z: 0 }];
2737
+ }));
2738
+ var repulsion = count > 110 ? 1800 : count > 70 ? 2600 : 3400;
2739
+ for (var i = 0; i < entries.length; i++) {
2740
+ for (var j = i + 1; j < entries.length; j++) {
2741
+ var a = entries[i];
2742
+ var b = entries[j];
2743
+ var dx = a.mesh.position.x - b.mesh.position.x;
2744
+ var dy = a.mesh.position.y - b.mesh.position.y;
2745
+ var dz = a.mesh.position.z - b.mesh.position.z;
2746
+ var distSq = Math.max(1200, dx * dx + dy * dy + dz * dz);
2747
+ var dist = Math.sqrt(distSq);
2748
+ var force = repulsion / distSq;
2749
+ var ax = (dx / dist) * force;
2750
+ var ay = (dy / dist) * force;
2751
+ var az = (dz / dist) * force;
2752
+ var fa = forces.get(a.node.id);
2753
+ var fb = forces.get(b.node.id);
2754
+ fa.x += ax;
2755
+ fa.y += ay;
2756
+ fa.z += az;
2757
+ fb.x -= ax;
2758
+ fb.y -= ay;
2759
+ fb.z -= az;
2760
+ }
2761
+ }
2762
+
2763
+ state.sim.edges.forEach(function (edge) {
2764
+ var from = state.three.nodeById.get(edge.from);
2765
+ var to = state.three.nodeById.get(edge.to);
2766
+ if (!from || !to) return;
2767
+ var dx = to.mesh.position.x - from.mesh.position.x;
2768
+ var dy = to.mesh.position.y - from.mesh.position.y;
2769
+ var dz = to.mesh.position.z - from.mesh.position.z;
2770
+ var dist = Math.max(1, Math.sqrt(dx * dx + dy * dy + dz * dz));
2771
+ var memoryCode = edge.memory_code_link || isMemoryCodeRelation(edge.relation);
2772
+ var target = memoryCode ? 190 : (from.node.entity.graph_kind === "code" && to.node.entity.graph_kind === "code" ? 240 : 220);
2773
+ var strength = memoryCode ? 0.0032 : 0.0018;
2774
+ var force = (dist - target) * strength;
2775
+ var fx = (dx / dist) * force;
2776
+ var fy = (dy / dist) * force;
2777
+ var fz = (dz / dist) * force;
2778
+ var ff = forces.get(from.node.id);
2779
+ var ft = forces.get(to.node.id);
2780
+ ff.x += fx;
2781
+ ff.y += fy;
2782
+ ff.z += fz;
2783
+ ft.x -= fx;
2784
+ ft.y -= fy;
2785
+ ft.z -= fz;
2786
+ });
2787
+
2788
+ var baseRadius = threeOrbRadius(count);
2789
+ var maxVelocity = 0;
2790
+ entries.forEach(function (entry) {
2791
+ var p = entry.mesh.position;
2792
+ var entity = entry.node.entity;
2793
+ var len = Math.max(1, Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z));
2794
+ var desiredRadius = baseRadius * threeOrbScale(entity);
2795
+ var radial = (desiredRadius - len) * 0.055;
2796
+ var f = forces.get(entry.node.id);
2797
+ f.x += (p.x / len) * radial;
2798
+ f.y += (p.y / len) * radial;
2799
+ f.z += (p.z / len) * radial;
2800
+ var depthTarget = desiredRadius * threeKindDepthBias(entity);
2801
+ f.z += (depthTarget - p.z) * 0.002;
2802
+ entry.vx = clamp((entry.vx + f.x) * 0.80, -4.2, 4.2);
2803
+ entry.vy = clamp((entry.vy + f.y) * 0.80, -4.2, 4.2);
2804
+ entry.vz = clamp((entry.vz + f.z) * 0.80, -4.2, 4.2);
2805
+ p.x += entry.vx;
2806
+ p.y += entry.vy;
2807
+ p.z += entry.vz;
2808
+ var nextLen = Math.max(1, Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z));
2809
+ var correction = (desiredRadius - nextLen) * 0.08;
2810
+ p.x += (p.x / nextLen) * correction;
2811
+ p.y += (p.y / nextLen) * correction;
2812
+ p.z += (p.z / nextLen) * correction;
2813
+ maxVelocity = Math.max(maxVelocity, Math.abs(entry.vx), Math.abs(entry.vy), Math.abs(entry.vz));
2814
+ });
2815
+ state.three.physicsTick += 1;
2816
+ state.three.physicsIdle = maxVelocity < 0.030 ? state.three.physicsIdle + 1 : 0;
2817
+ if (state.three.physicsIdle > 32) state.three.physicsTick = 181;
2818
+ }
2819
+
2820
+ function startThreePointer(event) {
2821
+ if (activeRenderMode() !== "3d" || event.button !== 0) return;
2822
+ event.preventDefault();
2823
+ var picked = pickThreeNode(event);
2824
+ if (picked) {
2825
+ var entry = state.three.nodeById.get(picked.id);
2826
+ state.three.drag = {
2827
+ type: "node",
2828
+ x: event.clientX,
2829
+ y: event.clientY,
2830
+ moved: false,
2831
+ picked: picked,
2832
+ entry: entry,
2833
+ plane: createThreeDragPlane(entry && entry.mesh)
2834
+ };
2835
+ return;
2836
+ }
2837
+ state.three.drag = {
2838
+ type: "space",
2839
+ x: event.clientX,
2840
+ y: event.clientY,
2841
+ rotationX: state.three.rotationX,
2842
+ rotationY: state.three.rotationY,
2843
+ moved: false,
2844
+ picked: null
2845
+ };
2846
+ }
2847
+
2848
+ function moveThreePointer(event) {
2849
+ if (activeRenderMode() !== "3d") return;
2850
+ state.three.lastPointerEvent = event;
2851
+ if (state.three.drag) {
2852
+ var dx = event.clientX - state.three.drag.x;
2853
+ var dy = event.clientY - state.three.drag.y;
2854
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) state.three.drag.moved = true;
2855
+ if (state.three.drag.type === "node") {
2856
+ moveThreeDraggedNode(event);
2857
+ renderThreeFrame();
2858
+ return;
2859
+ }
2860
+ state.three.rotationY = state.three.drag.rotationY + dx * 0.0065;
2861
+ state.three.rotationX = clamp(state.three.drag.rotationX + dy * 0.0048, -1.15, 0.45);
2862
+ if (state.three.root) {
2863
+ state.three.root.rotation.x = state.three.rotationX;
2864
+ state.three.root.rotation.y = state.three.rotationY;
2865
+ }
2866
+ renderThreeFrame();
2867
+ return;
2868
+ }
2869
+ state.three.hoverNode = pickThreeNode(event);
2870
+ updateThreeTooltip(event);
2871
+ }
2872
+
2873
+ function endThreePointer(event) {
2874
+ if (!state.three.drag) return;
2875
+ var picked = !state.three.drag.moved || state.three.drag.type === "node"
2876
+ ? (pickThreeNode(event) || state.three.drag.picked)
2877
+ : null;
2878
+ state.three.drag = null;
2879
+ if (picked) {
2880
+ state.selected = { kind: "entity", id: picked.id };
2881
+ render();
2882
+ }
2883
+ }
2884
+
2885
+ function leaveThreePointer() {
2886
+ state.three.hoverNode = null;
2887
+ state.three.drag = null;
2888
+ if (els.tooltip) els.tooltip.classList.remove("visible");
2889
+ }
2890
+
2891
+ function handleThreeWheel(event) {
2892
+ if (activeRenderMode() !== "3d") return;
2893
+ event.preventDefault();
2894
+ zoomThreeGraph(event.deltaY > 0 ? 0.88 : 1.14);
2895
+ renderThreeFrame();
2896
+ }
2897
+
2898
+ function handleThreeDoubleClick(event) {
2899
+ var picked = pickThreeNode(event);
2900
+ if (!picked) return;
2901
+ state.selected = { kind: "entity", id: picked.id };
2902
+ render();
2903
+ }
2904
+
2905
+ function pickThreeNode(event) {
2906
+ if (!state.three.raycaster || !state.three.camera || !state.three.pointer) return null;
2907
+ setThreePointerFromEvent(event);
2908
+ state.three.raycaster.setFromCamera(state.three.pointer, state.three.camera);
2909
+ var meshes = Array.from(state.three.nodeById.values()).map(function (entry) { return entry.mesh; });
2910
+ var hits = state.three.raycaster.intersectObjects(meshes, false);
2911
+ return hits.length && hits[0].object.userData ? hits[0].object.userData.node : null;
2912
+ }
2913
+
2914
+ function createThreeDragPlane(mesh) {
2915
+ if (!mesh || !state.three.camera || !state.three.THREE) return null;
2916
+ var THREE = state.three.THREE;
2917
+ state.three.root.updateMatrixWorld(true);
2918
+ state.three.camera.updateMatrixWorld(true);
2919
+ var normal = new THREE.Vector3();
2920
+ state.three.camera.getWorldDirection(normal);
2921
+ var point = new THREE.Vector3();
2922
+ mesh.getWorldPosition(point);
2923
+ return new THREE.Plane().setFromNormalAndCoplanarPoint(normal, point);
2924
+ }
2925
+
2926
+ function moveThreeDraggedNode(event) {
2927
+ var drag = state.three.drag;
2928
+ if (!drag || !drag.entry || !drag.entry.mesh || !drag.plane || !state.three.raycaster) return;
2929
+ var THREE = state.three.THREE;
2930
+ setThreePointerFromEvent(event);
2931
+ state.three.raycaster.setFromCamera(state.three.pointer, state.three.camera);
2932
+ var hit = new THREE.Vector3();
2933
+ if (!state.three.raycaster.ray.intersectPlane(drag.plane, hit)) return;
2934
+ var local = hit.clone();
2935
+ state.three.root.worldToLocal(local);
2936
+ drag.entry.mesh.position.copy(local);
2937
+ drag.entry.vx = 0;
2938
+ drag.entry.vy = 0;
2939
+ drag.entry.vz = 0;
2940
+ state.three.physicsTick = 0;
2941
+ state.three.physicsIdle = 0;
2942
+ updateThreeEdges();
2943
+ }
2944
+
2945
+ function updateThreeEdges() {
2946
+ if (!state.three.edgeRefs || !state.three.edgeRefs.length) return;
2947
+ state.three.edgeRefs.forEach(function (line) {
2948
+ var from = state.three.nodeById.get(line.userData.from);
2949
+ var to = state.three.nodeById.get(line.userData.to);
2950
+ if (!from || !to || !line.geometry) return;
2951
+ var attr = line.geometry.getAttribute("position");
2952
+ if (!attr) return;
2953
+ attr.setXYZ(0, from.mesh.position.x, from.mesh.position.y, from.mesh.position.z);
2954
+ attr.setXYZ(1, to.mesh.position.x, to.mesh.position.y, to.mesh.position.z);
2955
+ attr.needsUpdate = true;
2956
+ line.geometry.computeBoundingSphere();
2957
+ });
2958
+ }
2959
+
2960
+ function setThreePointerFromEvent(event) {
2961
+ var rect = els.threeGraph.getBoundingClientRect();
2962
+ state.three.pointer.x = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * 2 - 1;
2963
+ state.three.pointer.y = -(((event.clientY - rect.top) / Math.max(rect.height, 1)) * 2 - 1);
2964
+ }
2965
+
2966
+ function updateThreeTooltip(event) {
2967
+ if (!els.tooltip) return;
2968
+ var node = state.three.hoverNode;
2969
+ if (!node || state.three.drag) {
2970
+ els.tooltip.classList.remove("visible");
2971
+ return;
2972
+ }
2973
+ var entity = node.entity;
2974
+ var relationCount = state.sim.edges.filter(function (edge) { return edge.from === node.id || edge.to === node.id; }).length;
2975
+ var color = nodeThemeColor(entity);
2976
+ els.tooltip.innerHTML = [
2977
+ "<div class=\"tt-name\"></div>",
2978
+ "<div class=\"tt-type\"></div>",
2979
+ "<div class=\"tt-summary\"></div>",
2980
+ "<div class=\"tt-conns\"></div>"
2981
+ ].join("");
2982
+ els.tooltip.querySelector(".tt-name").textContent = displayName(entity);
2983
+ els.tooltip.querySelector(".tt-type").textContent = nodeKindLabel(entity);
2984
+ els.tooltip.querySelector(".tt-type").style.color = color;
2985
+ els.tooltip.querySelector(".tt-summary").textContent = shortName(entity.summary || entity.id, 150);
2986
+ els.tooltip.querySelector(".tt-conns").textContent = relationCount + " relation" + (relationCount === 1 ? "" : "s");
2987
+ var rect = els.threeGraph.getBoundingClientRect();
2988
+ els.tooltip.style.left = (event.clientX - rect.left + 14) + "px";
2989
+ els.tooltip.style.top = (event.clientY - rect.top + 14) + "px";
2990
+ els.tooltip.classList.add("visible");
2991
+ }
2992
+
2993
+ function hashString(value) {
2994
+ var text = String(value || "");
2995
+ var hash = 0;
2996
+ for (var i = 0; i < text.length; i++) {
2997
+ hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
2998
+ }
2999
+ return Math.abs(hash);
3000
+ }
3001
+
2320
3002
  function focusedCanvasNodeId() {
2321
3003
  if (state.sim.hoverNode) return state.sim.hoverNode.id;
2322
3004
  if (state.selected && state.selected.kind === "entity") return state.selected.id;
package/viewer/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Kage Memory Terminal</title>
7
- <link rel="stylesheet" href="./styles.css?v=15">
7
+ <link rel="stylesheet" href="./styles.css?v=19">
8
8
  </head>
9
9
  <body>
10
10
  <header class="app-header">
@@ -13,6 +13,12 @@
13
13
  <h1>Memory Terminal</h1>
14
14
  <p id="graphSummary">Load memory, code, and metrics graphs to inspect what agents know and why.</p>
15
15
  </div>
16
+ <nav class="site-links" aria-label="Kage site links">
17
+ <a href="../">Home</a>
18
+ <a href="../guide.html">Docs</a>
19
+ <a href="../releases.html">Releases</a>
20
+ <a href="https://github.com/kage-core/Kage">GitHub</a>
21
+ </nav>
16
22
  <div id="statusStrip" class="status-strip" aria-label="Graph health"></div>
17
23
  <div id="autoLoadStatus" class="autoload-status">auto-load: waiting</div>
18
24
  <label class="file-picker">
@@ -29,14 +35,15 @@
29
35
  <p id="graphSubhead">Combined repo memory and source graph.</p>
30
36
  </div>
31
37
  <div class="graph-actions" aria-label="Graph view controls">
32
- <span class="interaction-hint">drag canvas / wheel zoom / click node</span>
38
+ <span id="interactionHint" class="interaction-hint">drag canvas / wheel zoom / click node</span>
33
39
  <button id="zoomOut" type="button">-</button>
34
40
  <button id="fitView" type="button">Fit</button>
35
41
  <button id="zoomIn" type="button">+</button>
36
42
  </div>
37
43
  </div>
38
- <div class="graph-canvas-wrap">
44
+ <div id="graphCanvasWrap" class="graph-canvas-wrap">
39
45
  <canvas id="graphCanvas" aria-label="Interactive Kage memory and code graph"></canvas>
46
+ <div id="threeGraph" class="three-graph" aria-label="Interactive 3D Kage memory and code graph"></div>
40
47
  <div id="graphTooltip" class="graph-tooltip" role="status"></div>
41
48
  </div>
42
49
  <svg id="graphSvg" class="fallback-graph" viewBox="0 0 1000 660" role="img" aria-labelledby="graphTitle graphDescription">
@@ -74,6 +81,13 @@
74
81
  <option value="code">Code</option>
75
82
  </select>
76
83
  </label>
84
+ <label>
85
+ <span>Graph Mode</span>
86
+ <select id="renderMode">
87
+ <option value="2d" selected>2D Canvas</option>
88
+ <option value="3d">3D Space</option>
89
+ </select>
90
+ </label>
77
91
  <label>
78
92
  <span>Node Type</span>
79
93
  <select id="typeFilter">
@@ -149,6 +163,6 @@
149
163
  </section>
150
164
  </main>
151
165
 
152
- <script src="./app.js?v=15"></script>
166
+ <script src="./app.js?v=19"></script>
153
167
  </body>
154
168
  </html>
package/viewer/styles.css CHANGED
@@ -40,7 +40,10 @@ h2 { color: var(--terminal); font-size: 13px; letter-spacing: 0.04em; text-trans
40
40
 
41
41
  .app-header {
42
42
  display: grid;
43
- grid-template-columns: minmax(0, 1fr) auto auto auto;
43
+ grid-template-columns: minmax(360px, 1fr) auto auto;
44
+ grid-template-areas:
45
+ "brand links picker"
46
+ "status status autoload";
44
47
  gap: 16px;
45
48
  align-items: center;
46
49
  padding: 14px 18px;
@@ -52,7 +55,11 @@ h2 { color: var(--terminal); font-size: 13px; letter-spacing: 0.04em; text-trans
52
55
  z-index: 10;
53
56
  }
54
57
 
55
- .brand-block { min-width: 0; }
58
+ .brand-block {
59
+ grid-area: brand;
60
+ min-width: 280px;
61
+ max-width: 620px;
62
+ }
56
63
  .eyebrow {
57
64
  display: inline-flex;
58
65
  margin-bottom: 5px;
@@ -72,14 +79,42 @@ h2 { color: var(--terminal); font-size: 13px; letter-spacing: 0.04em; text-trans
72
79
  overflow-wrap: anywhere;
73
80
  }
74
81
 
82
+ .site-links {
83
+ grid-area: links;
84
+ display: flex;
85
+ flex-wrap: wrap;
86
+ gap: 10px;
87
+ justify-content: flex-end;
88
+ }
89
+ .site-links a {
90
+ display: inline-flex;
91
+ align-items: center;
92
+ min-height: 28px;
93
+ padding: 4px 9px;
94
+ border: 1px solid var(--line);
95
+ border-radius: 4px;
96
+ background: rgba(13, 25, 19, 0.86);
97
+ color: var(--terminal-dim);
98
+ font-size: 11px;
99
+ font-weight: 780;
100
+ text-decoration: none;
101
+ white-space: nowrap;
102
+ }
103
+ .site-links a:hover {
104
+ border-color: var(--line-strong);
105
+ color: var(--terminal-strong);
106
+ }
107
+
75
108
  .status-strip {
109
+ grid-area: status;
76
110
  display: flex;
77
111
  flex-wrap: wrap;
78
112
  gap: 8px;
79
- justify-content: flex-end;
113
+ justify-content: flex-start;
80
114
  }
81
115
 
82
116
  .autoload-status {
117
+ grid-area: autoload;
83
118
  display: inline-flex;
84
119
  align-items: center;
85
120
  min-height: 28px;
@@ -131,6 +166,7 @@ h2 { color: var(--terminal); font-size: 13px; letter-spacing: 0.04em; text-trans
131
166
  font-weight: 780;
132
167
  box-shadow: inset 0 0 0 1px rgba(65, 255, 143, 0.10);
133
168
  }
169
+ .file-picker { grid-area: picker; }
134
170
  .file-picker:hover, button:hover { background: #0d1f15; }
135
171
  .file-picker input {
136
172
  position: absolute;
@@ -337,16 +373,49 @@ input:focus, select:focus, button:focus, .file-picker:focus-within {
337
373
  #020503;
338
374
  }
339
375
 
340
- #graphCanvas {
376
+ #graphCanvas, .three-graph {
341
377
  width: 100%;
342
378
  height: 100%;
343
379
  min-height: 600px;
344
380
  display: block;
381
+ }
382
+
383
+ #graphCanvas {
345
384
  cursor: grab;
346
385
  }
347
386
 
348
387
  #graphCanvas:active { cursor: grabbing; }
349
388
 
389
+ .three-graph {
390
+ position: absolute;
391
+ inset: 0;
392
+ display: none;
393
+ cursor: grab;
394
+ background:
395
+ linear-gradient(rgba(65, 255, 143, 0.035) 1px, transparent 1px),
396
+ linear-gradient(90deg, rgba(65, 255, 143, 0.035) 1px, transparent 1px),
397
+ radial-gradient(circle at 52% 46%, rgba(106, 215, 255, 0.10), transparent 46%),
398
+ #020503;
399
+ background-size: 34px 34px, 34px 34px, 100% 100%, 100% 100%;
400
+ }
401
+ .three-graph canvas {
402
+ display: block;
403
+ width: 100% !important;
404
+ height: 100% !important;
405
+ }
406
+ .three-graph:active { cursor: grabbing; }
407
+ .three-status {
408
+ position: absolute;
409
+ inset: 0;
410
+ display: grid;
411
+ place-items: center;
412
+ color: var(--terminal-dim);
413
+ font-weight: 780;
414
+ }
415
+ .graph-canvas-wrap.mode-3d #graphCanvas { display: none; }
416
+ .graph-canvas-wrap.mode-3d .three-graph { display: block; }
417
+ .graph-canvas-wrap.mode-2d .three-graph { display: none; }
418
+
350
419
  #graphSvg {
351
420
  width: 100%;
352
421
  height: 100%;
@@ -669,6 +738,12 @@ input:focus, select:focus, button:focus, .file-picker:focus-within {
669
738
  @media (max-width: 1120px) {
670
739
  .app-header {
671
740
  grid-template-columns: 1fr;
741
+ grid-template-areas:
742
+ "brand"
743
+ "links"
744
+ "status"
745
+ "autoload"
746
+ "picker";
672
747
  align-items: stretch;
673
748
  padding: 12px 14px;
674
749
  }
@@ -693,7 +768,7 @@ input:focus, select:focus, button:focus, .file-picker:focus-within {
693
768
  .control-panel .panel-heading, .metrics-grid, .legend { grid-column: 1 / -1; }
694
769
  .control-panel label, .control-panel button { margin-top: 0; }
695
770
  .graph-panel { min-height: 620px; }
696
- #graphCanvas, .graph-canvas-wrap, #graphSvg { min-height: 560px; }
771
+ #graphCanvas, .three-graph, .graph-canvas-wrap, #graphSvg { min-height: 560px; }
697
772
  .details-panel {
698
773
  height: auto;
699
774
  max-height: 70vh;
@@ -710,5 +785,5 @@ input:focus, select:focus, button:focus, .file-picker:focus-within {
710
785
  .graph-toolbar { align-items: flex-start; flex-direction: column; }
711
786
  .graph-actions { width: 100%; }
712
787
  .interaction-hint { flex: 1; white-space: normal; }
713
- #graphCanvas, .graph-canvas-wrap, #graphSvg { min-height: 450px; }
788
+ #graphCanvas, .three-graph, .graph-canvas-wrap, #graphSvg { min-height: 450px; }
714
789
  }