This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/note-graph.js
2026-03-14 16:46:16 +00:00

502 lines
16 KiB
JavaScript

// ==========================================
// 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);