diff --git a/src/lib/components/GraphView.svelte b/src/lib/components/GraphView.svelte new file mode 100644 index 0000000..cba0943 --- /dev/null +++ b/src/lib/components/GraphView.svelte @@ -0,0 +1,423 @@ + + + + +
+
+ + + Graph view + {counts.nodes} {counts.nodes === 1 ? 'note' : 'notes'} · {counts.links} {counts.links === 1 ? 'link' : 'links'} + + + +
+ +
+ !nodeAt(local(e).sx, local(e).sy) && fit()} + > + + {#if data && !counts.nodes} +
+ {#if store.graph.local}This page has no links yet.{:else}No notes to show.{/if} +
+ {/if} + + {#if panelOpen} +
+ +
+ + +
+ {#if store.graph.local} +
+ Steps away +
+ {#each [1, 2, 3] as d (d)} + + {/each} +
+
+ {/if} + + + +
+ Spacing + { + 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(); + }} + /> +
+
+ Repulsion + { set({ repel: +e.currentTarget.value }); sim.params.repel = (+e.currentTarget.value) * store.graph.spacing; sim.reheat(0.25); start(); }} + /> +
+
+ Link Distance + { set({ linkDistance: +e.currentTarget.value }); sim.params.linkDistance = (+e.currentTarget.value) * store.graph.spacing; sim.reheat(0.25); start(); }} + /> +
+
+ {/if} +
+
diff --git a/src/lib/graph.js b/src/lib/graph.js new file mode 100644 index 0000000..a01c8dd --- /dev/null +++ b/src/lib/graph.js @@ -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; +} diff --git a/test/e2e-graph.test.cjs b/test/e2e-graph.test.cjs new file mode 100644 index 0000000..760f501 --- /dev/null +++ b/test/e2e-graph.test.cjs @@ -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); +});