Files
blog/src/components/NoteGraph.svelte
T
Hadi d6d410a2fa Add notes & card styling
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 14:03:48 +02:00

280 lines
7.8 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
interface GNode {
id: string;
title: string;
current: boolean;
}
interface GEdge {
from: string;
to: string;
}
interface Props {
nodes?: GNode[];
edges?: GEdge[];
}
const { nodes = [], edges = [] }: Props = $props();
const PRIMARY = "oklch(71% 0.0863 296.59)";
let canvas: HTMLCanvasElement;
function startAnimation(): () => void {
if (!canvas || nodes.length === 0) return () => {};
const W = (canvas.width = canvas.offsetWidth);
const H = (canvas.height = 190);
const ctx = canvas.getContext("2d")!;
type SimNode = GNode & { x: number; y: number; vx: number; vy: number };
const simNodes: SimNode[] = nodes.map((n) => ({
...n,
x: n.current ? W / 2 : W / 2 + (Math.random() - 0.5) * 80,
y: n.current ? H / 2 : H / 2 + (Math.random() - 0.5) * 80,
vx: 0,
vy: 0,
}));
let dragging: SimNode | null = null;
let hovered: SimNode | null = null;
function nodeAt(x: number, y: number): SimNode | null {
return (
simNodes.find((n) => {
const dx = n.x - x,
dy = n.y - y;
return Math.sqrt(dx * dx + dy * dy) < (n.current ? 10 : 8);
}) ?? null
);
}
function tick() {
for (let i = 0; i < simNodes.length; i++) {
for (let j = i + 1; j < simNodes.length; j++) {
const a = simNodes[i],
b = simNodes[j];
const dx = b.x - a.x,
dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const f = 900 / (d * d);
a.vx -= (dx / d) * f;
a.vy -= (dy / d) * f;
b.vx += (dx / d) * f;
b.vy += (dy / d) * f;
}
}
for (const e of edges) {
const a = simNodes.find((n) => n.id === e.from);
const b = simNodes.find((n) => n.id === e.to);
if (!a || !b) continue;
const dx = b.x - a.x,
dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const f = (d - 75) * 0.04;
a.vx += (dx / d) * f;
a.vy += (dy / d) * f;
b.vx -= (dx / d) * f;
b.vy -= (dy / d) * f;
}
for (const n of simNodes) {
n.vx += (W / 2 - n.x) * 0.025;
n.vy += (H / 2 - n.y) * 0.025;
}
for (const n of simNodes) {
if (n === dragging) continue;
n.vx *= 0.78;
n.vy *= 0.78;
n.x = Math.max(16, Math.min(W - 16, n.x + n.vx));
n.y = Math.max(16, Math.min(H - 16, n.y + n.vy));
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "oklch(2% 0 0)";
ctx.fillRect(0, 0, W, H);
const connected = new Set<string>();
if (hovered) {
for (const e of edges) {
if (e.from === hovered.id) connected.add(e.to);
if (e.to === hovered.id) connected.add(e.from);
}
}
for (const e of edges) {
const a = simNodes.find((n) => n.id === e.from);
const b = simNodes.find((n) => n.id === e.to);
if (!a || !b) continue;
const lit =
hovered && (e.from === hovered.id || e.to === hovered.id);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = lit ? "oklch(55% 0 0)" : "oklch(27% 0 0)";
ctx.lineWidth = lit ? 1.5 : 1;
ctx.stroke();
}
for (const n of simNodes) {
const isHov = hovered?.id === n.id;
const isCon = connected.has(n.id);
const r = n.current ? 7 : isHov ? 6 : 4.5;
if (isHov && !n.current) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 5, 0, Math.PI * 2);
ctx.fillStyle = "oklch(71% 0.0863 296.59 / 0.15)";
ctx.fill();
}
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = n.current
? PRIMARY
: isHov
? "oklch(78% 0.05 296.59)"
: isCon
? "oklch(58% 0.03 296.59)"
: "oklch(40% 0 0)";
ctx.fill();
if (n.current || isHov || isCon) {
ctx.font = `${n.current ? "10px" : "9px"} monospace`;
ctx.textAlign = "center";
ctx.fillStyle = n.current ? "oklch(87% 0 0)" : "oklch(62% 0 0)";
const label =
n.title.length > 14 ? n.title.slice(0, 13) + "…" : n.title;
ctx.fillText(label, n.x, n.y + r + 9);
}
}
}
let animId: number;
function loop() {
tick();
draw();
animId = requestAnimationFrame(loop);
}
animId = requestAnimationFrame(loop);
const onMousedown = (e: MouseEvent) => {
const r = canvas.getBoundingClientRect();
const sx = W / canvas.offsetWidth;
dragging = nodeAt(
(e.clientX - r.left) * sx,
(e.clientY - r.top) * (H / canvas.offsetHeight),
);
};
const onMousemove = (e: MouseEvent) => {
const r = canvas.getBoundingClientRect();
const sx = W / canvas.offsetWidth;
const x = (e.clientX - r.left) * sx;
const y = (e.clientY - r.top) * (H / canvas.offsetHeight);
if (dragging) {
dragging.x = x;
dragging.y = y;
dragging.vx = 0;
dragging.vy = 0;
}
hovered = nodeAt(x, y);
canvas.style.cursor =
hovered && !hovered.current ? "pointer" : "default";
};
const onMouseup = () => {
dragging = null;
};
const onMouseleave = () => {
dragging = null;
hovered = null;
};
const onClick = (e: MouseEvent) => {
const r = canvas.getBoundingClientRect();
const sx = W / canvas.offsetWidth;
const n = nodeAt(
(e.clientX - r.left) * sx,
(e.clientY - r.top) * (H / canvas.offsetHeight),
);
if (n && !n.current) window.location.href = `/notes/${n.id}`;
};
canvas.addEventListener("mousedown", onMousedown);
canvas.addEventListener("mousemove", onMousemove);
canvas.addEventListener("mouseup", onMouseup);
canvas.addEventListener("mouseleave", onMouseleave);
canvas.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(animId);
canvas.removeEventListener("mousedown", onMousedown);
canvas.removeEventListener("mousemove", onMousemove);
canvas.removeEventListener("mouseup", onMouseup);
canvas.removeEventListener("mouseleave", onMouseleave);
canvas.removeEventListener("click", onClick);
};
}
onMount(() => {
const drawer = document.getElementById(
"graph-drawer",
) as HTMLInputElement | null;
const outerDrawer =
drawer?.closest<HTMLElement>(".drawer.drawer-end") ?? null;
let stopFn: (() => void) | null = null;
function isVisible() {
return (
(drawer?.checked ?? false) ||
(outerDrawer?.classList.contains("xl:drawer-open") ?? false)
);
}
function start() {
if (stopFn) return;
stopFn = startAnimation();
}
function stop() {
stopFn?.();
stopFn = null;
}
if (isVisible()) start();
const onDrawerChange = () => {
isVisible() ? start() : stop();
};
drawer?.addEventListener("change", onDrawerChange);
// Watch for xl:drawer-open class toggled by the graph button script
const observer = new MutationObserver(() => {
isVisible() ? start() : stop();
});
if (outerDrawer) {
observer.observe(outerDrawer, {
attributes: true,
attributeFilter: ["class"],
});
}
return () => {
stop();
drawer?.removeEventListener("change", onDrawerChange);
observer.disconnect();
};
});
</script>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
bind:this={canvas}
height="190"
role="img"
aria-label="Graph of linked notes"
style="width:100%; display:block; background: oklch(2% 0 0); cursor:default;"
></canvas>