Files
ink-note-app/src/lib/components/GraphView.svelte
T

424 lines
17 KiB
Svelte

<script>
// Graph view: every note is a dot, every [[link]] a line, laid out by the force simulation in graph.js.
// Drawn on a canvas. Drag dots, drag the background to pan, scroll to zoom, click a dot to open it.
import { onMount, untrack } from 'svelte';
import Icon from './Icon.svelte';
import WindowControls from './WindowControls.svelte';
import { store } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
import { createSimulation, neighbourhood } from '$lib/graph.js';
let canvas = $state();
let wrap = $state();
let data = $state.raw(null); // { nodes, edges, ghosts } from the files
let filter = $state('');
let panelOpen = $state(false);
let counts = $state({ nodes: 0, links: 0 });
const sim = createSimulation();
const view = { k: 1, x: 0, y: 0 }; // zoom and pan (screen px of the world origin from the centre)
let size = { w: 0, h: 0, dpr: 1 };
let hover = null; // node under the pointer
let gesture = null; // { kind: 'drag' | 'pan', ... }
let raf = 0;
let fitted = false;
let colors = {};
let positions = new Map(); // id -> {x, y}, so a reload keeps the layout
/* data */
async function load() {
try { data = await api.graph(); } catch (err) { toast(errorText(err), 'x'); data = { nodes: [], edges: [], ghosts: [] }; }
}
onMount(() => { load(); });
// Notes added, renamed, moved or deleted: reload (store.pages is replaced on every refresh).
$effect(() => { store.pages; untrack(() => data && load()); });
// What to show, given the toggles.
$effect(() => {
if (!data) return;
const g = store.graph;
const cur = store.currentId;
untrack(() => rebuild(g, cur));
});
function rebuild(g, cur) {
for (const n of sim.nodes) positions.set(n.id, { x: n.x, y: n.y, vx: n.vx, vy: n.vy });
let nodes = data.nodes.map((n) => ({ ...n, ghost: false }));
if (g.ghosts) nodes = nodes.concat(data.ghosts.map((n) => ({ ...n, ghost: true })));
const ids = new Set(nodes.map((n) => n.id));
let edges = data.edges.filter(([a, b]) => ids.has(a) && ids.has(b)).map((e) => [...e, 'link']);
if (g.folders) for (const n of nodes) if (n.parent && ids.has(n.parent)) edges.push([n.id, n.parent, 'tree']);
if (g.local && cur && ids.has(cur)) {
const keep = neighbourhood(cur, edges, g.depth || 1);
nodes = nodes.filter((n) => keep.has(n.id));
edges = edges.filter(([a, b]) => keep.has(a) && keep.has(b));
}
if (!g.orphans) {
const linked = new Set(edges.flatMap(([a, b]) => [a, b]));
nodes = nodes.filter((n) => linked.has(n.id) || n.id === cur);
}
for (const n of nodes) Object.assign(n, positions.get(n.id) || {});
sim.params.repel = (store.graph.repel || 420) * g.spacing;
sim.params.linkDistance = (store.graph.linkDistance || 96) * g.spacing;
sim.setData(nodes, edges);
const kind = new Map(edges.map(([a, b, k]) => [a + '\u0000' + b, k]));
for (const l of sim.links) l.kind = kind.get(l.source.id + '\u0000' + l.target.id) || 'link';
counts = { nodes: nodes.filter((n) => !n.ghost).length, links: sim.links.filter((l) => l.kind === 'link').length };
if (!fitted) {
// A head start so the first frame isn't a tangle; then frame it.
sim.tick(Math.min(220, Math.max(60, 120000 / Math.max(1, nodes.length))));
fitted = fit(false);
}
sim.reheat(0.5);
hover = null;
start();
}
/* drawing */
function readColors() {
const css = getComputedStyle(document.documentElement);
const v = (n) => css.getPropertyValue(n).trim();
colors = {
ink: v('--ink'), ink2: v('--ink-2'), ink3: v('--ink-3'),
line: v('--line-2'), accent: v('--blush'), bg: v('--island'), font: v('--f-ui'),
tags: [v('--blush'), v('--sage'), v('--sand'), v('--dusk'), v('--pine')]
};
}
$effect(() => { store.theme; readColors(); draw(); });
const colorForNode = (n) => {
if (n.tags && n.tags.length > 0) {
// Pick a consistent color based on the first tag's name
let hash = 0;
for (let i = 0; i < n.tags[0].length; i++) hash = (hash << 5) - hash + n.tags[0].charCodeAt(i);
return colors.tags[Math.abs(hash) % colors.tags.length];
}
return colors.ink3; // Default
};
const radius = (n) => (n.ghost ? 3.5 : 3.6 + Math.sqrt(n.degree) * 1.7);
const matches = (n) => {
const f = filter.trim().toLowerCase();
if (!f) return true;
if (f.startsWith('#')) return (n.tags || []).some(t => ('#' + t.toLowerCase()).includes(f));
return n.title.toLowerCase().includes(f);
};
function draw() {
if (!canvas || !size.w) return;
const ctx = canvas.getContext('2d');
const { w, h, dpr } = size, k = view.k;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.translate(w / 2 + view.x, h / 2 + view.y);
ctx.scale(k, k);
// Hovering a dot lights it and its neighbours; typing a filter lights the matches.
const near = hover ? new Set([hover.id]) : null;
if (hover) for (const l of sim.links) { if (l.source === hover) near.add(l.target.id); if (l.target === hover) near.add(l.source.id); }
// Hover tooltip (show labels early if hovering over node)
let hoveredTitle = null;
const lit = (n) => (near ? near.has(n.id) : matches(n));
const anyFilter = !!filter.trim() || !!hover;
// links
ctx.lineWidth = 1 / k;
for (const l of sim.links) {
const on = hover && (l.source === hover || l.target === hover);
ctx.globalAlpha = on ? 0.9 : anyFilter ? 0.12 : l.kind === 'tree' ? 0.35 : 0.6;
ctx.strokeStyle = on ? colors.accent : colors.line;
if (l.kind === 'tree') ctx.setLineDash([3 / k, 4 / k]);
ctx.beginPath();
ctx.moveTo(l.source.x, l.source.y);
ctx.lineTo(l.target.x, l.target.y);
ctx.stroke();
if (l.kind === 'tree') ctx.setLineDash([]);
}
// dots
for (const n of sim.nodes) {
const r = radius(n);
const current = n.id === store.currentId;
ctx.globalAlpha = anyFilter && !lit(n) ? 0.2 : 1;
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
if (n.ghost) {
ctx.lineWidth = 1.2 / k;
ctx.strokeStyle = colors.ink3;
ctx.stroke();
} else {
const baseColor = colorForNode(n);
ctx.fillStyle = current || n === hover || (anyFilter && lit(n) && !hover) ? colors.accent : near && near.has(n.id) ? baseColor : baseColor;
ctx.fill();
}
if (current) {
ctx.lineWidth = 2 / k;
ctx.strokeStyle = colors.accent;
ctx.globalAlpha *= 0.35;
ctx.beginPath();
ctx.arc(n.x, n.y, r + 4 / k + 1, 0, Math.PI * 2);
ctx.stroke();
}
}
// labels: fade in as you zoom in; always on for the hovered dot, its neighbours and the open page
const fade = Math.max(0, Math.min(1, (k - 0.55) / 0.45));
ctx.font = `${12 / k}px ${colors.font}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (const n of sim.nodes) {
const always = n === hover || n.id === store.currentId || (near && near.has(n.id)) || (filter.trim() && matches(n));
let a = always ? 1 : fade * (anyFilter ? 0.25 : 0.85);
if (a <= 0.02) continue;
ctx.globalAlpha = a;
ctx.fillStyle = n === hover || always && anyFilter ? colors.ink : colors.ink2;
ctx.fillText(n.title.length > 40 ? n.title.slice(0, 39) + '…' : n.title, n.x, n.y + radius(n) + 4 / k);
}
ctx.globalAlpha = 1;
}
/* animation: run the simulation while it's warm or something is being dragged */
function frame() {
raf = 0;
if (sim.running) sim.tick(sim.nodes.length > 1500 ? 1 : 2);
draw();
if (sim.running || gesture?.kind === 'drag') start();
}
function start() { if (!raf) raf = requestAnimationFrame(frame); }
const redraw = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; draw(); if (sim.running) start(); }); };
/* coordinates */
const toWorld = (sx, sy) => ({ x: (sx - size.w / 2 - view.x) / view.k, y: (sy - size.h / 2 - view.y) / view.k });
function local(e) {
const r = canvas.getBoundingClientRect();
return { sx: e.clientX - r.left, sy: e.clientY - r.top };
}
function nodeAt(sx, sy) {
const p = toWorld(sx, sy);
let best = null, bestD = Infinity;
for (const n of sim.nodes) {
const d = Math.hypot(n.x - p.x, n.y - p.y);
if (d < radius(n) + 6 / view.k && d < bestD) { best = n; bestD = d; }
}
return best;
}
function fit(animate = true) {
const nodes = sim.nodes;
if (!nodes.length || !size.w) return false;
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of nodes) { x0 = Math.min(x0, n.x); y0 = Math.min(y0, n.y); x1 = Math.max(x1, n.x); y1 = Math.max(y1, n.y); }
const pad = 80;
const k = Math.max(0.15, Math.min(1.6, Math.min((size.w - pad * 2) / Math.max(1, x1 - x0), (size.h - pad * 2) / Math.max(1, y1 - y0))));
const target = { k, x: -((x0 + x1) / 2) * k, y: -((y0 + y1) / 2) * k };
if (!animate) { Object.assign(view, target); redraw(); return true; }
const from = { ...view }, t0 = performance.now();
const step = (t) => {
const p = Math.min(1, (t - t0) / 380), e = 1 - Math.pow(1 - p, 3);
view.k = from.k + (target.k - from.k) * e;
view.x = from.x + (target.x - from.x) * e;
view.y = from.y + (target.y - from.y) * e;
draw();
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
return true;
}
/* pointer: drag a dot, pan the background, click to open */
function onDown(e) {
if (e.button !== 0) return;
const { sx, sy } = local(e);
const n = nodeAt(sx, sy);
canvas.setPointerCapture(e.pointerId);
if (n) {
const p = toWorld(sx, sy);
gesture = { kind: 'drag', node: n, sx, sy, moved: false, dx: n.x - p.x, dy: n.y - p.y };
n.fx = n.x;
n.fy = n.y;
sim.alphaTarget = 0.25;
sim.reheat(0.25);
start();
} else {
gesture = { kind: 'pan', sx, sy, x: view.x, y: view.y, moved: false };
}
}
function onMove(e) {
const { sx, sy } = local(e);
if (!gesture) {
const n = nodeAt(sx, sy);
if (n !== hover) { hover = n; canvas.style.cursor = n ? 'pointer' : 'grab'; redraw(); }
return;
}
if (Math.hypot(sx - gesture.sx, sy - gesture.sy) > 3) gesture.moved = true;
if (gesture.kind === 'drag') {
const p = toWorld(sx, sy);
gesture.node.fx = p.x + gesture.dx;
gesture.node.fy = p.y + gesture.dy;
} else {
view.x = gesture.x + sx - gesture.sx;
view.y = gesture.y + sy - gesture.sy;
canvas.style.cursor = 'grabbing';
redraw();
}
}
function onUp(e) {
const g = gesture;
gesture = null;
if (!g) return;
canvas.style.cursor = hover ? 'pointer' : 'grab';
if (g.kind === 'drag') {
g.node.fx = g.node.fy = null;
sim.alphaTarget = 0;
if (!g.moved) open(g.node);
}
}
function open(n) {
if (n.ghost) store.openLink(n.title);
else store.openPage(n.id);
}
function onWheel(e) {
e.preventDefault();
const { sx, sy } = local(e);
const p = toWorld(sx, sy);
const k = Math.max(0.08, Math.min(6, view.k * Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0018))));
// keep the point under the cursor in place
view.x = sx - size.w / 2 - p.x * k;
view.y = sy - size.h / 2 - p.y * k;
view.k = k;
redraw();
}
function onLeave() { if (hover && !gesture) { hover = null; redraw(); } }
function onKey(e) {
if (e.target.closest?.('input')) return;
if (e.key === 'Escape' && !store.paletteOpen && !store.settingsOpen) { e.preventDefault(); store.closeGraph(); }
}
onMount(() => {
const ro = new ResizeObserver(() => {
const r = wrap.getBoundingClientRect();
size = { w: r.width, h: r.height, dpr: window.devicePixelRatio || 1 };
canvas.width = Math.round(r.width * size.dpr);
canvas.height = Math.round(r.height * size.dpr);
canvas.style.width = r.width + 'px';
canvas.style.height = r.height + 'px';
if (!fitted && sim.nodes.length) fitted = fit(false);
draw();
});
ro.observe(wrap);
canvas._graph = { sim, view, size: () => size }; // lets end-to-end tests find dots on the canvas
canvas.addEventListener('wheel', onWheel, { passive: false });
return () => { ro.disconnect(); cancelAnimationFrame(raf); canvas.removeEventListener('wheel', onWheel); };
});
$effect(() => { filter; redraw(); });
const set = (patch) => store.setGraph(patch);
</script>
<svelte:window onkeydown={onKey} />
<div class="graph-view">
<header class="topbar graph-top">
<button class="btn-icon menu-btn" aria-label="Show sidebar" onclick={() => store.toggleSidebar()}><Icon name="panel" /></button>
<button class="crumb graph-back" onclick={() => store.closeGraph()} title="Back to the page (Esc)"><Icon name="chev" class="back" /><span>Back</span></button>
<span class="graph-title"><Icon name="graph" />Graph view</span>
<span class="wc">{counts.nodes} {counts.nodes === 1 ? 'note' : 'notes'} · {counts.links} {counts.links === 1 ? 'link' : 'links'}</span>
<button class="btn-icon" aria-label="Fit to view" title="Fit to view" onclick={() => fit()}><Icon name="target" /></button>
<button class="btn-icon" class:on={panelOpen} aria-label="Graph settings" aria-expanded={panelOpen} title="Graph settings" onclick={() => (panelOpen = !panelOpen)}>
<Icon name="gear" />
</button>
<WindowControls />
</header>
<div class="graph-canvas" bind:this={wrap}>
<canvas
bind:this={canvas}
aria-label="Graph of notes and the links between them"
onpointerdown={onDown}
onpointermove={onMove}
onpointerup={onUp}
onpointercancel={onUp}
onpointerleave={onLeave}
ondblclick={(e) => !nodeAt(local(e).sx, local(e).sy) && fit()}
></canvas>
{#if data && !counts.nodes}
<div class="graph-empty">
{#if store.graph.local}This page has no links yet.{:else}No notes to show.{/if}
</div>
{/if}
{#if panelOpen}
<div class="graph-panel pop" role="group" aria-label="Graph settings">
<div class="gp-search">
<Icon name="search" />
<input bind:value={filter} placeholder="Highlight notes…" spellcheck="false" aria-label="Highlight notes" />
</div>
<div class="seg3 gp-seg" role="radiogroup" aria-label="Which notes">
<button class:on={!store.graph.local} role="radio" aria-checked={!store.graph.local} onclick={() => set({ local: false })}>All notes</button>
<button class:on={store.graph.local} role="radio" aria-checked={store.graph.local} disabled={!store.currentId} onclick={() => set({ local: true })}>This page</button>
</div>
{#if store.graph.local}
<div class="gp-row">
<span>Steps away</span>
<div class="seg3" role="radiogroup" aria-label="Steps away">
{#each [1, 2, 3] as d (d)}
<button class:on={store.graph.depth === d} role="radio" aria-checked={store.graph.depth === d} onclick={() => set({ depth: d })}>{d}</button>
{/each}
</div>
</div>
{/if}
<label class="gp-row"><span>Notes without links</span><button class="tgl" class:on={store.graph.orphans} role="switch" aria-checked={store.graph.orphans} aria-label="Notes without links" onclick={() => set({ orphans: !store.graph.orphans })}><span></span></button></label>
<label class="gp-row"><span>Links to missing pages</span><button class="tgl" class:on={store.graph.ghosts} role="switch" aria-checked={store.graph.ghosts} aria-label="Links to missing pages" onclick={() => set({ ghosts: !store.graph.ghosts })}><span></span></button></label>
<label class="gp-row"><span>Page hierarchy</span><button class="tgl" class:on={store.graph.folders} role="switch" aria-checked={store.graph.folders} aria-label="Page hierarchy" onclick={() => set({ folders: !store.graph.folders })}><span></span></button></label>
<div class="gp-row">
<span>Spacing</span>
<input
type="range"
min="0.5"
max="2"
step="0.1"
value={store.graph.spacing}
aria-label="Spacing"
oninput={(e) => {
set({ spacing: +e.currentTarget.value });
sim.params.repel = (store.graph.repel || 420) * (+e.currentTarget.value);
sim.params.linkDistance = (store.graph.linkDistance || 96) * (+e.currentTarget.value);
sim.reheat(0.25); start();
}}
/>
</div>
<div class="gp-row">
<span>Repulsion</span>
<input
type="range"
min="100"
max="1500"
step="100"
value={store.graph.repel || 420}
aria-label="Repulsion"
oninput={(e) => { set({ repel: +e.currentTarget.value }); sim.params.repel = (+e.currentTarget.value) * store.graph.spacing; sim.reheat(0.25); start(); }}
/>
</div>
<div class="gp-row">
<span>Link Distance</span>
<input
type="range"
min="20"
max="300"
step="10"
value={store.graph.linkDistance || 96}
aria-label="Link Distance"
oninput={(e) => { set({ linkDistance: +e.currentTarget.value }); sim.params.linkDistance = (+e.currentTarget.value) * store.graph.spacing; sim.reheat(0.25); start(); }}
/>
</div>
</div>
{/if}
</div>
</div>