benjivers/notes Public Public: anyone can read this; nobody can change it.

Files shared
373 lines · 11.3 KB · ~3.3k tok Raw History
1
// The graph: every note a point, every [[link]] a line, laid out by a
2
// small force simulation — notes push each other apart, links pull their
3
// ends together, and the whole drifts to the middle. Drag a note to move
4
// it, drag the space to pan, scroll or pinch to zoom, click to open.
5
// A canvas with data-graph="local" and data-focus="<id>" draws only that
6
// note and its neighbours. Colours are the tokens', read from the page.
7
8
const charts = new WeakSet();
9
10
function data() {
11
  try {
12
    return JSON.parse(document.getElementById("graph")?.textContent ?? "");
13
  } catch {
14
    return { nodes: [], edges: [] };
15
  }
16
}
17
18
function colours() {
19
  const css = getComputedStyle(document.documentElement);
20
  const v = (name) => css.getPropertyValue(name).trim();
21
  return {
22
    node: v("--color-graph-node"),
23
    edge: v("--color-graph-edge"),
24
    accent: v("--color-accent"),
25
    label: v("--color-fg"),
26
    quiet: v("--color-muted"),
27
    bg: v("--color-bg"),
28
  };
29
}
30
31
/** A small deterministic spread, so the same vault opens the same way. */
32
const jitter = (id, k) => {
33
  const x = Math.sin(id * 12.9898 + k * 78.233) * 43758.5453;
34
  return x - Math.floor(x) - 0.5;
35
};
36
37
function chart(canvas) {
38
  if (charts.has(canvas)) return;
39
  charts.add(canvas);
40
  const all = data();
41
  const focus = canvas.dataset.focus ? Number(canvas.dataset.focus) : null;
42
  let nodes = all.nodes;
43
  let edges = all.edges;
44
  if (focus !== null) {
45
    const near = new Set([focus]);
46
    for (const [a, b] of edges) {
47
      if (a === focus) near.add(b);
48
      if (b === focus) near.add(a);
49
    }
50
    nodes = nodes.filter((n) => near.has(n.id));
51
    edges = edges.filter(([a, b]) => near.has(a) && near.has(b));
52
  }
53
  const local = focus !== null;
54
  const byId = new Map();
55
  const ps = nodes.map((n, i) => {
56
    const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
57
    const r = 40 + Math.sqrt(nodes.length) * 18;
58
    const p = {
59
      ...n,
60
      x: n.id === focus ? 0 : Math.cos(angle) * r + jitter(n.id, 1) * 30,
61
      y: n.id === focus ? 0 : Math.sin(angle) * r + jitter(n.id, 2) * 30,
62
      vx: 0,
63
      vy: 0,
64
      r: (local ? 4 : 3) + Math.sqrt(n.links) * 1.7,
65
      fixed: false,
66
    };
67
    byId.set(n.id, p);
68
    return p;
69
  });
70
  const links = edges.map(([a, b]) => [byId.get(a), byId.get(b)]).filter(([a, b]) => a && b);
71
  const neighbours = new Map(ps.map((p) => [p.id, new Set()]));
72
  for (const [a, b] of links) {
73
    neighbours.get(a.id).add(b.id);
74
    neighbours.get(b.id).add(a.id);
75
  }
76
77
  const ctx = canvas.getContext("2d");
78
  let c = colours();
79
  let w = 0;
80
  let h = 0;
81
  let view = { x: 0, y: 0, k: local ? 1.1 : 1 };
82
  let hover = null;
83
  let alpha = 1;
84
  let running = false;
85
  const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
86
87
  function size() {
88
    const r = canvas.getBoundingClientRect();
89
    const dpr = devicePixelRatio || 1;
90
    w = r.width;
91
    h = r.height;
92
    canvas.width = Math.round(w * dpr);
93
    canvas.height = Math.round(h * dpr);
94
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
95
    draw();
96
  }
97
98
  function tick() {
99
    const n = ps.length;
100
    const repel = local ? 900 : 2600 + ps.length * 40;
101
    for (let i = 0; i < n; i++) {
102
      const a = ps[i];
103
      for (let j = i + 1; j < n; j++) {
104
        const b = ps[j];
105
        let dx = a.x - b.x;
106
        let dy = a.y - b.y;
107
        let d2 = dx * dx + dy * dy;
108
        if (d2 < 0.01) {
109
          dx = jitter(a.id, j);
110
          dy = jitter(b.id, i);
111
          d2 = 0.01;
112
        }
113
        if (d2 > 1_000_000) continue;
114
        const f = (repel * alpha) / d2;
115
        const d = Math.sqrt(d2);
116
        a.vx += (dx / d) * f;
117
        a.vy += (dy / d) * f;
118
        b.vx -= (dx / d) * f;
119
        b.vy -= (dy / d) * f;
120
      }
121
    }
122
    const rest = local ? 70 : 95;
123
    for (const [a, b] of links) {
124
      const dx = b.x - a.x;
125
      const dy = b.y - a.y;
126
      const d = Math.sqrt(dx * dx + dy * dy) || 1;
127
      const f = (d - rest) * 0.04 * alpha;
128
      a.vx += (dx / d) * f;
129
      a.vy += (dy / d) * f;
130
      b.vx -= (dx / d) * f;
131
      b.vy -= (dy / d) * f;
132
    }
133
    for (const p of ps) {
134
      p.vx -= p.x * 0.006 * alpha;
135
      p.vy -= p.y * 0.006 * alpha;
136
      if (p.fixed || (local && p.id === focus)) {
137
        p.vx = p.vy = 0;
138
        if (local && p.id === focus && !p.fixed) p.x = p.y = 0;
139
        continue;
140
      }
141
      p.vx *= 0.82;
142
      p.vy *= 0.82;
143
      p.x += Math.max(-30, Math.min(30, p.vx));
144
      p.y += Math.max(-30, Math.min(30, p.vy));
145
    }
146
    alpha = Math.max(0, alpha * 0.985 - 0.0005);
147
  }
148
149
  // Once the layout has mostly settled, frame it: the whole vault in view,
150
  // never blown up past a comfortable size. Only once — after that the
151
  // view is yours.
152
  let framed = false;
153
  function fit() {
154
    framed = true;
155
    if (ps.length === 0 || w === 0) return;
156
    let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
157
    for (const p of ps) {
158
      x0 = Math.min(x0, p.x - p.r);
159
      y0 = Math.min(y0, p.y - p.r);
160
      x1 = Math.max(x1, p.x + p.r);
161
      y1 = Math.max(y1, p.y + p.r + 18);
162
    }
163
    const k = Math.min((w * 0.8) / Math.max(1, x1 - x0), (h * 0.75) / Math.max(1, y1 - y0), local ? 1.6 : 1.5);
164
    view = { x: -(x0 + x1) / 2, y: -(y0 + y1) / 2, k: Math.max(0.25, k) };
165
  }
166
167
  function loop() {
168
    if (!framed && alpha < 0.3 && !drag) fit();
169
    if (alpha <= 0.002) {
170
      running = false;
171
      draw();
172
      return;
173
    }
174
    tick();
175
    draw();
176
    requestAnimationFrame(loop);
177
  }
178
  function heat(to = 0.6) {
179
    alpha = Math.max(alpha, to);
180
    if (still) {
181
      for (let i = 0; i < 400 && alpha > 0.002; i++) tick();
182
      if (!framed) fit();
183
      draw();
184
      return;
185
    }
186
    if (!running) {
187
      running = true;
188
      requestAnimationFrame(loop);
189
    }
190
  }
191
192
  const screen = (p) => ({ x: w / 2 + (p.x + view.x) * view.k, y: h / 2 + (p.y + view.y) * view.k });
193
  const world = (x, y) => ({ x: (x - w / 2) / view.k - view.x, y: (y - h / 2) / view.k - view.y });
194
195
  function draw() {
196
    ctx.clearRect(0, 0, w, h);
197
    const lit = hover ?? (local ? byId.get(focus) : null);
198
    const near = lit ? neighbours.get(lit.id) : null;
199
    ctx.lineWidth = 1;
200
    for (const [a, b] of links) {
201
      const on = lit && (a === lit || b === lit);
202
      ctx.strokeStyle = on ? c.accent : c.edge;
203
      ctx.globalAlpha = lit && !on ? 0.35 : on ? 0.9 : 0.75;
204
      const p = screen(a);
205
      const q = screen(b);
206
      ctx.beginPath();
207
      ctx.moveTo(p.x, p.y);
208
      ctx.lineTo(q.x, q.y);
209
      ctx.stroke();
210
    }
211
    // Labels: all of them when few or zoomed in; otherwise the hubs, and
212
    // whatever the pointer is near, so a big vault reads as a map, not a smudge.
213
    const all = local || ps.length <= 24 || view.k > 1.8;
214
    const hub = Math.max(5, [...ps].map((p) => p.links).sort((a, b) => b - a)[Math.min(9, ps.length - 1)] ?? 0);
215
    ctx.font = `${local ? 11 : 12}px system-ui, -apple-system, sans-serif`;
216
    ctx.textAlign = "center";
217
    ctx.textBaseline = "top";
218
    for (const p of ps) {
219
      const s = screen(p);
220
      const r = p.r * Math.sqrt(view.k);
221
      const on = lit && (p === lit || near.has(p.id));
222
      ctx.globalAlpha = lit && !on ? 0.35 : 1;
223
      ctx.fillStyle = p === lit || p.id === focus ? c.accent : c.node;
224
      ctx.beginPath();
225
      ctx.arc(s.x, s.y, r, 0, Math.PI * 2);
226
      ctx.fill();
227
      if (all || on || p.links >= hub) {
228
        const t = p.title.length > 28 ? `${p.title.slice(0, 27)}…` : p.title;
229
        ctx.globalAlpha = lit && !on ? 0.3 : 1;
230
        ctx.fillStyle = p === lit ? c.label : c.quiet;
231
        ctx.fillText(t, s.x, s.y + r + 4);
232
      }
233
    }
234
    ctx.globalAlpha = 1;
235
  }
236
237
  function at(x, y) {
238
    let best = null;
239
    let bestD = Infinity;
240
    for (const p of ps) {
241
      const s = screen(p);
242
      const d = Math.hypot(s.x - x, s.y - y);
243
      const reach = Math.max(10, p.r * Math.sqrt(view.k) + 4);
244
      if (d < reach && d < bestD) {
245
        best = p;
246
        bestD = d;
247
      }
248
    }
249
    return best;
250
  }
251
252
  // Pointers: one drags a note or pans; two pinch.
253
  const pointers = new Map();
254
  let drag = null;
255
  let moved = 0;
256
  let pinch = null;
257
  const local2 = (e) => {
258
    const r = canvas.getBoundingClientRect();
259
    return { x: e.clientX - r.left, y: e.clientY - r.top };
260
  };
261
  canvas.addEventListener("pointerdown", (e) => {
262
    canvas.setPointerCapture(e.pointerId);
263
    const p = local2(e);
264
    pointers.set(e.pointerId, p);
265
    moved = 0;
266
    if (pointers.size === 2) {
267
      const [a, b] = [...pointers.values()];
268
      pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), k: view.k };
269
      drag = null;
270
      return;
271
    }
272
    const node = at(p.x, p.y);
273
    drag = node ? { node, ...p } : { pan: true, ...p, vx: view.x, vy: view.y };
274
    if (node) {
275
      node.fixed = true;
276
      heat(0.3);
277
    }
278
  });
