mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
1052 lines
32 KiB
Svelte
1052 lines
32 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import { getGraphData } from '$lib/api';
|
|
import { activeNotePath, appConfig } from '$lib/stores/app';
|
|
|
|
let { onclose, onnavigate }: {
|
|
onclose: () => void;
|
|
onnavigate: (path: string, title: string) => void;
|
|
} = $props();
|
|
|
|
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
|
|
let canvas = $state<HTMLCanvasElement>(null!);
|
|
let loading = $state(true);
|
|
let searchQuery = $state('');
|
|
let localMode = $state(true); // default: show only active note's neighborhood
|
|
|
|
const dataPromise = getGraphData();
|
|
|
|
interface GraphNode { id: string; title: string; path: string; x: number; y: number; vx: number; vy: number; }
|
|
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
|
|
interface LabelRect { x: number; y: number; w: number; h: number; }
|
|
|
|
let nodes: GraphNode[] = [];
|
|
let edges: GraphEdge[] = [];
|
|
let nodeIndexMap: Map<string, number> = new Map();
|
|
let connectedSet: Set<number> = new Set();
|
|
let nodeDegree: number[] = [];
|
|
let searchMatchSet: Set<number> | null = null;
|
|
let folderColorMap = new Map<string, string>();
|
|
|
|
let pan = { x: 0, y: 0 };
|
|
let zoom = 1;
|
|
let dragging: GraphNode | null = null;
|
|
let dragMoved = false;
|
|
let mouseDownPos = { x: 0, y: 0 };
|
|
let panning = false;
|
|
let panStart = { x: 0, y: 0 };
|
|
let hoveredNode: GraphNode | null = null;
|
|
let hoveredIdx = -1;
|
|
let hoveredNeighborSet: Set<number> = new Set();
|
|
let hoveredEdgeSet: Set<number> = new Set();
|
|
let glowPhase = 0;
|
|
let activeNodeIdx = -1;
|
|
let navigatedFromGraph = false;
|
|
|
|
// Combined animation loop
|
|
let physicsRemaining = 0;
|
|
let animFrameId = 0;
|
|
let lastGlowTs = 0;
|
|
|
|
// DPI fix
|
|
let dpr = 1;
|
|
let canvasW = 0;
|
|
let canvasH = 0;
|
|
|
|
// Style cache - cleared on theme change
|
|
let cachedStyles: { border: string; text: string; textSec: string; accent: string; bg: string } | null = null;
|
|
|
|
const FOLDER_PALETTE = [
|
|
'#60a5fa', '#34d399', '#f59e0b', '#a78bfa',
|
|
'#fb7185', '#22d3ee', '#fb923c', '#86efac',
|
|
];
|
|
|
|
function getStyles() {
|
|
if (cachedStyles) return cachedStyles;
|
|
const s = getComputedStyle(document.documentElement);
|
|
cachedStyles = {
|
|
border: s.getPropertyValue('--border-color').trim() || '#444',
|
|
text: s.getPropertyValue('--text-primary').trim() || '#eee',
|
|
textSec: s.getPropertyValue('--text-secondary').trim() || '#aaa',
|
|
accent: s.getPropertyValue('--accent').trim() || '#7b9bd4',
|
|
bg: s.getPropertyValue('--bg-primary').trim() || '#1a1a1a',
|
|
};
|
|
return cachedStyles;
|
|
}
|
|
|
|
function getFolderKey(path: string): string {
|
|
// Normalize Windows backslashes so folder grouping/colours work there too.
|
|
const p = (path || '').replace(/\\/g, '/');
|
|
const vaultRoot = ($appConfig?.active_vault || '').replace(/\\/g, '/');
|
|
const rel = vaultRoot && p.startsWith(vaultRoot + '/') ? p.slice(vaultRoot.length + 1) : p;
|
|
const slash = rel.indexOf('/');
|
|
return slash >= 0 ? rel.slice(0, slash) : '';
|
|
}
|
|
|
|
function buildFolderColors() {
|
|
const seen = new Map<string, string>();
|
|
let colorIdx = 0;
|
|
for (const n of nodes) {
|
|
const key = getFolderKey(n.path);
|
|
if (key && !seen.has(key)) {
|
|
seen.set(key, FOLDER_PALETTE[colorIdx % FOLDER_PALETTE.length]);
|
|
colorIdx++;
|
|
}
|
|
}
|
|
folderColorMap = seen;
|
|
}
|
|
|
|
function getNodeBaseColor(i: number): string {
|
|
const { textSec, border } = getStyles();
|
|
if ((nodeDegree[i] || 0) === 0) return border;
|
|
const key = getFolderKey(nodes[i].path);
|
|
return folderColorMap.get(key) || textSec;
|
|
}
|
|
|
|
function getNodeRadius(i: number, isActive: boolean): number {
|
|
if (isActive) return 8;
|
|
const deg = nodeDegree[i] || 0;
|
|
if (deg === 0) return 3;
|
|
return Math.min(4.5 + deg * 0.7, 9);
|
|
}
|
|
|
|
function updateHoveredState(node: GraphNode | null) {
|
|
hoveredNode = node;
|
|
hoveredIdx = node ? nodes.indexOf(node) : -1;
|
|
hoveredNeighborSet = new Set();
|
|
hoveredEdgeSet = new Set();
|
|
if (hoveredIdx < 0) return;
|
|
hoveredNeighborSet.add(hoveredIdx);
|
|
for (let i = 0; i < edges.length; i++) {
|
|
const e = edges[i];
|
|
if (e.sourceIdx === hoveredIdx || e.targetIdx === hoveredIdx) {
|
|
hoveredEdgeSet.add(i);
|
|
hoveredNeighborSet.add(e.sourceIdx);
|
|
hoveredNeighborSet.add(e.targetIdx);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nodes/edges visible in local mode (active note + direct neighbours)
|
|
function computeLocalSets(): { nodeSet: Set<number>; edgeSet: Set<number> } {
|
|
const nodeSet = new Set<number>();
|
|
const edgeSet = new Set<number>();
|
|
if (activeNodeIdx < 0) return { nodeSet, edgeSet };
|
|
nodeSet.add(activeNodeIdx);
|
|
for (let i = 0; i < edges.length; i++) {
|
|
const e = edges[i];
|
|
if (e.sourceIdx === activeNodeIdx || e.targetIdx === activeNodeIdx) {
|
|
edgeSet.add(i);
|
|
nodeSet.add(e.sourceIdx);
|
|
nodeSet.add(e.targetIdx);
|
|
}
|
|
}
|
|
return { nodeSet, edgeSet };
|
|
}
|
|
|
|
function updateSearchMatch() {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
if (!q) { searchMatchSet = null; return; }
|
|
const matched = new Set<number>();
|
|
nodes.forEach((n, i) => { if (n.title.toLowerCase().includes(q)) matched.add(i); });
|
|
searchMatchSet = matched;
|
|
}
|
|
|
|
async function buildGraph() {
|
|
loading = true;
|
|
try {
|
|
const data = await dataPromise;
|
|
const w = canvasW || 800;
|
|
const h = canvasH || 600;
|
|
|
|
nodeIndexMap = new Map();
|
|
nodes = data.nodes.map((n, i) => {
|
|
nodeIndexMap.set(n.title.toLowerCase(), i);
|
|
return {
|
|
id: n.title.toLowerCase(),
|
|
title: n.title,
|
|
path: n.path,
|
|
x: w / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.5,
|
|
y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.5,
|
|
vx: 0, vy: 0,
|
|
};
|
|
});
|
|
|
|
connectedSet = new Set();
|
|
nodeDegree = new Array(nodes.length).fill(0);
|
|
edges = data.edges.map(e => {
|
|
connectedSet.add(e.source);
|
|
connectedSet.add(e.target);
|
|
nodeDegree[e.source] = (nodeDegree[e.source] || 0) + 1;
|
|
nodeDegree[e.target] = (nodeDegree[e.target] || 0) + 1;
|
|
return { sourceIdx: e.source, targetIdx: e.target, bidirectional: e.bidirectional };
|
|
});
|
|
|
|
buildFolderColors();
|
|
updateSearchMatch();
|
|
activeNodeIdx = nodes.findIndex(n => n.path === ($activeNotePath || ''));
|
|
} catch (e) {
|
|
console.error('Failed to build graph:', e);
|
|
}
|
|
loading = false;
|
|
startSimulation();
|
|
}
|
|
|
|
function centerOnActiveNote() {
|
|
if (!canvas || nodes.length === 0 || activeNodeIdx < 0) return;
|
|
const activeNode = nodes[activeNodeIdx];
|
|
const neighborhood: GraphNode[] = [activeNode];
|
|
for (const edge of edges) {
|
|
if (edge.sourceIdx === activeNodeIdx) neighborhood.push(nodes[edge.targetIdx]);
|
|
else if (edge.targetIdx === activeNodeIdx) neighborhood.push(nodes[edge.sourceIdx]);
|
|
}
|
|
const w = canvasW, h = canvasH, padding = 100;
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const n of neighborhood) {
|
|
if (n.x < minX) minX = n.x; if (n.y < minY) minY = n.y;
|
|
if (n.x > maxX) maxX = n.x; if (n.y > maxY) maxY = n.y;
|
|
}
|
|
const graphW = maxX - minX || 1, graphH = maxY - minY || 1;
|
|
zoom = Math.max(Math.min((w - padding * 2) / graphW, (h - padding * 2) / graphH, 2), 0.4);
|
|
pan.x = w / 2 - ((minX + maxX) / 2) * zoom;
|
|
pan.y = h / 2 - ((minY + maxY) / 2) * zoom;
|
|
}
|
|
|
|
function fitToView() {
|
|
if (!canvas || nodes.length === 0) return;
|
|
const w = canvasW, h = canvasH, padding = 70;
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const n of nodes) {
|
|
if (n.x < minX) minX = n.x; if (n.y < minY) minY = n.y;
|
|
if (n.x > maxX) maxX = n.x; if (n.y > maxY) maxY = n.y;
|
|
}
|
|
const graphW = maxX - minX || 1, graphH = maxY - minY || 1;
|
|
zoom = Math.max(Math.min((w - padding * 2) / graphW, (h - padding * 2) / graphH, 2), 0.2);
|
|
pan.x = w / 2 - ((minX + maxX) / 2) * zoom;
|
|
pan.y = h / 2 - ((minY + maxY) / 2) * zoom;
|
|
}
|
|
|
|
function focusActive() {
|
|
if (activeNodeIdx >= 0) centerOnActiveNote(); else fitToView();
|
|
draw();
|
|
}
|
|
|
|
function startSimulation() {
|
|
// Initial settle - enough for a rough layout, camera set after full physics
|
|
for (let i = 0; i < 80; i++) simulate();
|
|
|
|
// Show the full graph while physics finishes settling
|
|
fitToView();
|
|
draw();
|
|
|
|
// Queue remaining physics - camera will snap to active note when done
|
|
physicsRemaining = Math.min(400, Math.max(150, nodes.length * 3));
|
|
startAnimLoop();
|
|
}
|
|
|
|
function startAnimLoop() {
|
|
if (animFrameId) cancelAnimationFrame(animFrameId);
|
|
lastGlowTs = 0;
|
|
|
|
function loop(ts: number) {
|
|
let needsDraw = false;
|
|
|
|
// Physics batch
|
|
if (physicsRemaining > 0) {
|
|
const batch = Math.min(20, physicsRemaining);
|
|
for (let i = 0; i < batch; i++) simulate();
|
|
physicsRemaining -= batch;
|
|
// When physics is fully settled, snap camera to active note
|
|
if (physicsRemaining === 0) {
|
|
if (activeNodeIdx >= 0) centerOnActiveNote(); else fitToView();
|
|
}
|
|
needsDraw = true;
|
|
}
|
|
|
|
// Glow pulse for active node - throttled to ~30fps
|
|
if (activeNodeIdx >= 0) {
|
|
const elapsed = lastGlowTs > 0 ? Math.min(ts - lastGlowTs, 100) : 16.67;
|
|
if (lastGlowTs === 0 || ts - lastGlowTs >= 33) {
|
|
glowPhase += 0.04 * (elapsed / 16.67);
|
|
lastGlowTs = ts;
|
|
needsDraw = true;
|
|
}
|
|
}
|
|
|
|
if (needsDraw) draw();
|
|
|
|
if (physicsRemaining > 0 || activeNodeIdx >= 0) {
|
|
animFrameId = requestAnimationFrame(loop);
|
|
} else {
|
|
animFrameId = 0;
|
|
}
|
|
}
|
|
animFrameId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
function simulate() {
|
|
const nodeCount = nodes.length;
|
|
if (nodeCount === 0) return;
|
|
const w = canvasW || 800, h = canvasH || 600;
|
|
const centerX = w / 2, centerY = h / 2;
|
|
|
|
// Repulsion
|
|
for (let i = 0; i < nodeCount; i++) {
|
|
const a = nodes[i];
|
|
for (let j = i + 1; j < nodeCount; j++) {
|
|
const b = nodes[j];
|
|
const dx = b.x - a.x, dy = b.y - a.y;
|
|
const distSq = dx * dx + dy * dy;
|
|
if (distSq > 360000) continue;
|
|
const d = distSq || 1;
|
|
const force = 1500 / d;
|
|
const dist = Math.sqrt(d);
|
|
const fx = (dx / dist) * force, fy = (dy / dist) * force;
|
|
a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy;
|
|
}
|
|
}
|
|
|
|
// Spring attraction along edges
|
|
for (const edge of edges) {
|
|
const a = nodes[edge.sourceIdx], b = nodes[edge.targetIdx];
|
|
const dx = b.x - a.x, dy = b.y - a.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const force = (dist - 130) * 0.012;
|
|
const fx = (dx / dist) * force, fy = (dy / dist) * force;
|
|
a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
|
|
}
|
|
|
|
// Center gravity - weaker for isolated nodes so they spread instead of clustering
|
|
for (let i = 0; i < nodeCount; i++) {
|
|
const node = nodes[i];
|
|
const gravity = (nodeDegree[i] || 0) === 0 ? 0.0002 : 0.0008;
|
|
node.vx += (centerX - node.x) * gravity;
|
|
node.vy += (centerY - node.y) * gravity;
|
|
}
|
|
|
|
// Damping + integration
|
|
for (const node of nodes) {
|
|
if (node === dragging) continue;
|
|
node.vx *= 0.85; node.vy *= 0.85;
|
|
node.x += node.vx; node.y += node.vy;
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.save();
|
|
ctx.scale(dpr, dpr);
|
|
ctx.translate(pan.x, pan.y);
|
|
ctx.scale(zoom, zoom);
|
|
|
|
const { border: borderColor, text: textColor, textSec, accent, bg: bgColor } = getStyles();
|
|
const activePath = $activeNotePath || '';
|
|
const pulse = 0.5 + 0.5 * Math.sin(glowPhase);
|
|
const isHovering = hoveredIdx >= 0;
|
|
const isSearching = searchMatchSet !== null;
|
|
|
|
// Local mode: restrict which nodes/edges are visible
|
|
const { nodeSet: localNodeSet, edgeSet: localEdgeSet } = localMode ? computeLocalSets() : { nodeSet: null as Set<number> | null, edgeSet: null as Set<number> | null };
|
|
|
|
// ── Edges ──────────────────────────────────────────────────────────────
|
|
if (isHovering) {
|
|
// Dimmed non-connected edges
|
|
ctx.globalAlpha = 0.06;
|
|
ctx.strokeStyle = borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < edges.length; i++) {
|
|
if (hoveredEdgeSet.has(i)) continue;
|
|
if (localEdgeSet && !localEdgeSet.has(i)) continue;
|
|
ctx.moveTo(nodes[edges[i].sourceIdx].x, nodes[edges[i].sourceIdx].y);
|
|
ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Highlighted connected edges
|
|
ctx.globalAlpha = 0.8;
|
|
ctx.strokeStyle = accent;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
for (const i of hoveredEdgeSet) {
|
|
ctx.moveTo(nodes[edges[i].sourceIdx].x, nodes[edges[i].sourceIdx].y);
|
|
ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Arrowheads on highlighted edges
|
|
ctx.fillStyle = accent;
|
|
ctx.globalAlpha = 0.8;
|
|
const drawArrow = (tipX: number, tipY: number, ux: number, uy: number) => {
|
|
const bx = tipX - ux * 7, by = tipY - uy * 7;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tipX, tipY);
|
|
ctx.lineTo(bx - uy * 3.5, by + ux * 3.5);
|
|
ctx.lineTo(bx + uy * 3.5, by - ux * 3.5);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
};
|
|
for (const i of hoveredEdgeSet) {
|
|
const e = edges[i];
|
|
const src = nodes[e.sourceIdx];
|
|
const tgt = nodes[e.targetIdx];
|
|
const dx = tgt.x - src.x, dy = tgt.y - src.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < 1) continue;
|
|
const ux = dx / dist, uy = dy / dist;
|
|
// Arrow at target end (source → target)
|
|
const tgtR = getNodeRadius(e.targetIdx, tgt.path === activePath);
|
|
drawArrow(tgt.x - ux * tgtR, tgt.y - uy * tgtR, ux, uy);
|
|
// If bidirectional, also draw arrow at source end (target → source)
|
|
if (e.bidirectional) {
|
|
const srcR = getNodeRadius(e.sourceIdx, src.path === activePath);
|
|
drawArrow(src.x + ux * srcR, src.y + uy * srcR, -ux, -uy);
|
|
}
|
|
}
|
|
} else if (isSearching) {
|
|
// All visible edges faint
|
|
ctx.strokeStyle = borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.globalAlpha = 0.07;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < edges.length; i++) {
|
|
if (localEdgeSet && !localEdgeSet.has(i)) continue;
|
|
ctx.moveTo(nodes[edges[i].sourceIdx].x, nodes[edges[i].sourceIdx].y);
|
|
ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y);
|
|
}
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 0.4;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < edges.length; i++) {
|
|
if (localEdgeSet && !localEdgeSet.has(i)) continue;
|
|
const e = edges[i];
|
|
if (searchMatchSet!.has(e.sourceIdx) || searchMatchSet!.has(e.targetIdx)) {
|
|
ctx.moveTo(nodes[e.sourceIdx].x, nodes[e.sourceIdx].y);
|
|
ctx.lineTo(nodes[e.targetIdx].x, nodes[e.targetIdx].y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
} else {
|
|
ctx.strokeStyle = borderColor;
|
|
ctx.lineWidth = isMobile ? 1.5 : 1;
|
|
ctx.globalAlpha = isMobile ? 0.65 : 0.35;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < edges.length; i++) {
|
|
if (localEdgeSet && !localEdgeSet.has(i)) continue;
|
|
ctx.moveTo(nodes[edges[i].sourceIdx].x, nodes[edges[i].sourceIdx].y);
|
|
ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// On mobile draw arrowheads always (no hover available)
|
|
// Sizes divided by zoom so they stay fixed screen size regardless of zoom level
|
|
if (isMobile) {
|
|
ctx.fillStyle = borderColor;
|
|
ctx.globalAlpha = 0.6;
|
|
const al = 8 / zoom, aw = 4 / zoom;
|
|
const drawArrow = (tipX: number, tipY: number, ux: number, uy: number) => {
|
|
const bx = tipX - ux * al, by = tipY - uy * al;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tipX, tipY);
|
|
ctx.lineTo(bx - uy * aw, by + ux * aw);
|
|
ctx.lineTo(bx + uy * aw, by - ux * aw);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
};
|
|
for (let i = 0; i < edges.length; i++) {
|
|
if (localEdgeSet && !localEdgeSet.has(i)) continue;
|
|
const e = edges[i];
|
|
const src = nodes[e.sourceIdx], tgt = nodes[e.targetIdx];
|
|
const dx = tgt.x - src.x, dy = tgt.y - src.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < 1) continue;
|
|
const ux = dx / dist, uy = dy / dist;
|
|
const tgtR = getNodeRadius(e.targetIdx, tgt.path === activePath);
|
|
drawArrow(tgt.x - ux * tgtR, tgt.y - uy * tgtR, ux, uy);
|
|
if (e.bidirectional) {
|
|
const srcR = getNodeRadius(e.sourceIdx, src.path === activePath);
|
|
drawArrow(src.x + ux * srcR, src.y + uy * srcR, -ux, -uy);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
// ── Nodes + label collection ────────────────────────────────────────────
|
|
type LabelSpec = { x: number; y: number; text: string; fontSize: number; weight: string; color: string; alpha: number; priority: number; };
|
|
const labelQueue: LabelSpec[] = [];
|
|
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
// Skip nodes outside local view
|
|
if (localNodeSet && !localNodeSet.has(i)) continue;
|
|
|
|
const node = nodes[i];
|
|
const isActive = node.path === activePath;
|
|
const isHovered = node === hoveredNode;
|
|
const isNeighbor = hoveredNeighborSet.has(i);
|
|
const hasLinks = connectedSet.has(i);
|
|
const matchesSearch = !isSearching || searchMatchSet!.has(i);
|
|
|
|
const dimBySearch = isSearching && !matchesSearch;
|
|
const dimByHover = isHovering && !isActive && !isNeighbor;
|
|
const dimmed = dimBySearch || dimByHover;
|
|
|
|
const radius = getNodeRadius(i, isActive);
|
|
|
|
// Active glow ring - opacity pulse, no radius change
|
|
if (isActive) {
|
|
const glowR = radius + 12;
|
|
const glow = ctx.createRadialGradient(node.x, node.y, radius, node.x, node.y, glowR);
|
|
const glowAlpha = Math.round((0.25 + pulse * 0.45) * 255).toString(16).padStart(2, '0');
|
|
glow.addColorStop(0, accent + glowAlpha);
|
|
glow.addColorStop(1, accent + '00');
|
|
ctx.globalAlpha = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, glowR, 0, Math.PI * 2);
|
|
ctx.fillStyle = glow;
|
|
ctx.fill();
|
|
}
|
|
|
|
// Node fill
|
|
ctx.globalAlpha = dimmed ? (dimBySearch ? 0.07 : 0.12) : 1;
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
|
|
if (isActive || isHovered) {
|
|
ctx.fillStyle = accent;
|
|
} else if (isHovering && isNeighbor) {
|
|
ctx.fillStyle = accent + 'bb';
|
|
} else {
|
|
ctx.fillStyle = getNodeBaseColor(i);
|
|
}
|
|
ctx.fill();
|
|
|
|
// Active outer ring - static, alpha pulse only
|
|
if (isActive) {
|
|
ctx.globalAlpha = 0.35 + pulse * 0.3;
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, radius + 3, 0, Math.PI * 2);
|
|
ctx.strokeStyle = accent;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Queue label (skip for heavily dimmed nodes)
|
|
if (dimBySearch) continue;
|
|
const showLabel = isActive || isHovered ||
|
|
(matchesSearch && isSearching) ||
|
|
(hasLinks && zoom > 0.45);
|
|
if (!showLabel) continue;
|
|
|
|
const raw = node.title;
|
|
const text = raw.length > 24 ? raw.slice(0, 22) + '…' : raw;
|
|
const fontSize = isActive ? 12 : isHovered ? 11 : 10;
|
|
const weight = isActive ? '600' : '400';
|
|
const color = isActive || isHovered ? textColor : textSec;
|
|
const alpha = dimByHover ? 0 : 1;
|
|
const priority = isActive ? 0 : isHovered ? 1 : (matchesSearch && isSearching ? 2 : 3);
|
|
|
|
labelQueue.push({ x: node.x, y: node.y - radius - 5, text, fontSize, weight, color, alpha, priority });
|
|
}
|
|
|
|
// Draw labels with collision avoidance - high-priority labels drawn first
|
|
labelQueue.sort((a, b) => a.priority - b.priority);
|
|
const drawnRects: LabelRect[] = [];
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'bottom';
|
|
|
|
for (const lab of labelQueue) {
|
|
if (lab.alpha === 0) continue;
|
|
ctx.font = `${lab.weight} ${lab.fontSize}px -apple-system, BlinkMacSystemFont, sans-serif`;
|
|
const tw = ctx.measureText(lab.text).width;
|
|
const th = lab.fontSize + 2;
|
|
const rx = lab.x - tw / 2, ry = lab.y - th;
|
|
const overlaps = drawnRects.some(r =>
|
|
rx < r.x + r.w + 6 && rx + tw > r.x - 6 &&
|
|
ry < r.y + r.h + 3 && ry + th > r.y - 3
|
|
);
|
|
if (!overlaps) {
|
|
drawnRects.push({ x: rx, y: ry, w: tw, h: th });
|
|
ctx.globalAlpha = lab.alpha;
|
|
ctx.fillStyle = lab.color;
|
|
ctx.shadowColor = bgColor;
|
|
ctx.shadowBlur = 5 / zoom;
|
|
ctx.fillText(lab.text, lab.x, lab.y);
|
|
}
|
|
}
|
|
ctx.shadowBlur = 0;
|
|
ctx.globalAlpha = 1;
|
|
ctx.textBaseline = 'alphabetic';
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function getNodeAt(clientX: number, clientY: number): GraphNode | null {
|
|
if (!canvas) return null;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (clientX - rect.left - pan.x) / zoom;
|
|
const y = (clientY - rect.top - pan.y) / zoom;
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
const n = nodes[i];
|
|
const dx = n.x - x, dy = n.y - y;
|
|
const r = getNodeRadius(i, n.path === ($activeNotePath || '')) + 4;
|
|
if (dx * dx + dy * dy < r * r) return n;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function handleMouseDown(e: MouseEvent) {
|
|
mouseDownPos = { x: e.clientX, y: e.clientY };
|
|
dragMoved = false;
|
|
const node = getNodeAt(e.clientX, e.clientY);
|
|
if (node) { dragging = node; }
|
|
else { panning = true; panStart = { x: e.clientX - pan.x, y: e.clientY - pan.y }; }
|
|
}
|
|
|
|
function handleMouseMove(e: MouseEvent) {
|
|
const dx = e.clientX - mouseDownPos.x, dy = e.clientY - mouseDownPos.y;
|
|
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
|
|
|
|
if (dragging && dragMoved) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
dragging.x = (e.clientX - rect.left - pan.x) / zoom;
|
|
dragging.y = (e.clientY - rect.top - pan.y) / zoom;
|
|
dragging.vx = 0; dragging.vy = 0;
|
|
draw();
|
|
} else if (panning) {
|
|
pan.x = e.clientX - panStart.x;
|
|
pan.y = e.clientY - panStart.y;
|
|
draw();
|
|
} else if (!dragging) {
|
|
const node = getNodeAt(e.clientX, e.clientY);
|
|
if (node !== hoveredNode) {
|
|
updateHoveredState(node);
|
|
if (canvas) canvas.style.cursor = node ? 'pointer' : 'grab';
|
|
// Restart loop if it stopped (for immediate hover redraws)
|
|
if (!animFrameId) draw();
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleMouseUp(e: MouseEvent) {
|
|
if (dragging && !dragMoved) {
|
|
const node = dragging;
|
|
dragging = null;
|
|
navigatedFromGraph = true;
|
|
onnavigate(node.path, node.title);
|
|
return;
|
|
}
|
|
dragging = null;
|
|
panning = false;
|
|
}
|
|
|
|
function handleWheel(e: WheelEvent) {
|
|
e.preventDefault();
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
const oldZoom = zoom;
|
|
zoom = Math.max(0.2, Math.min(5, zoom * (e.deltaY > 0 ? 0.9 : 1.1)));
|
|
pan.x = mx - (mx - pan.x) * (zoom / oldZoom);
|
|
pan.y = my - (my - pan.y) * (zoom / oldZoom);
|
|
draw();
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
if (searchQuery) { searchQuery = ''; return; }
|
|
onclose();
|
|
}
|
|
}
|
|
|
|
let lastTouchDist = 0;
|
|
|
|
function handleTouchStart(e: TouchEvent) {
|
|
if (e.touches.length === 1) {
|
|
const t = e.touches[0];
|
|
mouseDownPos = { x: t.clientX, y: t.clientY };
|
|
dragMoved = false;
|
|
const node = getNodeAt(t.clientX, t.clientY);
|
|
if (node) { dragging = node; }
|
|
else { panning = true; panStart = { x: t.clientX - pan.x, y: t.clientY - pan.y }; }
|
|
} else if (e.touches.length === 2) {
|
|
dragging = null; panning = false;
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
}
|
|
|
|
function handleTouchMove(e: TouchEvent) {
|
|
e.preventDefault();
|
|
if (e.touches.length === 1) {
|
|
const t = e.touches[0];
|
|
const dx = t.clientX - mouseDownPos.x, dy = t.clientY - mouseDownPos.y;
|
|
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
|
|
if (dragging && dragMoved) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
dragging.x = (t.clientX - rect.left - pan.x) / zoom;
|
|
dragging.y = (t.clientY - rect.top - pan.y) / zoom;
|
|
dragging.vx = 0; dragging.vy = 0;
|
|
draw();
|
|
} else if (panning) {
|
|
pan.x = t.clientX - panStart.x;
|
|
pan.y = t.clientY - panStart.y;
|
|
draw();
|
|
}
|
|
} else if (e.touches.length === 2) {
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (lastTouchDist > 0) {
|
|
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = midX - rect.left, my = midY - rect.top;
|
|
const oldZoom = zoom;
|
|
zoom = Math.max(0.2, Math.min(5, zoom * (dist / lastTouchDist)));
|
|
pan.x = mx - (mx - pan.x) * (zoom / oldZoom);
|
|
pan.y = my - (my - pan.y) * (zoom / oldZoom);
|
|
draw();
|
|
}
|
|
lastTouchDist = dist;
|
|
}
|
|
}
|
|
|
|
function handleTouchEnd(e: TouchEvent) {
|
|
if (e.touches.length === 0) {
|
|
if (dragging && !dragMoved) {
|
|
const node = dragging;
|
|
dragging = null;
|
|
navigatedFromGraph = true;
|
|
onnavigate(node.path, node.title);
|
|
return;
|
|
}
|
|
dragging = null; panning = false; lastTouchDist = 0;
|
|
}
|
|
}
|
|
|
|
// Canvas setup
|
|
$effect(() => {
|
|
if (canvas) {
|
|
const rect = canvas.parentElement?.getBoundingClientRect();
|
|
if (rect) {
|
|
dpr = window.devicePixelRatio || 1;
|
|
canvasW = rect.width;
|
|
canvasH = rect.height;
|
|
canvas.width = canvasW * dpr;
|
|
canvas.height = canvasH * dpr;
|
|
}
|
|
buildGraph();
|
|
}
|
|
});
|
|
|
|
// React to active note changes (e.g., user clicks node → navigates → re-center)
|
|
$effect(() => {
|
|
const ap = $activeNotePath;
|
|
const newIdx = nodes.findIndex(n => n.path === (ap || ''));
|
|
activeNodeIdx = newIdx;
|
|
if (navigatedFromGraph && newIdx >= 0 && canvas) {
|
|
navigatedFromGraph = false;
|
|
centerOnActiveNote();
|
|
}
|
|
if (newIdx >= 0 && !animFrameId && canvas) startAnimLoop();
|
|
else if (newIdx < 0 && !animFrameId && canvas) draw(); // remove stale glow immediately
|
|
});
|
|
|
|
// React to search query changes
|
|
$effect(() => {
|
|
const _q = searchQuery; // track
|
|
updateSearchMatch();
|
|
if (canvas && !animFrameId) draw();
|
|
if (activeNodeIdx >= 0 && !animFrameId && canvas) startAnimLoop();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (animFrameId) cancelAnimationFrame(animFrameId);
|
|
});
|
|
</script>
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="graph-overlay" onkeydown={handleKeydown}>
|
|
<div class="graph-panel" class:mobile={isMobile}>
|
|
<div class="graph-header">
|
|
<h3>Graph View</h3>
|
|
<div class="graph-search">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
<!-- svelte-ignore a11y_autofocus -->
|
|
<input
|
|
type="text"
|
|
bind:value={searchQuery}
|
|
placeholder="Filter notes..."
|
|
class:active={!!searchQuery}
|
|
/>
|
|
{#if searchQuery}
|
|
<button class="graph-search-clear" aria-label="Clear search" onclick={() => { searchQuery = ''; }}>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
<div class="graph-stats">
|
|
{#if !loading}
|
|
{#if localMode && activeNodeIdx >= 0}
|
|
{computeLocalSets().nodeSet.size} notes · {computeLocalSets().edgeSet.size} links
|
|
{:else}
|
|
{nodes.length} notes · {edges.length} links
|
|
{/if}
|
|
{#if searchMatchSet}
|
|
· <span class="search-count">{searchMatchSet.size} matched</span>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
<button
|
|
class="graph-btn"
|
|
class:active={localMode}
|
|
title={localMode ? 'Show all notes' : 'Show local graph'}
|
|
onclick={() => { localMode = !localMode; focusActive(); }}
|
|
>
|
|
<!-- network/local icon -->
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/>
|
|
<line x1="12" y1="7" x2="5" y2="17"/><line x1="12" y1="7" x2="19" y2="17"/>
|
|
</svg>
|
|
</button>
|
|
<button class="graph-btn" onclick={focusActive} title="Focus active note">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<line x1="12" y1="2" x2="12" y2="6"/>
|
|
<line x1="12" y1="18" x2="12" y2="22"/>
|
|
<line x1="2" y1="12" x2="6" y2="12"/>
|
|
<line x1="18" y1="12" x2="22" y2="12"/>
|
|
</svg>
|
|
</button>
|
|
<button class="graph-close" aria-label="Close graph" onclick={onclose}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="graph-body">
|
|
{#if loading}
|
|
<div class="graph-loading">
|
|
<svg class="spinner" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10" opacity="0.25"/>
|
|
<path d="M12 2a10 10 0 019.95 9"/>
|
|
</svg>
|
|
Building graph...
|
|
</div>
|
|
{/if}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<canvas
|
|
bind:this={canvas}
|
|
class="graph-canvas"
|
|
onmousedown={handleMouseDown}
|
|
onmousemove={handleMouseMove}
|
|
onmouseup={handleMouseUp}
|
|
onwheel={handleWheel}
|
|
ontouchstart={handleTouchStart}
|
|
ontouchmove={handleTouchMove}
|
|
ontouchend={handleTouchEnd}
|
|
></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.graph-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 2000;
|
|
}
|
|
|
|
.graph-panel {
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
box-shadow: var(--shadow-lg);
|
|
width: 85vw;
|
|
height: 75vh;
|
|
max-width: 1200px;
|
|
max-height: 800px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.graph-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border-light);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-panel.mobile {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
max-width: none;
|
|
max-height: none;
|
|
border-radius: 0;
|
|
border: none;
|
|
}
|
|
|
|
.graph-panel.mobile .graph-header {
|
|
padding-top: calc(env(safe-area-inset-top, 36px) + 12px);
|
|
}
|
|
|
|
.graph-panel.mobile .graph-header h3 {
|
|
display: none;
|
|
}
|
|
|
|
.graph-panel.mobile .graph-stats {
|
|
display: none;
|
|
}
|
|
|
|
.graph-panel.mobile .graph-search {
|
|
max-width: none;
|
|
}
|
|
|
|
.graph-header h3 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.graph-search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex: 1;
|
|
max-width: 260px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-light);
|
|
border-radius: 8px;
|
|
padding: 4px 8px;
|
|
color: var(--text-tertiary);
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
.graph-search:focus-within {
|
|
border-color: var(--accent);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.graph-search input {
|
|
background: none;
|
|
border: none;
|
|
outline: none;
|
|
font-size: 12px;
|
|
color: var(--text-primary);
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.graph-search input::placeholder {
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.graph-search-clear {
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
color: var(--text-tertiary);
|
|
display: flex;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-search-clear:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.graph-stats {
|
|
flex: 1;
|
|
font-size: 11px;
|
|
color: var(--text-tertiary);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.search-count {
|
|
color: var(--accent);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.graph-btn,
|
|
.graph-close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-tertiary);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-btn.active {
|
|
color: var(--accent);
|
|
background: var(--accent-light);
|
|
}
|
|
|
|
.graph-btn:hover,
|
|
.graph-close:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.graph-body {
|
|
flex: 1;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.graph-canvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: grab;
|
|
touch-action: none;
|
|
}
|
|
|
|
.graph-canvas:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.graph-loading {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
font-size: 13px;
|
|
color: var(--text-tertiary);
|
|
z-index: 1;
|
|
}
|
|
|
|
.spinner {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</style>
|