491 lines
16 KiB
JavaScript
491 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.
|
|
// ==========================================
|
|
|
|
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 = {};
|
|
}
|