279
  canvas.addEventListener("pointermove", (e) => {
280
    const p = local2(e);
281
    if (pointers.has(e.pointerId)) pointers.set(e.pointerId, p);
282
    if (pinch && pointers.size === 2) {
283
      const [a, b] = [...pointers.values()];
284
      view.k = Math.max(0.2, Math.min(5, (pinch.k * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.d));
285
      draw();
286
      return;
287
    }
288
    if (drag) {
289
      moved += Math.abs(e.movementX) + Math.abs(e.movementY);
290
      if (drag.node) {
291
        const q = world(p.x, p.y);
292
        drag.node.x = q.x;
293
        drag.node.y = q.y;
294
        heat(0.3);
295
      } else {
296
        view.x = drag.vx + (p.x - drag.x) / view.k;
297
        view.y = drag.vy + (p.y - drag.y) / view.k;
298
      }
299
      draw();
300
      return;
301
    }
302
    const over = at(p.x, p.y);
303
    if (over !== hover) {
304
      hover = over;
305
      canvas.style.cursor = over ? "pointer" : "grab";
306
      draw();
307
    }
308
  });
309
  const up = (e) => {
310
    pointers.delete(e.pointerId);
311
    if (pointers.size < 2) pinch = null;
312
    if (!drag) return;
313
    if (drag.node) {
314
      drag.node.fixed = false;
315
      if (moved < 6) location.href = `/n/${drag.node.id}`;
316
    }
317
    drag = null;
318
  };
319
  canvas.addEventListener("pointerup", up);
320
  canvas.addEventListener("pointercancel", up);
321
  canvas.addEventListener("pointerleave", () => {
322
    if (hover && !drag) {
323
      hover = null;
324
      draw();
325
    }
326
  });
327
  canvas.addEventListener(
328
    "wheel",
329
    (e) => {
330
      e.preventDefault();
331
      const p = local2(e);
332
      const before = world(p.x, p.y);
333
      view.k = Math.max(0.2, Math.min(5, view.k * Math.exp(-e.deltaY * 0.0015)));
334
      const after = world(p.x, p.y);
335
      view.x += after.x - before.x;
336
      view.y += after.y - before.y;
337
      draw();
338
    },
339
    { passive: false },
340
  );
341
  // The keyboard reaches what the mouse does: Tab focuses the canvas, arrows
342
  // pan, + and - zoom.
343
  canvas.tabIndex = 0;
344
  canvas.addEventListener("keydown", (e) => {
345
    const step = 40 / view.k;
346
    const moves = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
347
    if (moves[e.key]) {
348
      view.x += moves[e.key][0];
349
      view.y += moves[e.key][1];
350
    } else if (e.key === "+" || e.key === "=") view.k = Math.min(5, view.k * 1.2);
351
    else if (e.key === "-") view.k = Math.max(0.2, view.k / 1.2);
352
    else return;
353
    e.preventDefault();
354
    draw();
355
  });
356
  canvas.style.cursor = "grab";
357
358
  new ResizeObserver(size).observe(canvas);
359
  document.addEventListener("theme", () => {
360
    c = colours();
361
    draw();
362
  });
363
  matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
364
    c = colours();
365
    draw();
366
  });
367
  size();
368
  heat(1);
369
}
370
371
const start = () => document.querySelectorAll("canvas[data-graph]").forEach(chart);
372
start();
373
document.addEventListener("live:swapped", start);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts