Design gallery
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
Drag a note to pull its links.

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]! })),
  };
}