feat: Graph View dynamic coloring and repulsion sliders

This commit is contained in:
Your Name committed 2026-10-09 23:01:40 +02:00
1 parent 1416f2e483
commit 81925934e3
3 files changed
+665

No files matched your search

+423
View File
@@ -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>
+187
View File
@@ -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;
}