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

Files 9a96f69e35aa62daafc52ed3af566f73004b8e77
370 lines · 11.0 KB · ~3.2k 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 : 1400;
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 > 250000) 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 : 60;
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.012 * alpha;
135
      p.vy -= p.y * 0.012 * 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 : 2.4);
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
    const labels = local || view.k > 1.15 || ps.length <= 30;
212
    ctx.font = `${local ? 11 : 12}px system-ui, -apple-system, sans-serif`;
213
    ctx.textAlign = "center";
214
    ctx.textBaseline = "top";
215
    for (const p of ps) {
216
      const s = screen(p);
217
      const r = p.r * Math.sqrt(view.k);
218
      const on = lit && (p === lit || near.has(p.id));
219
      ctx.globalAlpha = lit && !on ? 0.35 : 1;
220
      ctx.fillStyle = p === lit || p.id === focus ? c.accent : c.node;
221
      ctx.beginPath();
222
      ctx.arc(s.x, s.y, r, 0, Math.PI * 2);
223
      ctx.fill();
224
      if (labels || on) {
225
        const t = p.title.length > 28 ? `${p.title.slice(0, 27)}…` : p.title;
226
        ctx.globalAlpha = lit && !on ? 0.3 : 1;
227
        ctx.fillStyle = p === lit ? c.label : c.quiet;
228
        ctx.fillText(t, s.x, s.y + r + 4);
229
      }
230
    }
231
    ctx.globalAlpha = 1;
232
  }
233
234
  function at(x, y) {
235
    let best = null;
236
    let bestD = Infinity;
237
    for (const p of ps) {
238
      const s = screen(p);
239
      const d = Math.hypot(s.x - x, s.y - y);
240
      const reach = Math.max(10, p.r * Math.sqrt(view.k) + 4);
241
      if (d < reach && d < bestD) {
242
        best = p;
243
        bestD = d;
244
      }
245
    }
246
    return best;
247
  }
248
249
  // Pointers: one drags a note or pans; two pinch.
250
  const pointers = new Map();
251
  let drag = null;
252
  let moved = 0;
253
  let pinch = null;
254
  const local2 = (e) => {
255
    const r = canvas.getBoundingClientRect();
256
    return { x: e.clientX - r.left, y: e.clientY - r.top };
257
  };
258
  canvas.addEventListener("pointerdown", (e) => {
259
    canvas.setPointerCapture(e.pointerId);
260
    const p = local2(e);
261
    pointers.set(e.pointerId, p);
262
    moved = 0;
263
    if (pointers.size === 2) {
264
      const [a, b] = [...pointers.values()];
265
      pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), k: view.k };
266
      drag = null;
267
      return;
268
    }
269
    const node = at(p.x, p.y);
270
    drag = node ? { node, ...p } : { pan: true, ...p, vx: view.x, vy: view.y };
271
    if (node) {
272
      node.fixed = true;
273
      heat(0.3);
274
    }
275
  });
276
  canvas.addEventListener("pointermove", (e) => {
277
    const p = local2(e);
278
    if (pointers.has(e.pointerId)) pointers.set(e.pointerId, p);
279
    if (pinch && pointers.size === 2) {
280
      const [a, b] = [...pointers.values()];
281
      view.k = Math.max(0.2, Math.min(5, (pinch.k * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.d));
282
      draw();
283
      return;
284
    }
285
    if (drag) {
286
      moved += Math.abs(e.movementX) + Math.abs(e.movementY);
287
      if (drag.node) {
288
        const q = world(p.x, p.y);
289
        drag.node.x = q.x;
290
        drag.node.y = q.y;
291
        heat(0.3);
292
      } else {
293
        view.x = drag.vx + (p.x - drag.x) / view.k;
294
        view.y = drag.vy + (p.y - drag.y) / view.k;
295
      }
296
      draw();
297
      return;
298
    }
299
    const over = at(p.x, p.y);
300
    if (over !== hover) {
301
      hover = over;
302
      canvas.style.cursor = over ? "pointer" : "grab";
303
      draw();
304
    }
305
  });
306
  const up = (e) => {
307
    pointers.delete(e.pointerId);
308
    if (pointers.size < 2) pinch = null;
309
    if (!drag) return;
310
    if (drag.node) {
311
      drag.node.fixed = false;
312
      if (moved < 6) location.href = `/n/${drag.node.id}`;
313
    }
314
    drag = null;
315
  };
316
  canvas.addEventListener("pointerup", up);
317
  canvas.addEventListener("pointercancel", up);
318
  canvas.addEventListener("pointerleave", () => {
319
    if (hover && !drag) {
320
      hover = null;
321
      draw();
322
    }
323
  });
324
  canvas.addEventListener(
325
    "wheel",
326
    (e) => {
327
      e.preventDefault();
328
      const p = local2(e);
329
      const before = world(p.x, p.y);
330
      view.k = Math.max(0.2, Math.min(5, view.k * Math.exp(-e.deltaY * 0.0015)));
331
      const after = world(p.x, p.y);
332
      view.x += after.x - before.x;
333
      view.y += after.y - before.y;
334
      draw();
335
    },
336
    { passive: false },
337
  );
338
  // The keyboard reaches what the mouse does: Tab focuses the canvas, arrows
339
  // pan, + and - zoom.
340
  canvas.tabIndex = 0;
341
  canvas.addEventListener("keydown", (e) => {
342
    const step = 40 / view.k;
343
    const moves = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
344
    if (moves[e.key]) {
345
      view.x += moves[e.key][0];
346
      view.y += moves[e.key][1];
347
    } else if (e.key === "+" || e.key === "=") view.k = Math.min(5, view.k * 1.2);
348
    else if (e.key === "-") view.k = Math.max(0.2, view.k / 1.2);
349
    else return;
350
    e.preventDefault();
351
    draw();
352
  });
353
  canvas.style.cursor = "grab";
354
355
  new ResizeObserver(size).observe(canvas);
356
  document.addEventListener("theme", () => {
357
    c = colours();
358
    draw();
359
  });
360
  matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
361
    c = colours();
362
    draw();
363
  });
364
  size();
365
  heat(1);
366
}
367
368
const start = () => document.querySelectorAll("canvas[data-graph]").forEach(chart);
369
start();
370
document.addEventListener("live:swapped", start);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts