Backgrounds
Note graph
Notes as dots coloured by folder and links as lines, the way Obsidian draws a vault. Every note drifts, hovering one lights its links, and dragging one pulls its neighbours along.
- Projects
- Ideas
- Reading
- Daily
Code 2 files
src/gallery/note-graph/NoteGraph.astroAstro171 lines
---
// A notes graph drawn the way Obsidian's graph view draws one: notes as dots coloured by
// folder, links as lines. Each note drifts around its home, links act as springs, and
// hovering a note lights it and its links. Drag a note to pull its neighbours.
// Without JavaScript it is a still picture. Placeholder graph from graph.ts: feed in real
// nodes and links in the same shape.
import { makeGraph } from './graph';
const graph = makeGraph();
const { width, height, nodes, edges, folders } = graph;
// One colour per folder, read on the dark panel.
const tones = ['#e6efe7', '#f0b67f', '#6fd3c1', '#f2d479'];
---
<figure class="graph" data-graph={JSON.stringify(graph)}>
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`A sample graph of ${nodes.length} notes joined by ${edges.length} links, in ${folders.length} folders`}>
<g class="graph-links">
{edges.map(([a, b]) => <line x1={nodes[a]!.x} y1={nodes[a]!.y} x2={nodes[b]!.x} y2={nodes[b]!.y} />)}
</g>
<g class="graph-nodes">
{nodes.map((n) => <circle cx={n.x} cy={n.y} r={n.r} fill={tones[n.g % tones.length]} />)}
</g>
</svg>
<figcaption>
<ul class="graph-key">
{folders.map((f, g) => <li><i style={`background:${tones[g % tones.length]}`}></i>{f}</li>)}
</ul>
<span class="graph-hint">Drag a note to pull its links.</span>
</figcaption>
</figure>
<style>
.graph { position: absolute; inset: 0; margin: 0; padding: 4cqh 4cqw; display: flex; flex-direction: column; background: #17211c; container-type: size; }
.graph svg { flex: 1; min-height: 0; width: 100%; display: block; }
.graph-links line { stroke: #4a6a5a; stroke-width: 0.9; transition: stroke 160ms ease, opacity 160ms ease; }
.graph-nodes circle { stroke: #17211c; stroke-width: 1.5; cursor: grab; touch-action: none; transform-box: fill-box; transform-origin: center; transition: opacity 160ms ease, transform 200ms cubic-bezier(0.23, 1, 0.32, 1); }
@media (hover: hover) and (pointer: fine) { .graph-nodes circle:hover { transform: scale(1.5); } }
.graph.dragging, .graph.dragging circle { cursor: grabbing; }
.graph svg:global(.focus) .graph-nodes circle:not(:global(.hl)) { opacity: 0.22; }
.graph svg:global(.focus) .graph-links line:not(:global(.hl)) { opacity: 0.1; }
.graph svg:global(.focus) .graph-links line:global(.hl) { stroke: #cfe6d6; stroke-width: 1.3; }
.graph svg:global(.focus) .graph-nodes circle:global(.hl) { transform: scale(1.25); }
figcaption { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.5rem 1rem; padding-top: 0.5rem; color: #a9bcb0; font-size: 0.8125rem; }
.graph-key { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0; padding: 0; list-style: none; color: #dbe6de; }
.graph-key li { display: inline-flex; align-items: center; gap: 7px; }
.graph-key i { width: 10px; height: 10px; border-radius: 50%; }
.graph-hint { color: #cfe6d6; }
@container (max-height: 300px) { figcaption { display: none; } }
@media (prefers-reduced-motion: reduce) { .graph-links line, .graph-nodes circle { transition: none; } }
</style>
<script>
import type { Graph } from './graph';
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.querySelectorAll<HTMLElement>('[data-graph]').forEach((fig) => {
const graph = JSON.parse(fig.dataset.graph!) as Graph;
const svg = fig.querySelector('svg')!;
const dots = [...svg.querySelectorAll<SVGCircleElement>('.graph-nodes circle')];
const lines = [...svg.querySelectorAll<SVGLineElement>('.graph-links line')];
const { nodes, edges } = graph;
// Home is the computed layout; each note floats around its home on its own slow orbit.
const home = nodes.map((n) => ({ x: n.x, y: n.y }));
const pos = nodes.map((n) => ({ x: n.x, y: n.y, vx: 0, vy: 0 }));
const phase = nodes.map((_, i) => i * 2.399);
const rest = edges.map(([a, b]) => Math.hypot(home[a]!.x - home[b]!.x, home[a]!.y - home[b]!.y));
const near: number[][] = nodes.map(() => []);
edges.forEach(([a, b], k) => { near[a]!.push(k); near[b]!.push(k); });
let dragged = -1;
let visible = false;
let raf = 0;
let last = 0;
const draw = () => {
dots.forEach((c, i) => { c.setAttribute('cx', pos[i]!.x.toFixed(1)); c.setAttribute('cy', pos[i]!.y.toFixed(1)); });
lines.forEach((l, k) => {
const [a, b] = edges[k]!;
l.setAttribute('x1', pos[a]!.x.toFixed(1)); l.setAttribute('y1', pos[a]!.y.toFixed(1));
l.setAttribute('x2', pos[b]!.x.toFixed(1)); l.setAttribute('y2', pos[b]!.y.toFixed(1));
});
};
const step = (t: number) => {
const dt = Math.min(2, (t - (last || t)) / 16.7);
last = t;
const s = t / 1000;
// Pull toward a drifting home, so the whole graph breathes.
pos.forEach((p, i) => {
if (i === dragged) return;
const drift = reduce ? 0 : 4.5;
const hx = home[i]!.x + Math.sin(s * 0.8 + phase[i]!) * drift;
const hy = home[i]!.y + Math.cos(s * 0.7 + phase[i]!) * drift;
p.vx += (hx - p.x) * 0.012 * dt;
p.vy += (hy - p.y) * 0.012 * dt;
});
// Links act as springs, so a dragged note tugs its neighbours.
edges.forEach(([a, b], k) => {
const pa = pos[a]!, pb = pos[b]!;
const dx = pb.x - pa.x, dy = pb.y - pa.y;
const d = Math.hypot(dx, dy) || 0.01;
const f = (d - rest[k]!) * 0.02 * dt;
const fx = (dx / d) * f, fy = (dy / d) * f;
if (a !== dragged) { pa.vx += fx; pa.vy += fy; }
if (b !== dragged) { pb.vx -= fx; pb.vy -= fy; }
});
pos.forEach((p, i) => {
if (i === dragged) return;
p.vx *= 0.86; p.vy *= 0.86;
p.x += p.vx * dt; p.y += p.vy * dt;
});
draw();
const moving = pos.some((p) => Math.abs(p.vx) + Math.abs(p.vy) > 0.02);
raf = (visible && !document.hidden && (!reduce || dragged >= 0 || moving)) ? requestAnimationFrame(step) : 0;
};
const start = () => { if (!raf && visible && !document.hidden) { last = 0; raf = requestAnimationFrame(step); } };
new IntersectionObserver(([e]) => { visible = !!e?.isIntersecting; if (visible) start(); }).observe(fig);
document.addEventListener('visibilitychange', start);
// Hover or drag: light the note and its links, dim the rest.
const focus = (i: number) => {
svg.classList.toggle('focus', i >= 0);
dots.forEach((c) => c.classList.remove('hl'));
lines.forEach((l) => l.classList.remove('hl'));
if (i < 0) return;
dots[i]!.classList.add('hl');
near[i]!.forEach((k) => {
lines[k]!.classList.add('hl');
const [a, b] = edges[k]!;
dots[a === i ? b : a]!.classList.add('hl');
});
};
dots.forEach((c, i) => {
c.addEventListener('pointerenter', () => { if (dragged < 0) focus(i); });
c.addEventListener('pointerleave', () => { if (dragged < 0) focus(-1); });
});
const toSvg = (e: PointerEvent) => {
const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(svg.getScreenCTM()!.inverse());
return { x: pt.x, y: pt.y };
};
svg.addEventListener('pointerdown', (e) => {
const i = dots.indexOf(e.target as SVGCircleElement);
if (i < 0) return;
e.preventDefault();
dragged = i;
svg.setPointerCapture(e.pointerId);
fig.classList.add('dragging');
focus(i);
start();
});
svg.addEventListener('pointermove', (e) => {
if (dragged < 0) return;
const p = toSvg(e);
pos[dragged]!.x = p.x; pos[dragged]!.y = p.y;
pos[dragged]!.vx = 0; pos[dragged]!.vy = 0;
if (reduce) draw();
start();
});
const drop = () => {
if (dragged < 0) return;
dragged = -1;
fig.classList.remove('dragging');
focus(-1);
start();
};
svg.addEventListener('pointerup', drop);
svg.addEventListener('pointercancel', drop);
});
</script>src/gallery/note-graph/graph.tsTypeScript72 lines
// A made-up notes graph in the shape Obsidian's graph view draws: a few folders, each a
// loose cluster, with most links inside a folder and a few across. Seeded, so every build
// draws the same picture, and laid out once at build time with a small force simulation.
export interface Graph {
width: number; height: number;
nodes: { x: number; y: number; r: number; g: number }[];
edges: [number, number][];
folders: string[];
}
export function makeGraph(seed = 7): Graph {
let s = seed;
const rand = () => ((s = (s * 16807) % 2147483647) - 1) / 2147483646;
const width = 640, height = 400; // the space the layout runs in
const folders = ['Projects', 'Ideas', 'Reading', 'Daily'];
const sizes = [22, 16, 18, 12];
const centres = [[230, 190], [430, 140], [440, 290], [150, 320]];
const g: number[] = [];
sizes.forEach((n, f) => { for (let i = 0; i < n; i++) g.push(f); });
const pos = g.map((f) => ({ x: centres[f]![0]! + (rand() - 0.5) * 140, y: centres[f]![1]! + (rand() - 0.5) * 110, vx: 0, vy: 0 }));
// Each note links to one or two earlier notes in its folder; one in six also links across.
const edges: [number, number][] = [];
const seen = new Set<string>();
const add = (a: number, b: number) => {
const k = a < b ? `${a}-${b}` : `${b}-${a}`;
if (a !== b && !seen.has(k)) { seen.add(k); edges.push([a, b]); }
};
g.forEach((f, i) => {
const mates = g.map((h, j) => (h === f && j < i ? j : -1)).filter((j) => j >= 0);
if (mates.length) add(i, mates[Math.floor(rand() * mates.length)]!);
if (mates.length > 2 && rand() < 0.6) add(i, mates[Math.floor(rand() * mates.length)]!);
if (rand() < 0.17) add(i, Math.floor(rand() * g.length));
});
// Springs on links, repulsion between notes, a pull toward each folder's centre.
for (let step = 0; step < 300; step++) {
for (let a = 0; a < pos.length; a++) for (let b = a + 1; b < pos.length; b++) {
const dx = pos[b]!.x - pos[a]!.x, dy = pos[b]!.y - pos[a]!.y;
const d2 = Math.max(dx * dx + dy * dy, 25), f = 260 / d2;
const d = Math.sqrt(d2);
pos[a]!.vx -= (dx / d) * f; pos[a]!.vy -= (dy / d) * f;
pos[b]!.vx += (dx / d) * f; pos[b]!.vy += (dy / d) * f;
}
for (const [a, b] of edges) {
const dx = pos[b]!.x - pos[a]!.x, dy = pos[b]!.y - pos[a]!.y;
const d = Math.hypot(dx, dy) || 1, f = (d - 34) * 0.02;
pos[a]!.vx += (dx / d) * f; pos[a]!.vy += (dy / d) * f;
pos[b]!.vx -= (dx / d) * f; pos[b]!.vy -= (dy / d) * f;
}
pos.forEach((p, i) => {
const [cx, cy] = centres[g[i]!]!;
p.vx += (cx! - p.x) * 0.004; p.vy += (cy! - p.y) * 0.004;
p.vx *= 0.82; p.vy *= 0.82;
p.x = Math.min(width - 16, Math.max(16, p.x + p.vx));
p.y = Math.min(height - 16, Math.max(16, p.y + p.vy));
});
}
const degree = g.map(() => 0);
edges.forEach(([a, b]) => { degree[a]!++; degree[b]!++; });
// Crop the drawing to the notes, with room for them to drift and be dragged.
const pad = 24;
const minX = Math.min(...pos.map((p) => p.x)) - pad, minY = Math.min(...pos.map((p) => p.y)) - pad;
pos.forEach((p) => { p.x -= minX; p.y -= minY; });
return {
width: Math.round(Math.max(...pos.map((p) => p.x)) + pad), height: Math.round(Math.max(...pos.map((p) => p.y)) + pad), folders, edges,
nodes: pos.map((p, i) => ({ x: +p.x.toFixed(1), y: +p.y.toFixed(1), r: +(3 + Math.sqrt(degree[i]!) * 1.6).toFixed(1), g: g[i]! })),
};
}