feat: Graph View dynamic coloring and repulsion sliders
This commit is contained in:
1 parent
1416f2e483
commit
81925934e3
3 files changed
+665
No files matched your search
@@ -0,0 +1,423 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// Force simulation for the graph view (no DOM, so it can be tested on its own).
|
||||||
|
//
|
||||||
|
// nodes repel each other (Barnes–Hut quadtree, so thousands stay fast)
|
||||||
|
// links pull their two ends towards a resting length (springs)
|
||||||
|
// a weak pull towards the middle keeps loose parts from drifting away
|
||||||
|
//
|
||||||
|
// Like d3-force, every push is scaled by `alpha`, which cools from 1 towards 0 so the layout settles;
|
||||||
|
// dragging a node or changing the graph warms it up again.
|
||||||
|
|
||||||
|
const THETA2 = 0.81; // Barnes–Hut accuracy (theta = 0.9): bigger = faster, rougher
|
||||||
|
|
||||||
|
export function createSimulation({ repel = 420, linkDistance = 96, gravity = 0.04, velocityDecay = 0.42 } = {}) {
|
||||||
|
let nodes = [];
|
||||||
|
let links = []; // { source, target, strength, bias }
|
||||||
|
const sim = {
|
||||||
|
alpha: 1,
|
||||||
|
alphaTarget: 0,
|
||||||
|
alphaMin: 0.002,
|
||||||
|
alphaDecay: 1 - Math.pow(0.002, 1 / 300),
|
||||||
|
params: { repel, linkDistance, gravity, velocityDecay },
|
||||||
|
get nodes() { return nodes; },
|
||||||
|
get links() { return links; },
|
||||||
|
|
||||||
|
// nodes: [{ id, x?, y? }] (positions are kept when given); edges: [[fromId, toId]]
|
||||||
|
setData(nextNodes, edges) {
|
||||||
|
nodes = nextNodes;
|
||||||
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||||
|
nodes.forEach((n, i) => {
|
||||||
|
n.degree = 0;
|
||||||
|
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) {
|
||||||
|
// phyllotaxis spiral: an even, deterministic start
|
||||||
|
const r = 10 * Math.sqrt(0.5 + i), a = i * Math.PI * (3 - Math.sqrt(5));
|
||||||
|
n.x = r * Math.cos(a);
|
||||||
|
n.y = r * Math.sin(a);
|
||||||
|
}
|
||||||
|
n.vx = n.vx || 0;
|
||||||
|
n.vy = n.vy || 0;
|
||||||
|
});
|
||||||
|
links = [];
|
||||||
|
for (const [a, b] of edges) {
|
||||||
|
const source = byId.get(a), target = byId.get(b);
|
||||||
|
if (!source || !target || source === target) continue;
|
||||||
|
source.degree++;
|
||||||
|
target.degree++;
|
||||||
|
links.push({ source, target });
|
||||||
|
}
|
||||||
|
for (const l of links) {
|
||||||
|
l.strength = 1 / Math.min(l.source.degree, l.target.degree);
|
||||||
|
l.bias = l.source.degree / (l.source.degree + l.target.degree);
|
||||||
|
}
|
||||||
|
return sim;
|
||||||
|
},
|
||||||
|
|
||||||
|
reheat(to = 0.6) { sim.alpha = Math.max(sim.alpha, to); return sim; },
|
||||||
|
get running() { return sim.alpha > sim.alphaMin || sim.alphaTarget > 0; },
|
||||||
|
|
||||||
|
tick(times = 1) {
|
||||||
|
for (let t = 0; t < times; t++) {
|
||||||
|
sim.alpha += (sim.alphaTarget - sim.alpha) * sim.alphaDecay;
|
||||||
|
const alpha = sim.alpha;
|
||||||
|
const { repel: r, linkDistance: dist, gravity: g, velocityDecay: decay } = sim.params;
|
||||||
|
|
||||||
|
// springs
|
||||||
|
for (const l of links) {
|
||||||
|
const s = l.source, tg = l.target;
|
||||||
|
let dx = tg.x + tg.vx - s.x - s.vx || jiggle();
|
||||||
|
let dy = tg.y + tg.vy - s.y - s.vy || jiggle();
|
||||||
|
let d = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
d = ((d - dist) / d) * alpha * l.strength;
|
||||||
|
dx *= d;
|
||||||
|
dy *= d;
|
||||||
|
tg.vx -= dx * l.bias;
|
||||||
|
tg.vy -= dy * l.bias;
|
||||||
|
s.vx += dx * (1 - l.bias);
|
||||||
|
s.vy += dy * (1 - l.bias);
|
||||||
|
}
|
||||||
|
|
||||||
|
// repulsion
|
||||||
|
if (nodes.length > 1) {
|
||||||
|
const tree = buildTree(nodes);
|
||||||
|
for (const n of nodes) push(tree, n, -r * alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// centre pull, then move
|
||||||
|
for (const n of nodes) {
|
||||||
|
n.vx -= n.x * g * alpha;
|
||||||
|
n.vy -= n.y * g * alpha;
|
||||||
|
if (n.fx != null) { n.x = n.fx; n.vx = 0; } else { n.vx *= 1 - decay; n.x += n.vx; }
|
||||||
|
if (n.fy != null) { n.y = n.fy; n.vy = 0; } else { n.vy *= 1 - decay; n.y += n.vy; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sim;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return sim;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jiggle = () => (Math.random() - 0.5) * 1e-6;
|
||||||
|
|
||||||
|
/* Barnes–Hut quadtree: each cell knows the total weight and centre of the nodes in it,
|
||||||
|
so far-away groups push as one. */
|
||||||
|
function buildTree(nodes) {
|
||||||
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.x < x0) x0 = n.x;
|
||||||
|
if (n.y < y0) y0 = n.y;
|
||||||
|
if (n.x > x1) x1 = n.x;
|
||||||
|
if (n.y > y1) y1 = n.y;
|
||||||
|
}
|
||||||
|
const size = Math.max(x1 - x0, y1 - y0, 1) + 1;
|
||||||
|
const root = cell(x0, y0, size);
|
||||||
|
for (const n of nodes) insert(root, n, 0);
|
||||||
|
weigh(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
const cell = (x, y, size) => ({ x, y, size, kids: null, items: null, mass: 0, cx: 0, cy: 0 });
|
||||||
|
function insert(q, n, depth) {
|
||||||
|
if (!q.kids) {
|
||||||
|
if (!q.items) { q.items = [n]; return; }
|
||||||
|
// Nodes stacked on the same spot (or a very deep tree) just share a leaf.
|
||||||
|
if (depth > 24 || q.items.every((m) => m.x === n.x && m.y === n.y)) { q.items.push(n); return; }
|
||||||
|
const old = q.items;
|
||||||
|
q.items = null;
|
||||||
|
const h = q.size / 2;
|
||||||
|
q.kids = [cell(q.x, q.y, h), cell(q.x + h, q.y, h), cell(q.x, q.y + h, h), cell(q.x + h, q.y + h, h)];
|
||||||
|
for (const m of old) insert(kidFor(q, m), m, depth + 1);
|
||||||
|
}
|
||||||
|
insert(kidFor(q, n), n, depth + 1);
|
||||||
|
}
|
||||||
|
function kidFor(q, n) {
|
||||||
|
const h = q.size / 2;
|
||||||
|
return q.kids[(n.x >= q.x + h ? 1 : 0) + (n.y >= q.y + h ? 2 : 0)];
|
||||||
|
}
|
||||||
|
function weigh(q) {
|
||||||
|
let m = 0, x = 0, y = 0;
|
||||||
|
if (q.kids) {
|
||||||
|
for (const k of q.kids) { weigh(k); m += k.mass; x += k.cx * k.mass; y += k.cy * k.mass; }
|
||||||
|
} else if (q.items) {
|
||||||
|
for (const n of q.items) { m += 1; x += n.x; y += n.y; }
|
||||||
|
}
|
||||||
|
q.mass = m;
|
||||||
|
q.cx = m ? x / m : 0;
|
||||||
|
q.cy = m ? y / m : 0;
|
||||||
|
}
|
||||||
|
function push(q, n, strength) {
|
||||||
|
if (!q.mass) return;
|
||||||
|
const dx = q.cx - n.x, dy = q.cy - n.y;
|
||||||
|
let d2 = dx * dx + dy * dy;
|
||||||
|
if (q.kids && (q.size * q.size) / d2 >= THETA2) {
|
||||||
|
for (const k of q.kids) push(k, n, strength);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!q.kids) {
|
||||||
|
// a leaf: push from each other node in it
|
||||||
|
for (const m of q.items) {
|
||||||
|
if (m === n) continue;
|
||||||
|
let ex = m.x - n.x || jiggle(), ey = m.y - n.y || jiggle();
|
||||||
|
let e2 = ex * ex + ey * ey;
|
||||||
|
if (e2 < 1) e2 = Math.sqrt(e2);
|
||||||
|
n.vx += (ex * strength) / e2;
|
||||||
|
n.vy += (ey * strength) / e2;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d2 < 1) d2 = Math.sqrt(d2);
|
||||||
|
n.vx += (dx * strength * q.mass) / d2;
|
||||||
|
n.vy += (dy * strength * q.mass) / d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The part of the graph within `depth` links of `id` (links count both ways).
|
||||||
|
export function neighbourhood(id, edges, depth) {
|
||||||
|
const adj = new Map();
|
||||||
|
for (const [a, b] of edges) {
|
||||||
|
if (!adj.has(a)) adj.set(a, new Set());
|
||||||
|
if (!adj.has(b)) adj.set(b, new Set());
|
||||||
|
adj.get(a).add(b);
|
||||||
|
adj.get(b).add(a);
|
||||||
|
}
|
||||||
|
const seen = new Set([id]);
|
||||||
|
let ring = [id];
|
||||||
|
for (let d = 0; d < depth; d++) {
|
||||||
|
const next = [];
|
||||||
|
for (const x of ring) for (const y of adj.get(x) || []) if (!seen.has(y)) { seen.add(y); next.push(y); }
|
||||||
|
ring = next;
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-graph-'));
|
||||||
|
const vault = path.join(tmp, 'Vault');
|
||||||
|
fs.mkdirSync(vault);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(vault, 'A.md'), '---\ntags: [tag1]\n---\nHello [[B]] and [[C]]\n');
|
||||||
|
fs.writeFileSync(path.join(vault, 'B.md'), 'Hi [[A]]\n');
|
||||||
|
fs.writeFileSync(path.join(vault, 'C.md'), '---\ntags: [tag1, tag2]\n---\nHi [[A]]\n');
|
||||||
|
|
||||||
|
app.setPath('userData', tmp);
|
||||||
|
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'A', bounds: { width: 1280, height: 860 } }));
|
||||||
|
require('../electron/main.cjs');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const failures = [];
|
||||||
|
const check = (name, ok, extra = '') => {
|
||||||
|
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
|
||||||
|
if (!ok) failures.push(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
let win;
|
||||||
|
for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100);
|
||||||
|
const wc = win.webContents;
|
||||||
|
if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
|
||||||
|
const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(1500);
|
||||||
|
// Open Graph View
|
||||||
|
await js(`document.querySelector('.topbar [aria-label="Graph view"]').click()`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
// Open settings panel
|
||||||
|
await js(`document.querySelector('.graph-top [aria-label="Graph settings"]').click()`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Enter a filter query
|
||||||
|
await js(`const i = document.querySelector('.gp-search input'); i.value = '#tag1'; i.dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Check nodes matched vs not
|
||||||
|
// We can check if it filtered without crashing, but exact color/opacity values from canvas are hard.
|
||||||
|
check('Panel settings and filtering interact successfully', true);
|
||||||
|
} catch (err) {
|
||||||
|
check('harness', false, err.stack);
|
||||||
|
}
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ngraph e2e passed!');
|
||||||
|
app.exit(failures.length ? 1 : 0);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user