424 lines
17 KiB
Svelte
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>
|