Changeset 0.17.3 (#78)
This commit is contained in:
490
src/js/note-graph.js
Normal file
490
src/js/note-graph.js
Normal file
@@ -0,0 +1,490 @@
|
||||
// ==========================================
|
||||
// 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 = {};
|
||||
}
|
||||
Reference in New Issue
Block a user