// ========================================== // Chat Switchboard — Note Graph (Canvas) // ========================================== // Force-directed graph visualization of note // connections. Lazy-loaded — initializes only // when the graph view is opened. // ========================================== // // Exports: window.openNoteGraph,window.closeNoteGraph,window._render,window._graphResetZoom,window._graphToggleOrphans,window.invalidateNoteGraph var _graphData = null; // cached { nodes, edges, unresolved } var _graphState = null; // { panX, panY, zoom, animId, canvas, ctx } var _graphDirty = true; // invalidation flag // ── Force Simulation Parameters ───────────── const SIM = { repulsion: 800, attraction: 0.006, edgeLength: 130, damping: 0.88, centerGravity: 0.012, maxVelocity: 8, ticksPerFrame: 3, coolThreshold: 0.5, // total KE below this → stop sim }; const BASE_RADIUS = 6; // ── Color Palette for Folders ─────────────── const FOLDER_COLORS = [ '#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#06b6d4', '#84cc16', '#f97316', ]; var _folderColorMap = {}; function folderColor(path) { if (!path || path === '/') return '#6b7280'; if (!_folderColorMap[path]) { const idx = Object.keys(_folderColorMap).length % FOLDER_COLORS.length; _folderColorMap[path] = FOLDER_COLORS[idx]; } return _folderColorMap[path]; } // ── Graph Initialization ──────────────────── async function openNoteGraph() { const listView = document.getElementById('notesListView'); const editorView = document.getElementById('notesEditorView'); const graphView = document.getElementById('notesGraphView'); if (!graphView) return; listView.style.display = 'none'; editorView.style.display = 'none'; graphView.style.display = ''; const canvas = document.getElementById('noteGraphCanvas'); if (!canvas) return; // Size canvas to panel const rect = canvas.parentElement.getBoundingClientRect(); canvas.width = rect.width; canvas.height = rect.height - 40; // toolbar height const ctx = canvas.getContext('2d'); _graphState = { panX: 0, panY: 0, zoom: 1, animId: null, canvas, ctx, hoveredNode: null, dragNode: null, isDragging: false, lastMouse: null, showOrphans: true, }; // Load data try { _graphData = await API.getNoteGraph(); _prepareGraphNodes(canvas); _updateGraphStats(); _startSimulation(); _attachGraphListeners(canvas); } catch (e) { ctx.fillStyle = 'var(--text, #ccc)'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Failed to load graph: ' + e.message, canvas.width / 2, canvas.height / 2); } } function closeNoteGraph() { if (_graphState?.animId) { cancelAnimationFrame(_graphState.animId); _graphState.animId = null; } const graphView = document.getElementById('notesGraphView'); if (graphView) graphView.style.display = 'none'; document.getElementById('notesListView').style.display = ''; } // ── Prepare Nodes with Physics State ──────── function _prepareGraphNodes(canvas) { if (!_graphData) return; const cx = canvas.width / 2; const cy = canvas.height / 2; // Build lookup for edge source/target const nodeMap = {}; for (const n of _graphData.nodes) { n.x = cx + (Math.random() - 0.5) * canvas.width * 0.5; n.y = cy + (Math.random() - 0.5) * canvas.height * 0.5; n.vx = 0; n.vy = 0; n.ax = 0; n.ay = 0; n.pinned = false; n.hovered = false; nodeMap[n.id] = n; } // Resolve edge references for (const e of _graphData.edges) { e.sourceNode = nodeMap[e.source]; e.targetNode = nodeMap[e.target]; } // Optionally include ghost nodes for unresolved links _graphData._ghostNodes = []; if (_graphData.unresolved) { const ghostMap = {}; for (const d of _graphData.unresolved) { const key = d.title.toLowerCase(); if (!ghostMap[key]) { const ghost = { id: '__ghost_' + key, title: d.title, folder_path: '', tags: [], link_count: 0, x: cx + (Math.random() - 0.5) * canvas.width * 0.4, y: cy + (Math.random() - 0.5) * canvas.height * 0.4, vx: 0, vy: 0, ax: 0, ay: 0, pinned: false, hovered: false, isGhost: true, }; ghostMap[key] = ghost; _graphData._ghostNodes.push(ghost); } } } } function _updateGraphStats() { const nc = document.getElementById('graphNodeCount'); const ec = document.getElementById('graphEdgeCount'); if (nc) nc.textContent = _graphData?.nodes?.length || 0; if (ec) ec.textContent = _graphData?.edges?.length || 0; } // ── Force Simulation Loop ─────────────────── function _startSimulation() { if (!_graphData || !_graphState) return; function frame() { for (let t = 0; t < SIM.ticksPerFrame; t++) { _tick(); } _render(); // Check convergence let totalKE = 0; for (const n of _allNodes()) { totalKE += n.vx * n.vx + n.vy * n.vy; } if (totalKE > SIM.coolThreshold || _graphState.dragNode) { _graphState.animId = requestAnimationFrame(frame); } else { _graphState.animId = null; // One final render _render(); } } if (_graphState.animId) cancelAnimationFrame(_graphState.animId); _graphState.animId = requestAnimationFrame(frame); } function _allNodes() { if (!_graphData) return []; const nodes = [..._graphData.nodes]; if (_graphState?.showOrphans !== false && _graphData._ghostNodes) { nodes.push(..._graphData._ghostNodes); } return nodes; } function _tick() { const nodes = _allNodes(); const edges = _graphData?.edges || []; const canvas = _graphState?.canvas; if (!canvas || nodes.length === 0) return; // Reset accelerations for (const n of nodes) { n.ax = 0; n.ay = 0; } // 1. Repulsion (all pairs) for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const dx = nodes[j].x - nodes[i].x; const dy = nodes[j].y - nodes[i].y; const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); const f = SIM.repulsion / (dist * dist); const fx = (dx / dist) * f; const fy = (dy / dist) * f; nodes[i].ax -= fx; nodes[i].ay -= fy; nodes[j].ax += fx; nodes[j].ay += fy; } } // 2. Attraction (edges) for (const e of edges) { const s = e.sourceNode, t = e.targetNode; if (!s || !t) continue; const dx = t.x - s.x, dy = t.y - s.y; const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); const f = SIM.attraction * (dist - SIM.edgeLength); const fx = (dx / dist) * f; const fy = (dy / dist) * f; s.ax += fx; s.ay += fy; t.ax -= fx; t.ay -= fy; } // 3. Center gravity + integration const cx = canvas.width / 2, cy = canvas.height / 2; for (const n of nodes) { if (n.pinned) continue; n.ax += (cx - n.x) * SIM.centerGravity; n.ay += (cy - n.y) * SIM.centerGravity; n.vx = _clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity); n.vy = _clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity); n.x += n.vx; n.y += n.vy; } } function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } // ── Canvas Rendering ──────────────────────── function _render() { const { ctx, canvas, panX, panY, zoom, hoveredNode, showOrphans } = _graphState; if (!ctx || !_graphData) return; const nodes = _allNodes(); const edges = _graphData.edges || []; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(panX, panY); ctx.scale(zoom, zoom); // Edges for (const e of edges) { const s = e.sourceNode, t = e.targetNode; if (!s || !t) continue; const isHighlighted = hoveredNode && (s.id === hoveredNode.id || t.id === hoveredNode.id); ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(t.x, t.y); if (e.is_transclusion) { ctx.setLineDash([4, 4]); ctx.strokeStyle = isHighlighted ? 'rgba(99, 102, 241, 0.8)' : 'rgba(99, 102, 241, 0.3)'; } else { ctx.setLineDash([]); ctx.strokeStyle = isHighlighted ? 'rgba(160, 160, 160, 0.8)' : 'rgba(120, 120, 120, 0.25)'; } ctx.lineWidth = isHighlighted ? 2 : 1; ctx.stroke(); } ctx.setLineDash([]); // Nodes for (const n of nodes) { if (n.isGhost && !showOrphans) continue; const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2; const isHovered = hoveredNode && n.id === hoveredNode.id; const isNeighbor = hoveredNode && _isNeighbor(hoveredNode, n); const isDimmed = hoveredNode && !isHovered && !isNeighbor; ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); if (n.isGhost) { ctx.fillStyle = isDimmed ? 'rgba(200, 50, 50, 0.1)' : 'rgba(200, 50, 50, 0.4)'; ctx.setLineDash([2, 2]); ctx.strokeStyle = 'rgba(200, 50, 50, 0.5)'; } else if (isHovered) { ctx.fillStyle = '#6366f1'; } else { const color = folderColor(n.folder_path); ctx.fillStyle = isDimmed ? _withAlpha(color, 0.15) : _withAlpha(color, 0.7); ctx.strokeStyle = isDimmed ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.15)'; } ctx.fill(); if (!n.isGhost) { ctx.setLineDash([]); ctx.lineWidth = isHovered ? 2 : 1; ctx.stroke(); } else { ctx.stroke(); ctx.setLineDash([]); } // Labels if (zoom > 0.5 && (isHovered || isNeighbor || !hoveredNode)) { const fontSize = Math.max(10, 11 / zoom); ctx.font = `${n.isGhost ? 'italic ' : ''}${fontSize}px var(--font, system-ui, sans-serif)`; ctx.fillStyle = isDimmed ? 'rgba(200,200,200,0.2)' : 'rgba(220,220,220,0.9)'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const label = n.title.length > 30 ? n.title.slice(0, 28) + '…' : n.title; ctx.fillText(label, n.x, n.y + r + 4); } } ctx.restore(); } function _isNeighbor(hovered, candidate) { if (!_graphData) return false; for (const e of _graphData.edges) { if ((e.source === hovered.id && e.target === candidate.id) || (e.target === hovered.id && e.source === candidate.id)) { return true; } } return false; } function _withAlpha(hex, alpha) { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgba(${r},${g},${b},${alpha})`; } // ── Interaction: Pan, Zoom, Drag, Click ───── function _attachGraphListeners(canvas) { // Mouse → canvas coordinates (accounting for pan/zoom) function toGraph(e) { const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left - _graphState.panX) / _graphState.zoom; const y = (e.clientY - rect.top - _graphState.panY) / _graphState.zoom; return { x, y }; } function hitTest(gx, gy) { const nodes = _allNodes(); for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i]; const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2; const dx = n.x - gx, dy = n.y - gy; if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n; } return null; } // Mousedown canvas.addEventListener('mousedown', (e) => { const g = toGraph(e); const node = hitTest(g.x, g.y); if (node && !node.isGhost) { _graphState.dragNode = node; node.pinned = true; _graphState.isDragging = false; } _graphState.lastMouse = { x: e.clientX, y: e.clientY }; }); // Mousemove canvas.addEventListener('mousemove', (e) => { const g = toGraph(e); if (_graphState.dragNode) { _graphState.isDragging = true; _graphState.dragNode.x = g.x; _graphState.dragNode.y = g.y; _graphState.dragNode.vx = 0; _graphState.dragNode.vy = 0; if (!_graphState.animId) _startSimulation(); } else if (_graphState.lastMouse && e.buttons === 1) { // Pan _graphState.panX += e.clientX - _graphState.lastMouse.x; _graphState.panY += e.clientY - _graphState.lastMouse.y; _graphState.lastMouse = { x: e.clientX, y: e.clientY }; _render(); } else { // Hover const node = hitTest(g.x, g.y); if (node !== _graphState.hoveredNode) { _graphState.hoveredNode = node; canvas.style.cursor = node ? 'pointer' : 'default'; _render(); } } }); // Mouseup canvas.addEventListener('mouseup', (e) => { if (_graphState.dragNode) { _graphState.dragNode.pinned = false; // Click (not drag) → open note if (!_graphState.isDragging && !_graphState.dragNode.isGhost) { openNoteEditor(_graphState.dragNode.id); } _graphState.dragNode = null; } _graphState.lastMouse = null; _graphState.isDragging = false; }); // Wheel → zoom canvas.addEventListener('wheel', (e) => { e.preventDefault(); const factor = e.deltaY > 0 ? 0.92 : 1.08; const newZoom = _clamp(_graphState.zoom * factor, 0.15, 4.0); // Zoom toward cursor const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; _graphState.panX = mx - (mx - _graphState.panX) * (newZoom / _graphState.zoom); _graphState.panY = my - (my - _graphState.panY) * (newZoom / _graphState.zoom); _graphState.zoom = newZoom; _render(); }, { passive: false }); // Resize observer — debounced to prevent notification loop let _roFrame = null; const ro = new ResizeObserver(() => { if (_roFrame) return; // already scheduled _roFrame = requestAnimationFrame(() => { _roFrame = null; const rect = canvas.parentElement.getBoundingClientRect(); const w = Math.round(rect.width); const h = Math.round(rect.height - 40); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; _render(); } }); }); ro.observe(canvas.parentElement); } function _graphResetZoom() { if (!_graphState) return; _graphState.panX = 0; _graphState.panY = 0; _graphState.zoom = 1; _render(); } function _graphToggleOrphans() { if (!_graphState) return; _graphState.showOrphans = !_graphState.showOrphans; _render(); } // ── Invalidation ──────────────────────────── function invalidateNoteGraph() { _graphData = null; _graphDirty = true; _folderColorMap = {}; } // ── Exports ───────────────────────────────── sb.register('openNoteGraph', openNoteGraph); sb.register('closeNoteGraph', closeNoteGraph); sb.register('_render', _render); sb.register('_graphResetZoom', _graphResetZoom); sb.register('_graphToggleOrphans', _graphToggleOrphans); sb.register('invalidateNoteGraph', invalidateNoteGraph);