Something went wrong. Try again.
Digital Garden SSG
digital-garden ssg static-site-generator
Something went wrong. Try again.
21 kB · 708 lines
JavaScript
at initial
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709/** * Understory — Interactive link graph. * * Canvas2D force-directed graph with live simulation, drag-to-move, * scroll-to-zoom, pan, and smooth hover transitions. * No external dependencies (no d3, no pixi.js). * * Inspired by Quartz v4's graph: live requestAnimationFrame simulation, * node dragging pins fx/fy, zoom with scaleExtent, alpha decay. * * Data source: /graphData.json (emitted at build time) * Format: { nodes: [{id, title, url}], links: [{source, target}] } */(function () { "use strict";
// ── Configuration ───────────────────────────────────────────
const DEPTH = 1; const BASE_RADIUS = 2; const FONT = "11px sans-serif"; const LABEL_FONT_BOLD = "bold 11px sans-serif";
// Force parameters. const REPULSION = 800; const ATTRACTION = 0.008; const CENTER_GRAVITY = 0.02; const DAMPING = 0.7; const ALPHA_INIT = 1.0; const ALPHA_DECAY = 0.005; const ALPHA_MIN = 0.001; const ALPHA_REHEAT = 0.3; const VELOCITY_DECAY = 0.6;
// Interaction. const ZOOM_MIN = 0.25; const ZOOM_MAX = 4.0; const ZOOM_SENSITIVITY = 0.002; const HIT_RADIUS = 14;
// Smooth transitions. const HOVER_LERP = 0.15;
// ── Colours (CSS custom properties with fallbacks) ──────────
function getCSSVar(name, fallback) { const v = getComputedStyle(document.documentElement) .getPropertyValue(name) .trim(); return v || fallback; }
function refreshColours() { colours.link = getCSSVar("--border", "#bcc0cc"); colours.node = getCSSVar("--muted", "#6c6f85"); colours.active = getCSSVar("--accent", "#179299"); colours.hover = getCSSVar("--link", "#1e66f5"); colours.text = getCSSVar("--fg", "#4c4f69"); colours.bg = getCSSVar("--code-bg", "#e6e9ef"); }
const colours = {};
// ── State ───────────────────────────────────────────────────
let graphData = null; let subgraph = null; let hoveredNode = null; let draggedNode = null; let alpha = ALPHA_INIT; let animating = false; let canvas, ctx, width, height, dpr; let currentSlug = null;
// Camera: world coordinates → screen via scale + offset. let camZoom = 1.0; let camX = 0; let camY = 0;
// Pan state. let isPanning = false; let panStartX = 0; let panStartY = 0; let camStartX = 0; let camStartY = 0;
// Smooth hover: each node gets an animated hoverAlpha (0..1). // 1 = fully highlighted, 0 = default.
// ── Initialisation ──────────────────────────────────────────
const container = document.getElementById("graph-container"); if (!container) return;
// Determine current page slug. const canonical = document.querySelector('link[rel="canonical"]'); if (canonical) { try { currentSlug = new URL(canonical.href).pathname .replace(/^\//, "") .replace(/\/$/, ""); } catch (_) {} } if (!currentSlug) { currentSlug = location.pathname.replace(/^\//, "").replace(/\/$/, ""); } currentSlug = decodeURIComponent(currentSlug);
// Create canvas. canvas = document.createElement("canvas"); canvas.style.width = "100%"; canvas.style.height = "100%"; canvas.style.cursor = "default"; canvas.style.touchAction = "none"; container.appendChild(canvas); ctx = canvas.getContext("2d");
refreshColours();
// Fetch graph data. fetch("/graphData.json") .then(function (r) { return r.json(); }) .then(function (data) { graphData = data; buildSubgraph(); initPositions(); fitToView(); startAnimation(); }) .catch(function (e) { console.warn("Graph: failed to load graphData.json", e); });
// ── Subgraph extraction (BFS neighbourhood) ─────────────────
function buildSubgraph() { var adj = new Map(); var degreeMap = new Map();
for (var i = 0; i < graphData.nodes.length; i++) { adj.set(graphData.nodes[i].id, new Set()); degreeMap.set(graphData.nodes[i].id, 0); }
for (var i = 0; i < graphData.links.length; i++) { var l = graphData.links[i]; if (adj.has(l.source) && adj.has(l.target)) { adj.get(l.source).add(l.target); adj.get(l.target).add(l.source); degreeMap.set(l.source, degreeMap.get(l.source) + 1); degreeMap.set(l.target, degreeMap.get(l.target) + 1); } }
// BFS from current page. var visited = new Set(); var queue = [[currentSlug, 0]]; visited.add(currentSlug);
while (queue.length > 0) { var item = queue.shift(); var id = item[0]; var depth = item[1]; if (depth >= DEPTH) continue; var neighbours = adj.get(id); if (!neighbours) continue; neighbours.forEach(function (nid) { if (!visited.has(nid)) { visited.add(nid); queue.push([nid, depth + 1]); } }); }
if (!adj.has(currentSlug)) { subgraph = { nodes: [], links: [], nodeMap: new Map() }; return; }
var nodeMap = new Map(); var nodes = []; for (var i = 0; i < graphData.nodes.length; i++) { var n = graphData.nodes[i]; if (visited.has(n.id)) { var degree = degreeMap.get(n.id) || 0; var sn = { id: n.id, title: n.title, url: n.url, x: 0, y: 0, vx: 0, vy: 0, fx: null, // pinned x (when dragging) fy: null, // pinned y (when dragging) r: BASE_RADIUS + Math.sqrt(degree), hoverAlpha: 0, degree: degree, }; nodes.push(sn); nodeMap.set(n.id, sn); } }
var links = []; for (var i = 0; i < graphData.links.length; i++) { var l = graphData.links[i]; if (nodeMap.has(l.source) && nodeMap.has(l.target)) { links.push({ source: nodeMap.get(l.source), target: nodeMap.get(l.target), }); } }
subgraph = { nodes: nodes, links: links, nodeMap: nodeMap }; }
// ── Initial positions ───────────────────────────────────────
function initPositions() { var nodes = subgraph.nodes; if (nodes.length === 0) return;
for (var i = 0; i < nodes.length; i++) { var n = nodes[i]; n.x = (Math.random() - 0.5) * 200; n.y = (Math.random() - 0.5) * 200; n.vx = 0; n.vy = 0; }
// Place active node at center. var active = subgraph.nodeMap.get(currentSlug); if (active) { active.x = 0; active.y = 0; }
alpha = ALPHA_INIT; }
// ── Fit camera to show all nodes ────────────────────────────
function fitToView() { resize(); if (!subgraph || subgraph.nodes.length === 0) return;
var nodes = subgraph.nodes; var minX = Infinity, maxX = -Infinity; var minY = Infinity, maxY = -Infinity;
for (var i = 0; i < nodes.length; i++) { var n = nodes[i]; if (n.x < minX) minX = n.x; if (n.x > maxX) maxX = n.x; if (n.y < minY) minY = n.y; if (n.y > maxY) maxY = n.y; }
var rangeX = (maxX - minX) || 1; var rangeY = (maxY - minY) || 1; var pad = 60;
camZoom = Math.min( (width - pad * 2) / rangeX, (height - pad * 2) / rangeY, 3.0 ); camZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, camZoom)); camX = width / 2 - ((minX + maxX) / 2) * camZoom; camY = height / 2 - ((minY + maxY) / 2) * camZoom; }
// ── Force simulation tick ───────────────────────────────────
function simTick() { if (!subgraph || subgraph.nodes.length === 0) return; if (alpha < ALPHA_MIN) return;
var nodes = subgraph.nodes; var links = subgraph.links; var n = nodes.length;
// Repulsion (all pairs — O(n^2) is fine for local graphs). for (var i = 0; i < n; i++) { for (var j = i + 1; j < n; j++) { var a = nodes[i]; var b = nodes[j]; var dx = b.x - a.x; var dy = b.y - a.y; var dist2 = dx * dx + dy * dy; if (dist2 < 1) dist2 = 1; var dist = Math.sqrt(dist2); var force = (REPULSION * alpha) / dist2; var fx = (dx / dist) * force; var fy = (dy / dist) * force; a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy; } }
// Attraction along edges. for (var i = 0; i < links.length; i++) { var l = links[i]; var dx = l.target.x - l.source.x; var dy = l.target.y - l.source.y; var dist = Math.sqrt(dx * dx + dy * dy) || 1; var force = dist * ATTRACTION * alpha; var fx = (dx / dist) * force; var fy = (dy / dist) * force; l.source.vx += fx; l.source.vy += fy; l.target.vx -= fx; l.target.vy -= fy; }
// Center gravity. for (var i = 0; i < n; i++) { var nd = nodes[i]; nd.vx -= nd.x * CENTER_GRAVITY * alpha; nd.vy -= nd.y * CENTER_GRAVITY * alpha; }
// Integrate positions. for (var i = 0; i < n; i++) { var nd = nodes[i]; nd.vx *= VELOCITY_DECAY; nd.vy *= VELOCITY_DECAY;
// Respect pinned positions (drag). if (nd.fx !== null) { nd.x = nd.fx; nd.vx = 0; } else { nd.x += nd.vx; } if (nd.fy !== null) { nd.y = nd.fy; nd.vy = 0; } else { nd.y += nd.vy; } }
// Alpha decay. alpha -= ALPHA_DECAY; if (alpha < ALPHA_MIN) alpha = 0; }
// ── Coordinate transforms ───────────────────────────────────
// World → screen. function toScreenX(wx) { return wx * camZoom + camX; } function toScreenY(wy) { return wy * camZoom + camY; }
// Screen → world. function toWorldX(sx) { return (sx - camX) / camZoom; } function toWorldY(sy) { return (sy - camY) / camZoom; }
// ── Canvas resize ───────────────────────────────────────────
function resize() { var rect = container.getBoundingClientRect(); width = rect.width; height = rect.height; dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }
// ── Rendering ───────────────────────────────────────────────
function draw() { resize(); ctx.clearRect(0, 0, width, height);
if (!subgraph || subgraph.nodes.length === 0) { ctx.fillStyle = colours.node; ctx.font = FONT; ctx.textAlign = "center"; ctx.fillText("No linked pages", width / 2, height / 2); return; }
var nodes = subgraph.nodes; var links = subgraph.links;
// Build the hover neighbourhood set. var hoverSet = new Set(); if (hoveredNode) { hoverSet.add(hoveredNode.id); for (var i = 0; i < links.length; i++) { var l = links[i]; if (l.source.id === hoveredNode.id) hoverSet.add(l.target.id); if (l.target.id === hoveredNode.id) hoverSet.add(l.source.id); } }
// Animate hoverAlpha toward targets. for (var i = 0; i < nodes.length; i++) { var nd = nodes[i]; var target = hoverSet.has(nd.id) ? 1 : 0; nd.hoverAlpha += (target - nd.hoverAlpha) * HOVER_LERP; if (Math.abs(nd.hoverAlpha - target) < 0.01) nd.hoverAlpha = target; }
// ── Draw links ──────────────────────────────────────────
for (var i = 0; i < links.length; i++) { var l = links[i]; var sx1 = toScreenX(l.source.x); var sy1 = toScreenY(l.source.y); var sx2 = toScreenX(l.target.x); var sy2 = toScreenY(l.target.y);
var isHighlighted = hoveredNode && ( l.source.id === hoveredNode.id || l.target.id === hoveredNode.id );
var linkHA = Math.max(l.source.hoverAlpha, l.target.hoverAlpha);
ctx.strokeStyle = isHighlighted ? colours.hover : colours.link; ctx.globalAlpha = hoveredNode ? (isHighlighted ? 0.6 + linkHA * 0.2 : 0.08) : 0.25; ctx.lineWidth = isHighlighted ? 1.5 : 1; ctx.beginPath(); ctx.moveTo(sx1, sy1); ctx.lineTo(sx2, sy2); ctx.stroke(); } ctx.globalAlpha = 1;
// ── Draw nodes ──────────────────────────────────────────
for (var i = 0; i < nodes.length; i++) { var nd = nodes[i]; var sx = toScreenX(nd.x); var sy = toScreenY(nd.y); var isActive = nd.id === currentSlug; var isHovered = hoveredNode && nd.id === hoveredNode.id; var inHover = hoverSet.has(nd.id);
// Radius scales with zoom, with floor. var baseR = nd.r * Math.max(camZoom * 0.8, 0.6); var r = baseR; if (isActive) r = baseR * 1.3; if (isHovered) r = baseR * 1.5;
// Colour. var col; if (isActive) { col = colours.active; } else if (isHovered) { col = colours.hover; } else if (hoveredNode && !inHover) { col = colours.link; } else { col = colours.node; }
// Alpha: dim non-connected when hovering. ctx.globalAlpha = hoveredNode && !inHover && !isActive ? 0.2 : 1;
ctx.fillStyle = col; ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1;
// ── Draw labels ─────────────────────────────────────────
ctx.textAlign = "center"; ctx.textBaseline = "bottom";
for (var i = 0; i < nodes.length; i++) { var nd = nodes[i]; var isActive = nd.id === currentSlug; var isHovered = hoveredNode && nd.id === hoveredNode.id; var inHover = hoverSet.has(nd.id);
// Always show active label; show hovered + neighbourhood labels. var showLabel = isActive || isHovered || (hoveredNode && inHover); if (!showLabel) continue;
var sx = toScreenX(nd.x); var sy = toScreenY(nd.y); var r = nd.r * Math.max(camZoom * 0.8, 0.6); if (isActive) r *= 1.3; if (isHovered) r *= 1.5;
ctx.font = (isActive || isHovered) ? LABEL_FONT_BOLD : FONT;
if (isActive) { ctx.fillStyle = colours.active; } else if (isHovered) { ctx.fillStyle = colours.hover; } else { ctx.fillStyle = colours.text; }
// Label opacity: smooth transition. ctx.globalAlpha = isActive ? 1 : nd.hoverAlpha; ctx.fillText(nd.title, sx, sy - r - 3); } ctx.globalAlpha = 1; }
// ── Animation loop ──────────────────────────────────────────
function tick() { simTick(); draw();
// Keep animating while: // - simulation is running (alpha > 0) // - a node is being dragged // - hover transitions are in progress var needsFrame = alpha > 0 || draggedNode !== null; if (!needsFrame && subgraph) { for (var i = 0; i < subgraph.nodes.length; i++) { var nd = subgraph.nodes[i]; if (nd.hoverAlpha > 0.01 && nd.hoverAlpha < 0.99) { needsFrame = true; break; } } }
if (needsFrame) { requestAnimationFrame(tick); } else { animating = false; } }
function startAnimation() { if (!animating) { animating = true; requestAnimationFrame(tick); } }
function reheat() { alpha = Math.max(alpha, ALPHA_REHEAT); startAnimation(); }
// ── Hit testing ─────────────────────────────────────────────
function findNodeAt(screenX, screenY) { if (!subgraph) return null; var wx = toWorldX(screenX); var wy = toWorldY(screenY); var hitR = HIT_RADIUS / camZoom;
// Search in reverse to hit topmost first. for (var i = subgraph.nodes.length - 1; i >= 0; i--) { var nd = subgraph.nodes[i]; var dx = wx - nd.x; var dy = wy - nd.y; if (dx * dx + dy * dy < hitR * hitR) return nd; } return null; }
// ── Mouse / pointer events ──────────────────────────────────
function getPointerPos(e) { var rect = canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }
canvas.addEventListener("pointerdown", function (e) { var pos = getPointerPos(e); var node = findNodeAt(pos.x, pos.y);
if (node) { // Start dragging. draggedNode = node; node.fx = node.x; node.fy = node.y; canvas.style.cursor = "grabbing"; canvas.setPointerCapture(e.pointerId); reheat(); } else { // Start panning. isPanning = true; panStartX = pos.x; panStartY = pos.y; camStartX = camX; camStartY = camY; canvas.style.cursor = "grabbing"; canvas.setPointerCapture(e.pointerId); }
e.preventDefault(); });
canvas.addEventListener("pointermove", function (e) { var pos = getPointerPos(e);
if (draggedNode) { draggedNode.fx = toWorldX(pos.x); draggedNode.fy = toWorldY(pos.y); reheat(); return; }
if (isPanning) { camX = camStartX + (pos.x - panStartX); camY = camStartY + (pos.y - panStartY); startAnimation(); return; }
// Hover detection. var node = findNodeAt(pos.x, pos.y); if (node !== hoveredNode) { hoveredNode = node; canvas.style.cursor = node ? "pointer" : "default"; startAnimation(); } });
canvas.addEventListener("pointerup", function (e) { if (draggedNode) { // Release pin. draggedNode.fx = null; draggedNode.fy = null; draggedNode = null; canvas.style.cursor = hoveredNode ? "pointer" : "default"; reheat(); }
if (isPanning) { isPanning = false; canvas.style.cursor = "default"; }
canvas.releasePointerCapture(e.pointerId); });
canvas.addEventListener("pointerleave", function () { if (hoveredNode) { hoveredNode = null; startAnimation(); } canvas.style.cursor = "default"; });
// Click to navigate. canvas.addEventListener("click", function (e) { // If we were dragging or panning, don't navigate. if (isPanning) return; var pos = getPointerPos(e); var node = findNodeAt(pos.x, pos.y); if (node && node.url) { window.location.href = node.url; } });
// ── Zoom (wheel) ────────────────────────────────────────────
canvas.addEventListener("wheel", function (e) { e.preventDefault(); var pos = getPointerPos(e);
// Zoom toward pointer position. var oldZoom = camZoom; var zoomDelta = -e.deltaY * ZOOM_SENSITIVITY; camZoom *= 1 + zoomDelta; camZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, camZoom));
// Adjust camera so the point under the cursor stays fixed. var scale = camZoom / oldZoom; camX = pos.x - (pos.x - camX) * scale; camY = pos.y - (pos.y - camY) * scale;
startAnimation(); }, { passive: false });
// ── Resize handling ─────────────────────────────────────────
var resizeTimer; window.addEventListener("resize", function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(function () { startAnimation(); }, 50); });
// ── Colour scheme change ────────────────────────────────────
if (window.matchMedia) { window .matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", function () { refreshColours(); startAnimation(); }); }})();