Monorepo for Aesthetic.Computer
aesthetic.computer
1// 3D Process Tree Visualization
2// Shared between VS Code extension and local dev testing
3
4(function() {
5 'use strict';
6
7 // Check if we're in VS Code webview or standalone
8 const isVSCode = typeof acquireVsCodeApi === 'function';
9
10 // 🎨 Color Schemes (imported from color-schemes.js or embedded)
11 const colorSchemes = window.AestheticColorSchemes?.schemes || {
12 "dark": {
13 "background": "#181318",
14 "backgroundAlt": "#141214",
15 "foreground": "#ffffffcc",
16 "foregroundBright": "#ffffff",
17 "foregroundMuted": "#555555",
18 "accent": "#a87090",
19 "accentBright": "#ff69b4",
20 "statusOnline": "#0f0",
21 "categories": {
22 "editor": 11561983,
23 "tui": 16738740,
24 "bridge": 7077791,
25 "db": 16771947,
26 "proxy": 7053311,
27 "ai": 16752491,
28 "shell": 7077887,
29 "dev": 7077791,
30 "ide": 7053311,
31 "lsp": 8947848,
32 "kernel": 8965375
33 },
34 "three": {
35 "sceneBackground": 1577752,
36 "kernelOuter": 4491519,
37 "kernelRing": 6728447,
38 "kernelCore": 8965375,
39 "connectionLine": 4473924,
40 "connectionActive": 16738740,
41 "deadProcess": 4473924
42 },
43 "ui": {
44 "shadow": "rgba(0, 0, 0, 0.6)",
45 "overlay": "rgba(0, 0, 0, 0.85)"
46 }
47 },
48 "light": {
49 "background": "#fcf7c5",
50 "backgroundAlt": "#f5f0c0",
51 "foreground": "#281e5a",
52 "foregroundBright": "#281e5a",
53 "foregroundMuted": "#806060",
54 "accent": "#387adf",
55 "accentBright": "#006400",
56 "statusOnline": "#006400",
57 "categories": {
58 "editor": 8405200,
59 "tui": 13648000,
60 "bridge": 2129984,
61 "db": 10518528,
62 "proxy": 2121920,
63 "ai": 12607520,
64 "shell": 32896,
65 "dev": 2129984,
66 "ide": 2121920,
67 "lsp": 6316128,
68 "kernel": 3701471
69 },
70 "three": {
71 "sceneBackground": 16578501,
72 "kernelOuter": 3701471,
73 "kernelRing": 25600,
74 "kernelCore": 3701471,
75 "connectionLine": 11051136,
76 "connectionActive": 25600,
77 "deadProcess": 11051136
78 },
79 "ui": {
80 "shadow": "rgba(0, 0, 0, 0.2)",
81 "overlay": "rgba(252, 247, 197, 0.95)"
82 }
83 },
84 "red": {
85 "background": "#181010",
86 "backgroundAlt": "#140c0c",
87 "foreground": "#ffffffcc",
88 "foregroundBright": "#ffffff",
89 "foregroundMuted": "#555555",
90 "accent": "#ff5555",
91 "accentBright": "#ff8888",
92 "statusOnline": "#0f0",
93 "categories": {
94 "editor": 11561983,
95 "tui": 16738740,
96 "bridge": 7077791,
97 "db": 16771947,
98 "proxy": 7053311,
99 "ai": 16752491,
100 "shell": 7077887,
101 "dev": 7077791,
102 "ide": 7053311,
103 "lsp": 8947848,
104 "kernel": 8965375
105 },
106 "three": {
107 "sceneBackground": 1576976,
108 "kernelOuter": 4491519,
109 "kernelRing": 6728447,
110 "kernelCore": 8965375,
111 "connectionLine": 4473924,
112 "connectionActive": 16746632,
113 "deadProcess": 4473924
114 },
115 "ui": {
116 "shadow": "rgba(0, 0, 0, 0.6)",
117 "overlay": "rgba(0, 0, 0, 0.85)"
118 }
119 },
120 "orange": {
121 "background": "#181410",
122 "backgroundAlt": "#14100c",
123 "foreground": "#ffffffcc",
124 "foregroundBright": "#ffffff",
125 "foregroundMuted": "#555555",
126 "accent": "#ffb86c",
127 "accentBright": "#ffd8a8",
128 "statusOnline": "#0f0",
129 "categories": {
130 "editor": 11561983,
131 "tui": 16738740,
132 "bridge": 7077791,
133 "db": 16771947,
134 "proxy": 7053311,
135 "ai": 16752491,
136 "shell": 7077887,
137 "dev": 7077791,
138 "ide": 7053311,
139 "lsp": 8947848,
140 "kernel": 8965375
141 },
142 "three": {
143 "sceneBackground": 1578000,
144 "kernelOuter": 4491519,
145 "kernelRing": 6728447,
146 "kernelCore": 8965375,
147 "connectionLine": 4473924,
148 "connectionActive": 16767144,
149 "deadProcess": 4473924
150 },
151 "ui": {
152 "shadow": "rgba(0, 0, 0, 0.6)",
153 "overlay": "rgba(0, 0, 0, 0.85)"
154 }
155 },
156 "yellow": {
157 "background": "#181810",
158 "backgroundAlt": "#14140c",
159 "foreground": "#ffffffcc",
160 "foregroundBright": "#ffffff",
161 "foregroundMuted": "#555555",
162 "accent": "#f1fa8c",
163 "accentBright": "#ffffa0",
164 "statusOnline": "#0f0",
165 "categories": {
166 "editor": 11561983,
167 "tui": 16738740,
168 "bridge": 7077791,
169 "db": 16771947,
170 "proxy": 7053311,
171 "ai": 16752491,
172 "shell": 7077887,
173 "dev": 7077791,
174 "ide": 7053311,
175 "lsp": 8947848,
176 "kernel": 8965375
177 },
178 "three": {
179 "sceneBackground": 1579024,
180 "kernelOuter": 4491519,
181 "kernelRing": 6728447,
182 "kernelCore": 8965375,
183 "connectionLine": 4473924,
184 "connectionActive": 16777120,
185 "deadProcess": 4473924
186 },
187 "ui": {
188 "shadow": "rgba(0, 0, 0, 0.6)",
189 "overlay": "rgba(0, 0, 0, 0.85)"
190 }
191 },
192 "green": {
193 "background": "#101810",
194 "backgroundAlt": "#0c140c",
195 "foreground": "#ffffffcc",
196 "foregroundBright": "#ffffff",
197 "foregroundMuted": "#555555",
198 "accent": "#50fa7b",
199 "accentBright": "#80ffae",
200 "statusOnline": "#0f0",
201 "categories": {
202 "editor": 11561983,
203 "tui": 16738740,
204 "bridge": 7077791,
205 "db": 16771947,
206 "proxy": 7053311,
207 "ai": 16752491,
208 "shell": 7077887,
209 "dev": 7077791,
210 "ide": 7053311,
211 "lsp": 8947848,
212 "kernel": 8965375
213 },
214 "three": {
215 "sceneBackground": 1054736,
216 "kernelOuter": 4491519,
217 "kernelRing": 6728447,
218 "kernelCore": 8965375,
219 "connectionLine": 4473924,
220 "connectionActive": 8454062,
221 "deadProcess": 4473924
222 },
223 "ui": {
224 "shadow": "rgba(0, 0, 0, 0.6)",
225 "overlay": "rgba(0, 0, 0, 0.85)"
226 }
227 },
228 "blue": {
229 "background": "#101418",
230 "backgroundAlt": "#0c1014",
231 "foreground": "#ffffffcc",
232 "foregroundBright": "#ffffff",
233 "foregroundMuted": "#555555",
234 "accent": "#61afef",
235 "accentBright": "#8cd0ff",
236 "statusOnline": "#0f0",
237 "categories": {
238 "editor": 11561983,
239 "tui": 16738740,
240 "bridge": 7077791,
241 "db": 16771947,
242 "proxy": 7053311,
243 "ai": 16752491,
244 "shell": 7077887,
245 "dev": 7077791,
246 "ide": 7053311,
247 "lsp": 8947848,
248 "kernel": 8965375
249 },
250 "three": {
251 "sceneBackground": 1053720,
252 "kernelOuter": 4491519,
253 "kernelRing": 6728447,
254 "kernelCore": 8965375,
255 "connectionLine": 4473924,
256 "connectionActive": 9228543,
257 "deadProcess": 4473924
258 },
259 "ui": {
260 "shadow": "rgba(0, 0, 0, 0.6)",
261 "overlay": "rgba(0, 0, 0, 0.85)"
262 }
263 },
264 "indigo": {
265 "background": "#121018",
266 "backgroundAlt": "#0e0c14",
267 "foreground": "#ffffffcc",
268 "foregroundBright": "#ffffff",
269 "foregroundMuted": "#555555",
270 "accent": "#6272a4",
271 "accentBright": "#8be9fd",
272 "statusOnline": "#0f0",
273 "categories": {
274 "editor": 11561983,
275 "tui": 16738740,
276 "bridge": 7077791,
277 "db": 16771947,
278 "proxy": 7053311,
279 "ai": 16752491,
280 "shell": 7077887,
281 "dev": 7077791,
282 "ide": 7053311,
283 "lsp": 8947848,
284 "kernel": 8965375
285 },
286 "three": {
287 "sceneBackground": 1183768,
288 "kernelOuter": 4491519,
289 "kernelRing": 6728447,
290 "kernelCore": 8965375,
291 "connectionLine": 4473924,
292 "connectionActive": 9169405,
293 "deadProcess": 4473924
294 },
295 "ui": {
296 "shadow": "rgba(0, 0, 0, 0.6)",
297 "overlay": "rgba(0, 0, 0, 0.85)"
298 }
299 },
300 "violet": {
301 "background": "#161016",
302 "backgroundAlt": "#120c12",
303 "foreground": "#ffffffcc",
304 "foregroundBright": "#ffffff",
305 "foregroundMuted": "#555555",
306 "accent": "#bd93f9",
307 "accentBright": "#ff79c6",
308 "statusOnline": "#0f0",
309 "categories": {
310 "editor": 11561983,
311 "tui": 16738740,
312 "bridge": 7077791,
313 "db": 16771947,
314 "proxy": 7053311,
315 "ai": 16752491,
316 "shell": 7077887,
317 "dev": 7077791,
318 "ide": 7053311,
319 "lsp": 8947848,
320 "kernel": 8965375
321 },
322 "three": {
323 "sceneBackground": 1445910,
324 "kernelOuter": 4491519,
325 "kernelRing": 6728447,
326 "kernelCore": 8965375,
327 "connectionLine": 4473924,
328 "connectionActive": 16742854,
329 "deadProcess": 4473924
330 },
331 "ui": {
332 "shadow": "rgba(0, 0, 0, 0.6)",
333 "overlay": "rgba(0, 0, 0, 0.85)"
334 }
335 },
336 "pink": {
337 "background": "#181014",
338 "backgroundAlt": "#140c10",
339 "foreground": "#ffffffcc",
340 "foregroundBright": "#ffffff",
341 "foregroundMuted": "#555555",
342 "accent": "#ff79c6",
343 "accentBright": "#ff9ce6",
344 "statusOnline": "#0f0",
345 "categories": {
346 "editor": 11561983,
347 "tui": 16738740,
348 "bridge": 7077791,
349 "db": 16771947,
350 "proxy": 7053311,
351 "ai": 16752491,
352 "shell": 7077887,
353 "dev": 7077791,
354 "ide": 7053311,
355 "lsp": 8947848,
356 "kernel": 8965375
357 },
358 "three": {
359 "sceneBackground": 1576980,
360 "kernelOuter": 4491519,
361 "kernelRing": 6728447,
362 "kernelCore": 8965375,
363 "connectionLine": 4473924,
364 "connectionActive": 16751846,
365 "deadProcess": 4473924
366 },
367 "ui": {
368 "shadow": "rgba(0, 0, 0, 0.6)",
369 "overlay": "rgba(0, 0, 0, 0.85)"
370 }
371 },
372 "pencil": {
373 "background": "#181818",
374 "backgroundAlt": "#141414",
375 "foreground": "#ffffffcc",
376 "foregroundBright": "#ffffff",
377 "foregroundMuted": "#555555",
378 "accent": "#e0e0e0",
379 "accentBright": "#ffffff",
380 "statusOnline": "#0f0",
381 "categories": {
382 "editor": 11561983,
383 "tui": 16738740,
384 "bridge": 7077791,
385 "db": 16771947,
386 "proxy": 7053311,
387 "ai": 16752491,
388 "shell": 7077887,
389 "dev": 7077791,
390 "ide": 7053311,
391 "lsp": 8947848,
392 "kernel": 8965375
393 },
394 "three": {
395 "sceneBackground": 1579032,
396 "kernelOuter": 4491519,
397 "kernelRing": 6728447,
398 "kernelCore": 8965375,
399 "connectionLine": 4473924,
400 "connectionActive": 16777215,
401 "deadProcess": 4473924
402 },
403 "ui": {
404 "shadow": "rgba(0, 0, 0, 0.6)",
405 "overlay": "rgba(0, 0, 0, 0.85)"
406 }
407 }
408};
409
410 // Detect theme from data attribute, URL param, VS Code CSS vars, or OS preference
411 function detectTheme() {
412 // Check data attribute first (set by the HTML)
413 const dataTheme = document.body.dataset.theme;
414 if (colorSchemes[dataTheme]) return dataTheme;
415
416 // Check URL param
417 const urlParams = new URLSearchParams(window.location.search);
418 const urlTheme = urlParams.get('theme');
419 if (colorSchemes[urlTheme]) return urlTheme;
420
421 // Check VS Code CSS variables
422 if (typeof getComputedStyle !== 'undefined') {
423 const bgColor = getComputedStyle(document.body).getPropertyValue('--vscode-editor-background').trim();
424
425 if (bgColor && bgColor.startsWith('#')) {
426 // Exact match against known backgrounds
427 const bgLower = bgColor.toLowerCase();
428 for (const [key, scheme] of Object.entries(colorSchemes)) {
429 if (scheme.background.toLowerCase() === bgLower) {
430 return key;
431 }
432 }
433
434 // Check for Light vs Dark if no exact match
435 const r = parseInt(bgColor.slice(1, 3), 16);
436 const g = parseInt(bgColor.slice(3, 5), 16);
437 const b = parseInt(bgColor.slice(5, 7), 16);
438 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
439 return luminance > 0.5 ? 'light' : 'dark';
440 }
441 }
442
443 // Fall back to OS preference (prefers-color-scheme)
444 if (typeof window !== 'undefined' && window.matchMedia) {
445 if (window.matchMedia('(prefers-color-scheme: light)').matches) {
446 return 'light';
447 }
448 }
449
450 return 'dark';
451 }
452
453 let currentTheme = detectTheme();
454 let scheme = colorSchemes[currentTheme];
455 let colors = scheme.categories;
456
457 // Apply initial body styling based on detected theme
458 document.body.style.background = scheme.background;
459 document.body.style.color = scheme.foreground;
460 document.body.dataset.theme = currentTheme;
461
462 // Dev badge is now in the HTML for dev.html - no need to create dynamically
463
464 let width = window.innerWidth, height = window.innerHeight;
465 let meshes = new Map(), connections = new Map(), ws;
466 let graveyard = [];
467 const MAX_GRAVEYARD = 30;
468 const GRAVEYARD_Y = -200;
469
470 // Three.js setup
471 const scene = new THREE.Scene();
472 scene.background = new THREE.Color(scheme.three.sceneBackground);
473
474 const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 5000);
475 camera.position.set(0, 150, 400);
476 camera.lookAt(0, 0, 0);
477
478 const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('canvas'), antialias: true });
479 renderer.setSize(width, height);
480 renderer.setPixelRatio(window.devicePixelRatio);
481 renderer.setClearColor(scheme.three.sceneBackground);
482
483 const controls = new THREE.OrbitControls(camera, renderer.domElement);
484 controls.enableDamping = true;
485 controls.dampingFactor = 0.05;
486 controls.minDistance = 20;
487 controls.maxDistance = 3000;
488 controls.enablePan = true;
489 controls.autoRotate = true;
490 controls.autoRotateSpeed = 0.3;
491 controls.target.set(0, 0, 0);
492
493 let focusedPid = null;
494 let focusTarget = new THREE.Vector3(0, 0, 0);
495 let focusDistance = null;
496 let transitioning = false;
497
498 // Tour mode state
499 let tourMode = false;
500 let tourIndex = 0;
501 let tourProcessList = [];
502 let tourAutoPlay = false;
503 let tourAutoPlayInterval = null;
504 const TOUR_SPEED = 2500; // ms between auto-advances
505
506 const raycaster = new THREE.Raycaster();
507 const mouse = new THREE.Vector2();
508
509 renderer.domElement.addEventListener('click', (e) => {
510 mouse.x = (e.clientX / width) * 2 - 1;
511 mouse.y = -(e.clientY / height) * 2 + 1;
512
513 raycaster.setFromCamera(mouse, camera);
514 const meshArray = Array.from(meshes.values());
515 const intersects = raycaster.intersectObjects(meshArray);
516
517 if (intersects.length > 0) {
518 const clicked = intersects[0].object;
519 const pid = clicked.userData.pid;
520
521 if (focusedPid === String(pid)) {
522 focusedPid = null;
523 focusTarget.set(0, 0, 0);
524 focusDistance = null;
525 } else {
526 focusedPid = String(pid);
527 focusTarget.copy(clicked.position);
528 focusDistance = 80 + (clicked.userData.size || 6) * 3;
529 }
530 transitioning = true;
531 controls.autoRotate = true;
532 } else if (!e.shiftKey) {
533 focusedPid = null;
534 focusTarget.set(0, 0, 0);
535 focusDistance = null;
536 transitioning = true;
537 }
538 });
539
540 renderer.domElement.addEventListener('dblclick', () => {
541 focusedPid = null;
542 focusTarget.set(0, 0, 0);
543 focusDistance = null;
544 transitioning = true;
545 camera.position.set(0, 150, 400);
546 });
547
548 // Tour Mode Functions
549 function updateTourUI() {
550 let tourUI = document.getElementById('tour-ui');
551 if (!tourUI) {
552 tourUI = document.createElement('div');
553 tourUI.id = 'tour-ui';
554 document.body.appendChild(tourUI);
555 }
556 // Tour UI positioned above center panel, non-overlapping
557 tourUI.style.cssText = `
558 position: fixed;
559 bottom: 180px;
560 left: 50%;
561 transform: translateX(-50%);
562 background: ${scheme.ui.overlay};
563 padding: 16px 24px;
564 border-radius: 12px;
565 color: ${scheme.foregroundBright};
566 font-family: monospace;
567 font-size: 12px;
568 z-index: 1000;
569 display: none;
570 text-align: center;
571 border: 1px solid ${scheme.foregroundMuted}40;
572 backdrop-filter: blur(8px);
573 -webkit-backdrop-filter: blur(8px);
574 min-width: 280px;
575 `;
576
577 if (tourMode && tourProcessList.length > 0) {
578 const current = tourProcessList[tourIndex];
579 const mesh = meshes.get(current);
580 const name = mesh?.userData?.name || current;
581 const icon = mesh?.userData?.icon || '●';
582 const category = mesh?.userData?.category || '';
583
584 tourUI.style.display = 'block';
585 tourUI.innerHTML = `
586 <div style="margin-bottom:10px;font-size:12px;color:${scheme.accent};text-transform:uppercase;letter-spacing:1px;">🎬 Tour Mode</div>
587 <div style="font-size:24px;margin-bottom:4px;color:${scheme.foregroundBright};">${icon}</div>
588 <div style="font-size:14px;font-weight:bold;color:${scheme.foregroundBright};margin-bottom:2px;">${name}</div>
589 <div style="color:${scheme.foregroundMuted};margin-bottom:14px;font-size:11px;">${category} • ${tourIndex + 1}/${tourProcessList.length}</div>
590 <div style="display:flex;gap:8px;justify-content:center;">
591 <button onclick="ProcessTreeViz.tourPrev()" class="header-btn" style="pointer-events:auto;padding:8px 14px;">← Prev</button>
592 <button onclick="ProcessTreeViz.toggleAutoPlay()" class="header-btn" style="pointer-events:auto;padding:8px 14px;">${tourAutoPlay ? '⏸ Stop' : '▶ Auto'}</button>
593 <button onclick="ProcessTreeViz.tourNext()" class="header-btn" style="pointer-events:auto;padding:8px 14px;">Next →</button>
594 <button onclick="ProcessTreeViz.exitTour()" class="header-btn" style="pointer-events:auto;padding:8px 14px;">✕</button>
595 </div>
596 ${tourAutoPlay ? `<div style="color:${scheme.accentBright};margin-top:10px;font-size:10px;">▶ Auto-playing...</div>` : ''}
597 `;
598 // Hide the tour button when in tour mode
599 const btn = document.getElementById('tour-btn');
600 if (btn) btn.style.display = 'none';
601 } else {
602 tourUI.style.display = 'none';
603 // Show the tour button when not in tour mode
604 const btn = document.getElementById('tour-btn');
605 if (btn) btn.style.display = '';
606 }
607 }
608
609 function buildTourList() {
610 // Build ordered list: kernel first, then by category, then by tree depth
611 const categoryOrder = ['kernel', 'ide', 'editor', 'tui', 'dev', 'db', 'shell', 'ai', 'lsp', 'proxy', 'bridge'];
612 const list = Array.from(meshes.keys());
613
614 list.sort((a, b) => {
615 const meshA = meshes.get(a);
616 const meshB = meshes.get(b);
617 const catA = meshA?.userData?.category || 'zzz';
618 const catB = meshB?.userData?.category || 'zzz';
619 const orderA = categoryOrder.indexOf(catA);
620 const orderB = categoryOrder.indexOf(catB);
621 return (orderA === -1 ? 99 : orderA) - (orderB === -1 ? 99 : orderB);
622 });
623
624 return list;
625 }
626
627 function focusOnProcess(pid) {
628 const mesh = meshes.get(pid);
629 if (!mesh) return;
630
631 focusedPid = pid;
632 focusTarget.copy(mesh.position);
633 focusDistance = 80 + (mesh.userData.size || 6) * 3;
634 transitioning = true;
635 controls.autoRotate = true;
636 }
637
638 function startTour() {
639 if (window.ASTTreeViz?.getTab() === 'sources') {
640 window.ASTTreeViz.startTour();
641 // Ensure local state reflects we are "busy" or just let AST handle it
642 return;
643 }
644
645 tourMode = true;
646 tourProcessList = buildTourList();
647 tourIndex = 0;
648 if (tourProcessList.length > 0) {
649 focusOnProcess(tourProcessList[0]);
650 }
651 updateTourUI();
652 }
653
654 function exitTour() {
655 if (window.ASTTreeViz?.getTab() === 'sources') {
656 window.ASTTreeViz.stopTour();
657 return;
658 }
659
660 tourMode = false;
661 tourAutoPlay = false;
662 if (tourAutoPlayInterval) {
663 clearInterval(tourAutoPlayInterval);
664 tourAutoPlayInterval = null;
665 }
666 focusedPid = null;
667 focusTarget.set(0, 0, 0);
668 focusDistance = null;
669 transitioning = true;
670 updateTourUI();
671 }
672
673 function tourNext() {
674 if (window.ASTTreeViz?.getTab() === 'sources') {
675 window.ASTTreeViz.tourNext();
676 return;
677 }
678
679 if (!tourMode || tourProcessList.length === 0) return;
680 tourIndex = (tourIndex + 1) % tourProcessList.length;
681 focusOnProcess(tourProcessList[tourIndex]);
682 updateTourUI();
683 }
684
685 function tourPrev() {
686 if (window.ASTTreeViz?.getTab() === 'sources') {
687 window.ASTTreeViz.tourPrev();
688 return;
689 }
690
691 if (!tourMode || tourProcessList.length === 0) return;
692 tourIndex = (tourIndex - 1 + tourProcessList.length) % tourProcessList.length;
693 focusOnProcess(tourProcessList[tourIndex]);
694 updateTourUI();
695 }
696
697 function toggleAutoPlay() {
698 if (window.ASTTreeViz?.getTab() === 'sources') {
699 window.ASTTreeViz.toggleTourAutoPlay();
700 return;
701 }
702
703 tourAutoPlay = !tourAutoPlay;
704 if (tourAutoPlay) {
705 tourAutoPlayInterval = setInterval(tourNext, TOUR_SPEED);
706 } else {
707 if (tourAutoPlayInterval) {
708 clearInterval(tourAutoPlayInterval);
709 tourAutoPlayInterval = null;
710 }
711 }
712 updateTourUI();
713 }
714
715 // Keyboard controls
716 document.addEventListener('keydown', (e) => {
717 const isSourceTour = window.ASTTreeViz?.getTab() === 'sources' && window.ASTTreeViz?.isTourMode();
718 const isTourActive = tourMode || isSourceTour;
719
720 // T to start tour
721 if (e.key === 't' || e.key === 'T') {
722 if (!isTourActive) {
723 startTour();
724 } else {
725 exitTour();
726 }
727 return;
728 }
729
730 if (isTourActive) {
731 switch(e.key) {
732 case 'ArrowRight':
733 case 'l':
734 case 'L':
735 tourNext();
736 e.preventDefault();
737 break;
738 case 'ArrowLeft':
739 case 'h':
740 case 'H':
741 tourPrev();
742 e.preventDefault();
743 break;
744 case ' ':
745 toggleAutoPlay();
746 e.preventDefault();
747 break;
748 case 'Escape':
749 case 'q':
750 case 'Q':
751 exitTour();
752 e.preventDefault();
753 break;
754 }
755 }
756 });
757
758 let processTree = { roots: [], byPid: new Map() };
759
760 let kernelMesh = null, kernelGlow = null, kernelCore = null;
761 function createKernelNode() {
762 const group = new THREE.Group();
763
764 const outerGeo = new THREE.SphereGeometry(35, 32, 32);
765 const outerMat = new THREE.MeshBasicMaterial({
766 color: scheme.three.kernelOuter, transparent: true, opacity: 0.15, wireframe: true
767 });
768 group.add(new THREE.Mesh(outerGeo, outerMat));
769
770 const ringGeo = new THREE.TorusGeometry(25, 1.5, 8, 48);
771 const ringMat = new THREE.MeshBasicMaterial({
772 color: scheme.three.kernelRing, transparent: true, opacity: 0.4
773 });
774 const ring = new THREE.Mesh(ringGeo, ringMat);
775 ring.rotation.x = Math.PI / 2;
776 group.add(ring);
777 kernelGlow = ring;
778
779 const coreGeo = new THREE.SphereGeometry(12, 24, 24);
780 const coreMat = new THREE.MeshBasicMaterial({
781 color: scheme.three.kernelCore, transparent: true, opacity: 0.7
782 });
783 const core = new THREE.Mesh(coreGeo, coreMat);
784 group.add(core);
785 kernelCore = core;
786
787 group.userData = {
788 pid: 'kernel', name: 'Fedora Linux', icon: '🐧', category: 'kernel',
789 cpu: 0, rss: 0, size: 35, targetPos: new THREE.Vector3(0, 0, 0), pulsePhase: 0
790 };
791 return group;
792 }
793
794 kernelMesh = createKernelNode();
795 scene.add(kernelMesh);
796 meshes.set('kernel', kernelMesh);
797
798 function createNodeMesh(node) {
799 const cpu = node.cpu || 0;
800 const memMB = (node.rss || 10000) / 1024;
801 const baseColor = colors[node.category] || 0x666666;
802 const size = Math.max(4, Math.min(12, 3 + memMB * 0.05 + cpu * 0.1));
803
804 const geo = new THREE.SphereGeometry(size, 12, 12);
805 const mat = new THREE.MeshBasicMaterial({
806 color: baseColor, transparent: true, opacity: 0.7 + cpu * 0.003
807 });
808
809 const mesh = new THREE.Mesh(geo, mat);
810 mesh.userData = {
811 ...node, size, baseColor, targetPos: new THREE.Vector3(),
812 pulsePhase: Math.random() * Math.PI * 2
813 };
814 return mesh;
815 }
816
817 function createConnectionLine(color) {
818 const geo = new THREE.CylinderGeometry(1.5, 1.5, 1, 8);
819 const mat = new THREE.MeshBasicMaterial({
820 color: color || scheme.three.connectionLine, transparent: true, opacity: 0.5
821 });
822 return new THREE.Mesh(geo, mat);
823 }
824
825 function updateConnectionMesh(conn, childPos, parentPos) {
826 const mesh = conn.line;
827 const mid = new THREE.Vector3().addVectors(childPos, parentPos).multiplyScalar(0.5);
828 mesh.position.copy(mid);
829 const dir = new THREE.Vector3().subVectors(parentPos, childPos);
830 const length = dir.length();
831 mesh.scale.set(1, length, 1);
832 mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
833 }
834
835 function layoutTree(processes) {
836 const byPid = new Map();
837 const children = new Map();
838
839 processes.forEach(p => {
840 byPid.set(String(p.pid), p);
841 children.set(String(p.pid), []);
842 });
843
844 const roots = [];
845 processes.forEach(p => {
846 const parentPid = String(p.parentInteresting || 0);
847 if (parentPid && byPid.has(parentPid)) {
848 children.get(parentPid).push(p);
849 } else {
850 roots.push(p);
851 }
852 });
853
854 const categoryOrder = ['ide', 'editor', 'tui', 'dev', 'db', 'shell', 'ai', 'lsp', 'proxy', 'bridge'];
855 roots.sort((a, b) => {
856 const ai = categoryOrder.indexOf(a.category);
857 const bi = categoryOrder.indexOf(b.category);
858 return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
859 });
860
861 const levelHeight = 50, baseRadius = 100;
862
863 function countDescendants(pid) {
864 const nodeChildren = children.get(pid) || [];
865 let count = nodeChildren.length;
866 nodeChildren.forEach(c => count += countDescendants(String(c.pid)));
867 return count;
868 }
869
870 function positionNode(node, depth, angle, radius, parentX, parentZ) {
871 const pid = String(node.pid);
872 const nodeChildren = children.get(pid) || [];
873 const childCount = nodeChildren.length;
874
875 const x = parentX + Math.cos(angle) * radius;
876 const z = parentZ + Math.sin(angle) * radius;
877
878 node.targetX = x;
879 node.targetY = -depth * levelHeight;
880 node.targetZ = z;
881
882 if (childCount > 0) {
883 const arcSpread = Math.min(Math.PI * 0.9, Math.PI * 0.3 * childCount);
884 const startAngle = angle - arcSpread / 2;
885 const childRadius = 35 + childCount * 10;
886
887 nodeChildren.forEach((child, i) => {
888 const childAngle = childCount === 1 ? angle : startAngle + (arcSpread / (childCount - 1)) * i;
889 positionNode(child, depth + 1, childAngle, childRadius, x, z);
890 });
891 }
892 }
893
894 const totalRoots = roots.length;
895 if (totalRoots > 0) {
896 const weights = roots.map(r => 1 + countDescendants(String(r.pid)) * 0.5);
897 const totalWeight = weights.reduce((a, b) => a + b, 0);
898
899 let currentAngle = -Math.PI / 2;
900 roots.forEach((root, i) => {
901 const angleSpan = (weights[i] / totalWeight) * Math.PI * 2;
902 const angle = currentAngle + angleSpan / 2;
903 currentAngle += angleSpan;
904 positionNode(root, 0, angle, baseRadius, 0, 0);
905 });
906 }
907
908 return { roots, byPid, children };
909 }
910
911 function updateLabels() {
912 const container = document.getElementById('labels');
913 container.innerHTML = '';
914 scene.updateMatrixWorld();
915
916 meshes.forEach((mesh, pid) => {
917 const pos = new THREE.Vector3();
918 mesh.getWorldPosition(pos);
919 const labelPos = pos.clone();
920 labelPos.y += (mesh.userData.size || 8) + 5;
921 labelPos.project(camera);
922
923 const x = (labelPos.x * 0.5 + 0.5) * width;
924 const y = (-labelPos.y * 0.5 + 0.5) * height;
925
926 if (labelPos.z < 1 && x > -100 && x < width + 100 && y > -100 && y < height + 100) {
927 const d = mesh.userData;
928 const color = '#' + (colors[d.category] || 0x666666).toString(16).padStart(6, '0');
929 const distToCamera = camera.position.distanceTo(pos);
930 // Larger base scale, less reduction with distance
931 const proximityScale = Math.max(0.7, Math.min(3, 200 / distToCamera));
932 // Higher minimum opacity - always readable
933 const opacity = focusedPid
934 ? (pid === focusedPid ? 1 : (d.parentInteresting === parseInt(focusedPid) ? 0.95 : 0.7))
935 : Math.max(0.85, Math.min(1, 400 / distToCamera));
936
937 const cpuPct = Math.min(100, d.cpu || 0);
938 const memMB = ((d.rss || 0) / 1024).toFixed(0);
939
940 // Extract short command for display (first 40 chars of cmdShort or cmd)
941 const cmdDisplay = d.cmdShort || d.cmd || '';
942 const cmdShort = cmdDisplay.length > 50 ? cmdDisplay.slice(0, 47) + '...' : cmdDisplay;
943
944 // Calculate rotation based on connection to parent (make label parallel to line)
945 let rotation = 0;
946 const parentPid = String(d.parentInteresting || 0);
947 const parentMesh = meshes.has(parentPid) ? meshes.get(parentPid) : meshes.get('kernel');
948 if (parentMesh && pid !== 'kernel') {
949 const parentPos = new THREE.Vector3();
950 parentMesh.getWorldPosition(parentPos);
951 // Project both positions to 2D screen space
952 const childScreen = pos.clone().project(camera);
953 const parentScreen = parentPos.clone().project(camera);
954 // Calculate angle in screen space
955 const dx = (parentScreen.x - childScreen.x);
956 const dy = (parentScreen.y - childScreen.y);
957 rotation = Math.atan2(-dy, dx) * (180 / Math.PI);
958 // Clamp rotation to reasonable range (-45 to 45 degrees)
959 rotation = Math.max(-45, Math.min(45, rotation));
960 }
961
962 const label = document.createElement('div');
963 label.className = 'proc-label';
964 label.style.left = x + 'px';
965 label.style.top = y + 'px';
966 label.style.opacity = opacity;
967 label.style.transform = 'translate(-50%, -100%) scale(' + proximityScale + ') rotate(' + rotation + 'deg)';
968 // Show name, then command on second line, then stats (no background)
969 label.innerHTML = '<div class="icon">' + (d.icon || '●') + '</div>' +
970 '<div class="name" style="color:' + color + '">' + (d.name || pid) + '</div>' +
971 (cmdShort ? '<div class="cmd" style="color:' + scheme.foregroundMuted + ';font-size:8px;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + cmdShort + '</div>' : '') +
972 '<div class="info" style="color:' + scheme.foregroundMuted + ';">' + memMB + 'MB · ' + cpuPct.toFixed(0) + '%</div>';
973 container.appendChild(label);
974 }
975 });
976
977 // Add labels for graveyard (dead) processes
978 graveyard.forEach((grave) => {
979 const mesh = grave.mesh;
980 if (!mesh) return;
981
982 const pos = new THREE.Vector3();
983 mesh.getWorldPosition(pos);
984 const labelPos = pos.clone();
985 labelPos.y += 8;
986 labelPos.project(camera);
987
988 const x = (labelPos.x * 0.5 + 0.5) * width;
989 const y = (-labelPos.y * 0.5 + 0.5) * height;
990
991 if (labelPos.z < 1 && x > -100 && x < width + 100 && y > -100 && y < height + 100) {
992 const distToCamera = camera.position.distanceTo(pos);
993 const proximityScale = Math.max(0.5, Math.min(2, 150 / distToCamera));
994 const age = (Date.now() - grave.deathTime) / 1000;
995 const opacity = Math.max(0.3, 0.7 - age * 0.01);
996
997 const cmdShort = grave.cmd ? (grave.cmd.length > 30 ? grave.cmd.slice(0, 27) + '...' : grave.cmd) : '';
998 const timeAgo = age < 60 ? Math.floor(age) + 's ago' : Math.floor(age / 60) + 'm ago';
999
1000 const label = document.createElement('div');
1001 label.className = 'proc-label graveyard';
1002 label.style.left = x + 'px';
1003 label.style.top = y + 'px';
1004 label.style.opacity = opacity;
1005 label.style.transform = 'translate(-50%, -100%) scale(' + proximityScale + ')';
1006 label.innerHTML = '<div class="icon">💀</div>' +
1007 '<div class="name" style="color:' + scheme.foregroundMuted + ';text-decoration:line-through;">' + (grave.name || grave.pid) + '</div>' +
1008 (cmdShort ? '<div class="cmd" style="color:' + scheme.foregroundMuted + ';font-size:7px;opacity:0.7;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + cmdShort + '</div>' : '') +
1009 '<div class="info" style="color:' + scheme.foregroundMuted + ';opacity:0.6;">' + timeAgo + '</div>';
1010 container.appendChild(label);
1011 }
1012 });
1013 }
1014
1015 function updateViz(processData) {
1016 if (!processData?.interesting) return;
1017
1018 const processes = processData.interesting;
1019 document.getElementById('process-count').textContent = processes.length;
1020
1021 processTree = layoutTree(processes);
1022 const currentPids = new Set(processes.map(p => String(p.pid)));
1023
1024 processes.forEach(p => {
1025 const pid = String(p.pid);
1026
1027 if (!meshes.has(pid)) {
1028 const mesh = createNodeMesh(p);
1029 mesh.position.set(p.targetX || 0, p.targetY || 0, p.targetZ || 0);
1030 mesh.userData.targetPos.set(p.targetX || 0, p.targetY || 0, p.targetZ || 0);
1031 scene.add(mesh);
1032 meshes.set(pid, mesh);
1033
1034 // Respect current visibility
1035 const isSourcesTab = window.ASTTreeViz?.getTab() === 'sources';
1036 mesh.visible = !isSourcesTab;
1037 } else {
1038 const mesh = meshes.get(pid);
1039 const d = mesh.userData;
1040 d.cpu = p.cpu; d.mem = p.mem; d.rss = p.rss; d.name = p.name;
1041 d.targetPos.set(p.targetX || d.targetPos.x, p.targetY || d.targetPos.y, p.targetZ || d.targetPos.z);
1042
1043 const memMB = (p.rss || 10000) / 1024;
1044 d.size = Math.max(4, Math.min(12, 3 + memMB * 0.05 + p.cpu * 0.1));
1045 mesh.scale.setScalar(d.size / 6);
1046
1047 const baseColor = colors[p.category] || 0x666666;
1048 const brighten = Math.min(1.8, 1 + p.cpu * 0.02);
1049 const r = ((baseColor >> 16) & 255) * brighten;
1050 const g = ((baseColor >> 8) & 255) * brighten;
1051 const b = (baseColor & 255) * brighten;
1052 mesh.material.color.setRGB(Math.min(255, r) / 255, Math.min(255, g) / 255, Math.min(255, b) / 255);
1053 mesh.material.opacity = 0.7 + p.cpu * 0.003;
1054 }
1055
1056 const parentPid = String(p.parentInteresting || 0);
1057 const childColor = colors[p.category] || 0x666666;
1058 if (parentPid && meshes.has(parentPid)) {
1059 const connKey = pid + '->' + parentPid;
1060 if (!connections.has(connKey)) {
1061 const line = createConnectionLine(childColor);
1062 scene.add(line);
1063 connections.set(connKey, { line, childPid: pid, parentPid, childColor });
1064
1065 // Respect current visibility
1066 const isSourcesTab = window.ASTTreeViz?.getTab() === 'sources';
1067 line.visible = !isSourcesTab;
1068 }
1069 } else {
1070 const connKey = pid + '->kernel';
1071 if (!connections.has(connKey)) {
1072 const line = createConnectionLine(childColor);
1073 scene.add(line);
1074 connections.set(connKey, { line, childPid: pid, parentPid: 'kernel', childColor });
1075
1076 // Respect current visibility
1077 const isSourcesTab = window.ASTTreeViz?.getTab() === 'sources';
1078 line.visible = !isSourcesTab;
1079 }
1080 }
1081 });
1082
1083 meshes.forEach((mesh, pid) => {
1084 if (pid === 'kernel') return;
1085 if (!currentPids.has(pid) && !mesh.userData.isDead) {
1086 mesh.userData.isDead = true;
1087 mesh.userData.deathTime = Date.now();
1088
1089 const graveyardIndex = graveyard.length;
1090 const col = graveyardIndex % 10;
1091 const row = Math.floor(graveyardIndex / 10);
1092 mesh.userData.targetPos.set((col - 4.5) * 25, GRAVEYARD_Y - row * 20, 0);
1093
1094 mesh.material.opacity = 0.25;
1095 mesh.material.color.setHex(scheme.three.deadProcess);
1096
1097 // Store full process info for graveyard labels
1098 const d = mesh.userData;
1099 const graveItem = {
1100 pid,
1101 mesh,
1102 name: d.name,
1103 icon: d.icon || '💀',
1104 cmd: d.cmdShort || d.cmd || '',
1105 category: d.category,
1106 deathTime: Date.now()
1107 };
1108 graveyard.push(graveItem);
1109 meshes.delete(pid);
1110
1111 // Respect current visibility
1112 const isSourcesTab = window.ASTTreeViz?.getTab() === 'sources';
1113 mesh.visible = !isSourcesTab;
1114
1115 while (graveyard.length > MAX_GRAVEYARD) {
1116 const oldest = graveyard.shift();
1117 scene.remove(oldest.mesh);
1118 if (oldest.mesh.geometry) oldest.mesh.geometry.dispose();
1119 if (oldest.mesh.material) oldest.mesh.material.dispose();
1120 }
1121 }
1122 });
1123
1124 const graveyardPids = new Set(graveyard.map(g => g.pid));
1125 connections.forEach((conn, key) => {
1126 const childExists = meshes.has(conn.childPid) || graveyardPids.has(conn.childPid);
1127 const parentExists = meshes.has(conn.parentPid) || graveyardPids.has(conn.parentPid);
1128 if (!childExists || !parentExists) {
1129 scene.remove(conn.line);
1130 conn.line.geometry.dispose();
1131 conn.line.material.dispose();
1132 connections.delete(key);
1133 }
1134 });
1135
1136 // Refresh tour list if in tour mode (processes may have changed)
1137 if (tourMode) {
1138 const oldPid = tourProcessList[tourIndex];
1139 tourProcessList = buildTourList();
1140 // Try to stay on the same process if it still exists
1141 const newIndex = tourProcessList.indexOf(oldPid);
1142 if (newIndex !== -1) {
1143 tourIndex = newIndex;
1144 } else if (tourIndex >= tourProcessList.length) {
1145 tourIndex = Math.max(0, tourProcessList.length - 1);
1146 }
1147 updateTourUI();
1148 }
1149 }
1150
1151 let time = 0;
1152 function animate() {
1153 requestAnimationFrame(animate);
1154 time += 0.016;
1155
1156 if (focusedPid && meshes.has(focusedPid)) {
1157 focusTarget.lerp(meshes.get(focusedPid).position, 0.08);
1158 }
1159
1160 controls.target.lerp(focusTarget, transitioning ? 0.06 : 0.02);
1161
1162 if (focusDistance !== null) {
1163 const currentDist = camera.position.distanceTo(controls.target);
1164 if (Math.abs(currentDist - focusDistance) > 5) {
1165 const dir = camera.position.clone().sub(controls.target).normalize();
1166 const targetPos = controls.target.clone().add(dir.multiplyScalar(focusDistance));
1167 camera.position.lerp(targetPos, 0.04);
1168 } else {
1169 transitioning = false;
1170 }
1171 } else {
1172 transitioning = false;
1173 }
1174
1175 controls.update();
1176
1177 if (kernelGlow) {
1178 kernelGlow.rotation.z = time * 0.3;
1179 kernelGlow.rotation.x = Math.PI / 2 + Math.sin(time * 0.5) * 0.1;
1180 }
1181 if (kernelCore) {
1182 const pulse = 1 + Math.sin(time * 0.8) * 0.1;
1183 kernelCore.scale.setScalar(pulse);
1184 }
1185 if (kernelMesh) {
1186 kernelMesh.rotation.y = time * 0.1;
1187 }
1188
1189 // Graveyard animation with null checks
1190 graveyard.forEach((grave, i) => {
1191 const mesh = grave.mesh;
1192 if (mesh && mesh.userData && mesh.material) {
1193 const d = mesh.userData;
1194 mesh.position.x += (d.targetPos.x - mesh.position.x) * 0.02;
1195 mesh.position.y += (d.targetPos.y - mesh.position.y) * 0.015;
1196 mesh.position.z += (d.targetPos.z - mesh.position.z) * 0.02;
1197 mesh.position.x += Math.sin(time * 0.3 + i) * 0.05;
1198 const age = (Date.now() - grave.deathTime) / 1000;
1199 mesh.material.opacity = Math.max(0.1, 0.3 - age * 0.005);
1200 }
1201 });
1202
1203 // Active meshes animation with null checks
1204 meshes.forEach((mesh, pid) => {
1205 if (!mesh || !mesh.userData || !mesh.material) return;
1206 const d = mesh.userData;
1207 const cpu = d.cpu || 0;
1208 const isFocused = focusedPid === pid;
1209 const isRelated = focusedPid && (d.parentInteresting === parseInt(focusedPid) || String(d.parentInteresting) === focusedPid);
1210
1211 mesh.position.x += (d.targetPos.x - mesh.position.x) * 0.03;
1212 mesh.position.y += (d.targetPos.y - mesh.position.y) * 0.03;
1213 mesh.position.z += (d.targetPos.z - mesh.position.z) * 0.03;
1214
1215 const float = Math.sin(time * 0.5 + d.pulsePhase) * 2;
1216 mesh.position.y += float * 0.02;
1217
1218 const pulseAmp = isFocused ? 0.2 : (0.1 + cpu * 0.005);
1219 const pulse = 1 + Math.sin(time * (1 + cpu * 0.05) + d.pulsePhase) * pulseAmp;
1220 const sizeMultiplier = isFocused ? 1.5 : (isRelated ? 1.2 : 1);
1221 mesh.scale.setScalar((d.size / 6) * pulse * sizeMultiplier);
1222
1223 if (focusedPid) {
1224 mesh.material.opacity = isFocused ? 1 : (isRelated ? 0.8 : 0.3);
1225 } else {
1226 mesh.material.opacity = 0.7 + cpu * 0.003;
1227 }
1228 });
1229
1230 connections.forEach(conn => {
1231 const childMesh = meshes.get(conn.childPid);
1232 const parentMesh = meshes.get(conn.parentPid);
1233 if (childMesh && parentMesh) {
1234 updateConnectionMesh(conn, childMesh.position, parentMesh.position);
1235 const involvesFocus = focusedPid && (conn.childPid === focusedPid || conn.parentPid === focusedPid);
1236 conn.line.material.opacity = focusedPid ? (involvesFocus ? 0.9 : 0.15) : 0.6;
1237 // Use child's category color for the line (color-coded connections)
1238 const childCategory = childMesh.userData?.category;
1239 const lineColor = involvesFocus ? scheme.three.connectionActive : (colors[childCategory] || conn.childColor || scheme.three.connectionLine);
1240 conn.line.material.color.setHex(lineColor);
1241 const thickness = involvesFocus ? 2.5 : 1.5;
1242 conn.line.scale.x = thickness / 1.5;
1243 conn.line.scale.z = thickness / 1.5;
1244 }
1245 });
1246
1247 // 🌳 AST Tree Animation (if loaded)
1248 if (window.ASTTreeViz?.animateAST) {
1249 window.ASTTreeViz.animateAST();
1250 }
1251
1252 renderer.render(scene, camera);
1253 updateLabels();
1254 }
1255
1256 // Connection state tracking
1257 let connectionState = 'disconnected'; // disconnected, connecting, connected
1258 let reconnectAttempts = 0;
1259 let lastConnectTime = 0;
1260
1261 // Connection log messages for the corner indicator
1262 const connectionLog = [];
1263 const MAX_LOG_LINES = 6;
1264
1265 function addConnectionLog(msg) {
1266 const now = new Date();
1267 const ts = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
1268 connectionLog.push({ ts, msg });
1269 if (connectionLog.length > MAX_LOG_LINES) connectionLog.shift();
1270 }
1271
1272 function updateConnectionUI() {
1273 const dot = document.getElementById('status-dot');
1274 let indicator = document.getElementById('connection-indicator');
1275
1276 if (!indicator) {
1277 indicator = document.createElement('div');
1278 indicator.id = 'connection-indicator';
1279 indicator.style.cssText = `
1280 position: fixed; top: 50px; left: 12px;
1281 max-width: 280px;
1282 padding: 8px 10px;
1283 background: ${scheme.ui.shadow};
1284 border-radius: 6px;
1285 border: 1px solid ${scheme.foregroundMuted}30;
1286 backdrop-filter: blur(6px);
1287 -webkit-backdrop-filter: blur(6px);
1288 z-index: 500; pointer-events: none;
1289 transition: opacity 0.5s ease;
1290 font-family: monospace;
1291 font-size: 10px;
1292 line-height: 1.5;
1293 `;
1294 document.body.appendChild(indicator);
1295 }
1296
1297 if (connectionState === 'connected') {
1298 dot?.classList.add('online');
1299 addConnectionLog('connected ✓');
1300 // Show briefly then fade out
1301 indicator.style.opacity = '1';
1302 renderConnectionIndicator(indicator);
1303 setTimeout(() => { indicator.style.opacity = '0'; }, 2000);
1304 setTimeout(() => { if (connectionState === 'connected') indicator.style.display = 'none'; }, 2500);
1305 } else {
1306 dot?.classList.remove('online');
1307 indicator.style.display = 'block';
1308 indicator.style.opacity = '1';
1309
1310 if (connectionState === 'connecting') {
1311 addConnectionLog(`connecting... (attempt ${reconnectAttempts})`);
1312 } else {
1313 addConnectionLog(`waiting to reconnect (attempt ${reconnectAttempts})`);
1314 }
1315
1316 renderConnectionIndicator(indicator);
1317 }
1318 }
1319
1320 function renderConnectionIndicator(indicator) {
1321 const stateColor = connectionState === 'connected' ? (scheme.statusOnline || '#0f0')
1322 : connectionState === 'connecting' ? (scheme.accent || '#ff69b4')
1323 : (scheme.foregroundMuted || '#555');
1324 const stateIcon = connectionState === 'connected' ? '●'
1325 : connectionState === 'connecting' ? '◌'
1326 : '○';
1327
1328 const logHtml = connectionLog.map(l =>
1329 `<div style="color: ${scheme.foregroundMuted}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"><span style="color: ${scheme.foregroundMuted}80;">${l.ts}</span> ${l.msg}</div>`
1330 ).join('');
1331
1332 indicator.innerHTML = `
1333 <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
1334 <span style="color: ${stateColor}; font-size: 8px;">${stateIcon}</span>
1335 <span style="color: ${scheme.foreground || '#fff'}; font-size: 10px; font-weight: bold;">process server</span>
1336 </div>
1337 ${logHtml}
1338 ${reconnectAttempts > 5 ? `<button onclick="location.reload()" style="margin-top: 6px; padding: 3px 8px; background: ${scheme.accent}; border: none; border-radius: 3px; color: ${scheme.foregroundBright}; cursor: pointer; pointer-events: auto; font-size: 9px; font-family: monospace;">↻ refresh</button>` : ''}
1339 `;
1340 }
1341
1342 function connectWS() {
1343 connectionState = 'connecting';
1344 reconnectAttempts++;
1345 updateConnectionUI();
1346
1347 try {
1348 ws = new WebSocket('ws://127.0.0.1:7890/ws');
1349
1350 ws.onopen = () => {
1351 connectionState = 'connected';
1352 reconnectAttempts = 0;
1353 lastConnectTime = Date.now();
1354 updateConnectionUI();
1355 console.log('🟢 Connected to process server');
1356 };
1357
1358 ws.onclose = () => {
1359 connectionState = 'disconnected';
1360 updateConnectionUI();
1361 // Exponential backoff: 1s, 2s, 4s, 8s, max 10s
1362 const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempts - 1, 3)), 10000);
1363 console.log(`🔴 Disconnected, reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
1364 setTimeout(connectWS, delay);
1365 };
1366
1367 ws.onerror = (err) => {
1368 console.log('🔴 WebSocket error:', err);
1369 ws.close();
1370 };
1371
1372 ws.onmessage = (e) => {
1373 try {
1374 const data = JSON.parse(e.data);
1375 if (data.system) {
1376 document.getElementById('uptime').textContent = data.system.uptime.formatted;
1377 document.getElementById('cpus').textContent = data.system.cpus;
1378 const m = data.system.memory;
1379 document.getElementById('mem-text').textContent = m.used + ' / ' + m.total;
1380
1381 // Update stats graph with system data
1382 updateStatsGraph(data.system);
1383 }
1384 updateViz(data.processes);
1385 } catch {}
1386 };
1387 } catch (err) {
1388 console.log('🔴 WebSocket creation error:', err);
1389 connectionState = 'disconnected';
1390 updateConnectionUI();
1391 setTimeout(connectWS, 2000);
1392 }
1393 }
1394
1395 // 📊 Stats Graph (CPU/Memory history)
1396 const GRAPH_POINTS = 60; // 60 data points
1397 const cpuHistory = new Array(GRAPH_POINTS).fill(0);
1398 const memHistory = new Array(GRAPH_POINTS).fill(0);
1399 let statsCanvas = null;
1400 let statsCtx = null;
1401
1402 function initStatsGraph() {
1403 statsCanvas = document.getElementById('stats-graph-canvas');
1404 if (statsCanvas) {
1405 statsCtx = statsCanvas.getContext('2d');
1406 // Set actual pixel dimensions for crisp rendering
1407 const rect = statsCanvas.getBoundingClientRect();
1408 statsCanvas.width = rect.width * window.devicePixelRatio;
1409 statsCanvas.height = rect.height * window.devicePixelRatio;
1410 statsCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
1411 }
1412 }
1413
1414 function updateStatsGraph(system) {
1415 if (!statsCtx) initStatsGraph();
1416 if (!statsCtx) return;
1417
1418 // Parse memory usage
1419 const m = system.memory;
1420 let memPct = 0;
1421 if (m && m.used && m.total) {
1422 const usedNum = parseFloat(m.used.replace(/[^\d.]/g, ''));
1423 const totalNum = parseFloat(m.total.replace(/[^\d.]/g, ''));
1424 if (totalNum > 0) {
1425 memPct = (usedNum / totalNum) * 100;
1426 }
1427 }
1428
1429 // Calculate total CPU usage from all processes
1430 let totalCpu = 0;
1431 meshes.forEach((mesh, pid) => {
1432 if (pid !== 'kernel' && mesh.userData.cpu) {
1433 totalCpu += mesh.userData.cpu;
1434 }
1435 });
1436 // Normalize to percentage (divide by number of CPUs)
1437 const numCpus = parseInt(system.cpus) || 1;
1438 const cpuPct = Math.min(100, totalCpu / numCpus);
1439
1440 // Shift history and add new values
1441 cpuHistory.shift();
1442 cpuHistory.push(cpuPct);
1443 memHistory.shift();
1444 memHistory.push(memPct);
1445
1446 // Update text labels
1447 const cpuEl = document.getElementById('cpu-pct');
1448 const memEl = document.getElementById('mem-pct');
1449 if (cpuEl) cpuEl.textContent = cpuPct.toFixed(1);
1450 if (memEl) memEl.textContent = memPct.toFixed(1);
1451
1452 // Draw graph
1453 drawStatsGraph();
1454 }
1455
1456 function drawStatsGraph() {
1457 if (!statsCtx || !statsCanvas) return;
1458
1459 const rect = statsCanvas.getBoundingClientRect();
1460 const w = rect.width;
1461 const h = rect.height;
1462
1463 // Clear canvas
1464 statsCtx.clearRect(0, 0, w, h);
1465
1466 // Colors based on theme
1467 const cpuColor = currentTheme === 'light' ? '#006400' : '#50fa7b';
1468 const memColor = currentTheme === 'light' ? '#c71585' : '#ff79c6';
1469 const gridColor = currentTheme === 'light' ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)';
1470
1471 // Draw grid lines
1472 statsCtx.strokeStyle = gridColor;
1473 statsCtx.lineWidth = 0.5;
1474 for (let i = 0; i <= 4; i++) {
1475 const y = (h / 4) * i;
1476 statsCtx.beginPath();
1477 statsCtx.moveTo(0, y);
1478 statsCtx.lineTo(w, y);
1479 statsCtx.stroke();
1480 }
1481
1482 // Draw CPU line
1483 statsCtx.strokeStyle = cpuColor;
1484 statsCtx.lineWidth = 1.5;
1485 statsCtx.beginPath();
1486 for (let i = 0; i < GRAPH_POINTS; i++) {
1487 const x = (w / (GRAPH_POINTS - 1)) * i;
1488 const y = h - (cpuHistory[i] / 100) * h;
1489 if (i === 0) statsCtx.moveTo(x, y);
1490 else statsCtx.lineTo(x, y);
1491 }
1492 statsCtx.stroke();
1493
1494 // Fill CPU area (semi-transparent)
1495 statsCtx.fillStyle = cpuColor.replace(')', ',0.15)').replace('rgb', 'rgba').replace('#', '');
1496 if (cpuColor.startsWith('#')) {
1497 const r = parseInt(cpuColor.slice(1, 3), 16);
1498 const g = parseInt(cpuColor.slice(3, 5), 16);
1499 const b = parseInt(cpuColor.slice(5, 7), 16);
1500 statsCtx.fillStyle = `rgba(${r},${g},${b},0.15)`;
1501 }
1502 statsCtx.beginPath();
1503 statsCtx.moveTo(0, h);
1504 for (let i = 0; i < GRAPH_POINTS; i++) {
1505 const x = (w / (GRAPH_POINTS - 1)) * i;
1506 const y = h - (cpuHistory[i] / 100) * h;
1507 statsCtx.lineTo(x, y);
1508 }
1509 statsCtx.lineTo(w, h);
1510 statsCtx.closePath();
1511 statsCtx.fill();
1512
1513 // Draw Memory line
1514 statsCtx.strokeStyle = memColor;
1515 statsCtx.lineWidth = 1.5;
1516 statsCtx.beginPath();
1517 for (let i = 0; i < GRAPH_POINTS; i++) {
1518 const x = (w / (GRAPH_POINTS - 1)) * i;
1519 const y = h - (memHistory[i] / 100) * h;
1520 if (i === 0) statsCtx.moveTo(x, y);
1521 else statsCtx.lineTo(x, y);
1522 }
1523 statsCtx.stroke();
1524
1525 // Fill Memory area (semi-transparent)
1526 if (memColor.startsWith('#')) {
1527 const r = parseInt(memColor.slice(1, 3), 16);
1528 const g = parseInt(memColor.slice(3, 5), 16);
1529 const b = parseInt(memColor.slice(5, 7), 16);
1530 statsCtx.fillStyle = `rgba(${r},${g},${b},0.15)`;
1531 }
1532 statsCtx.beginPath();
1533 statsCtx.moveTo(0, h);
1534 for (let i = 0; i < GRAPH_POINTS; i++) {
1535 const x = (w / (GRAPH_POINTS - 1)) * i;
1536 const y = h - (memHistory[i] / 100) * h;
1537 statsCtx.lineTo(x, y);
1538 }
1539 statsCtx.lineTo(w, h);
1540 statsCtx.closePath();
1541 statsCtx.fill();
1542 }
1543
1544 window.addEventListener('resize', () => {
1545 width = window.innerWidth;
1546 height = window.innerHeight;
1547 camera.aspect = width / height;
1548 camera.updateProjectionMatrix();
1549 renderer.setSize(width, height);
1550
1551 // Reinitialize stats graph canvas on resize
1552 statsCanvas = null;
1553 statsCtx = null;
1554 initStatsGraph();
1555 });
1556
1557 // 🎨 Theme switching function
1558 function setTheme(themeName) {
1559 if (themeName !== 'light' && themeName !== 'dark') return;
1560 currentTheme = themeName;
1561 scheme = colorSchemes[currentTheme];
1562 colors = scheme.categories;
1563
1564 // Update scene background
1565 scene.background.setHex(scheme.three.sceneBackground);
1566 renderer.setClearColor(scheme.three.sceneBackground);
1567
1568 // Update body styling
1569 document.body.dataset.theme = themeName;
1570 document.body.style.background = scheme.background;
1571 document.body.style.color = scheme.foreground;
1572
1573 // Update kernel mesh colors
1574 if (kernelMesh) {
1575 kernelMesh.children[0].material.color.setHex(scheme.three.kernelOuter);
1576 if (kernelGlow) kernelGlow.material.color.setHex(scheme.three.kernelRing);
1577 if (kernelCore) kernelCore.material.color.setHex(scheme.three.kernelCore);
1578 }
1579
1580 // Update all process node colors
1581 meshes.forEach((mesh, pid) => {
1582 if (pid === 'kernel') return;
1583 const category = mesh.userData.category;
1584 const newColor = colors[category] || 0x666666;
1585 mesh.material.color.setHex(newColor);
1586 mesh.userData.baseColor = newColor;
1587 });
1588
1589 // Update connections
1590 connections.forEach(conn => {
1591 conn.line.material.color.setHex(scheme.three.connectionLine);
1592 });
1593
1594 // Update graveyard
1595 graveyard.forEach(grave => {
1596 if (grave.mesh && grave.mesh.material) {
1597 grave.mesh.material.color.setHex(scheme.three.deadProcess);
1598 }
1599 });
1600
1601 // Update CSS styles
1602 updateThemeStyles();
1603 }
1604
1605 function toggleTheme() {
1606 setTheme(currentTheme === 'dark' ? 'light' : 'dark');
1607 return currentTheme;
1608 }
1609
1610 function updateThemeStyles() {
1611 // Update dynamic CSS based on theme
1612 let styleEl = document.getElementById('theme-dynamic-styles');
1613 if (!styleEl) {
1614 styleEl = document.createElement('style');
1615 styleEl.id = 'theme-dynamic-styles';
1616 document.head.appendChild(styleEl);
1617 }
1618 styleEl.textContent = `
1619 /* Header styles */
1620 .title .dot { color: ${scheme.accentBright}; }
1621 .status-dot { background: ${scheme.accent}; }
1622 .status-dot.online { background: ${scheme.statusOnline}; }
1623 .header-center { color: ${scheme.foregroundMuted}; }
1624 .header-center .val { color: ${scheme.foregroundBright}; }
1625 .header-btn {
1626 background: ${currentTheme === 'light' ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.08)'};
1627 border-color: ${currentTheme === 'light' ? 'rgba(0,0,0,0.15)' : 'rgba(255,255,255,0.15)'};
1628 color: ${scheme.foregroundBright};
1629 }
1630 .header-btn:hover { border-color: ${scheme.accentBright}; }
1631
1632 /* Center panel styles */
1633 .center-panel {
1634 background: ${currentTheme === 'light' ? 'rgba(252,247,197,0.8)' : 'rgba(24,19,24,0.7)'};
1635 border-color: ${currentTheme === 'light' ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.08)'};
1636 }
1637 .process-counter .count { color: ${scheme.foregroundBright}; }
1638 .process-counter .label { color: ${scheme.foregroundMuted}; }
1639
1640 /* Status bar styles */
1641 .status-bar { color: ${scheme.foregroundMuted}; }
1642 .dev-badge {
1643 background: ${scheme.accentBright};
1644 color: ${currentTheme === 'light' ? '#fff' : '#000'};
1645 }
1646
1647 /* Label styles - no background, stronger text shadow */
1648 .proc-label {
1649 text-shadow: 0 0 6px ${scheme.background}, 0 0 10px ${scheme.background}, 0 0 14px ${scheme.background};
1650 background: transparent;
1651 }
1652 .proc-label .info { color: ${scheme.foregroundMuted}; }
1653
1654 /* Tour UI styles */
1655 #tour-ui { background: ${scheme.ui.overlay}; border-color: ${scheme.foregroundMuted}; }
1656 `;
1657 }
1658
1659 // Apply initial theme styles
1660 updateThemeStyles();
1661
1662 // Expose for external use (mock data injection, etc.)
1663 window.ProcessTreeViz = {
1664 updateViz,
1665 scene,
1666 camera,
1667 renderer,
1668 controls,
1669 meshes,
1670 connections,
1671 graveyard,
1672 // Tour mode
1673 startTour,
1674 exitTour,
1675 tourNext,
1676 tourPrev,
1677 toggleAutoPlay,
1678 isTourMode: () => tourMode,
1679 // Theme control
1680 setTheme,
1681 toggleTheme,
1682 getTheme: () => currentTheme,
1683 getScheme: () => scheme,
1684 colorSchemes
1685 };
1686
1687 // Add tour button to #header-right (new structure) or .header-right (old structure)
1688 const headerRight = document.getElementById('header-right') || document.querySelector('.header-right');
1689 if (headerRight) {
1690 const tourBtn = document.createElement('button');
1691 tourBtn.id = 'tour-btn';
1692 tourBtn.className = 'hdr-btn';
1693 tourBtn.textContent = '🎬';
1694 tourBtn.title = 'Tour Mode';
1695 tourBtn.onclick = () => {
1696 const isSourceTour = window.ASTTreeViz?.getTab() === 'sources' && window.ASTTreeViz?.isTourMode();
1697 if (!tourMode && !isSourceTour) startTour();
1698 else exitTour();
1699 };
1700 headerRight.insertBefore(tourBtn, headerRight.firstChild);
1701 }
1702
1703 animate();
1704 connectWS();
1705})();