/** * 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(); }); } })();