graph.js
Table of Contents
This literate configuration file tangles to the script graph.js. Graph rendering in myzettel is done by this script using the json data generated by gen_graph.py. Searching the graph is made possible by search.js.
Re-execution Guard
Kills previous renderer, aborts in-flight requests on re-execution, ensuring clean state when the script runs again in the same page context (hot reload, navigation without full page refresh).
if (window._graphRenderer) { try { window._graphRenderer.kill(); } catch(e) {} } document.querySelectorAll(".graph-link-tooltip").forEach(el => el.remove()); if (window._graphAbort) window._graphAbort.abort(); if (window._sidebarAbort) window._sidebarAbort.abort(); window._sidebarAbort = new AbortController(); window._graphAbort = new AbortController(); const signal = window._graphAbort.signal;
Palette
Reads CSS variables so the graph respects the site theme.
const _css = getComputedStyle(document.documentElement); const _v = (name) => _css.getPropertyValue(name).trim(); const theme = { paletteS: _v("--graph-palette-s"), paletteL: _v("--graph-palette-l"), nodeDefault: _v("--graph-node-default"), edgeDefault: _v("--graph-edge-default"), label: _v("--graph-label"), nodeDim: _v("--graph-node-dim"), edgeDim: _v("--graph-edge-dim"), edgeHi: _v("--graph-edge-hi"), edgeBi: _v("--graph-edge-bi"), edgeUni: _v("--graph-edge-uni"), };
Pre-Compute Metrics
Builds the backlinks lookup and the Graphology graph from JSON data. Node sizes are computed after all edges are added so graph.degree() is accurate.
const backlinks = {}; data.nodes.forEach(n => { backlinks[n.id] = n.backlinks || 0; }); // ── BUILD GRAPHOLOGY GRAPH ───────────────────────────────── const currentPath = window.location.pathname; const isMobile = window.innerWidth < 768; const graph = new graphology.Graph({ multi: false, allowSelfLoops: false }); const communityCount = data.communityCount ?? 1; const palS = parseFloat(theme.paletteS); const palL = parseFloat(theme.paletteL); data.nodes.forEach(n => { const isCurrentPage = currentPath === `/docs/${n.lnk}/`; const hue = Math.round(((n.community ?? 0) / communityCount) * 360); graph.addNode(n.id, { label: n.label, size: 1, color: isCurrentPage ? "#ff6b6b" : hslToHex(hue, palS, palL), borderColor: backlinks[n.id] > 8 ? "#ffffff" : undefined, lnk: n.lnk, excerpt: n.excerpt || "", x: n.x ?? 0, y: n.y ?? 0, origX: n.x ?? 0, origY: n.y ?? 0, }); }); data.links.forEach(l => { // Skip self-loops and duplicate edges if (l.source === l.target) return; if (!graph.hasEdge(l.source, l.target) && !graph.hasEdge(l.target, l.source)) { graph.addEdge(l.source, l.target, { color: l.bidirectional ? theme.edgeBi : theme.edgeUni, size: l.bidirectional ? 0.12 : 0.08, weight: l.bidirectional ? 1 : 0.8, }); } }); graph.forEachNode((id, attrs) => { const size = 2 + Math.log(graph.degree(id) + 1) * 0.75 + (backlinks[id] > 1 ? 0.5 : 0); graph.setNodeAttribute(id, "size", size); }); function hslToHex(h, s, l) { l /= 100; const a = (s * Math.min(l, 1 - l)) / 100; const f = n => { const k = (n + h / 30) % 12; const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); return Math.round(255 * color).toString(16).padStart(2, "0"); }; return `#${f(0)}${f(8)}${f(4)}`; } const nodeByLink = {}; graph.forEachNode((id, attrs) => { nodeByLink[attrs.lnk] = id; }); // ── GLOBAL HELPER FUNCTIONS ──────────── const GRAPH_STATE = { IDLE: "idle", HIGHLIGHTED: "highlighted", SEARCHED: "searched" }; let _graphState = GRAPH_STATE.IDLE; let _activeNodes = new Set(); let _activeEdges = new Set(); let _focusNodeId = null; let _searchTerm = ""; function clearHighlight() { _focusNodeId = null; _searchTerm = ""; _activeNodes.clear(); _activeEdges.clear(); _graphState = GRAPH_STATE.IDLE; renderer.refresh(); } function centreOnNode(nodeId) { const nodeDisplayData = renderer.getNodeDisplayData(nodeId); if (!nodeDisplayData) return; renderer.getCamera().animate({ x: nodeDisplayData.x, y: nodeDisplayData.y, ratio: 0.6 }, { duration: 600 }); } function centreOnGraph() { renderer.getCamera().animate({ x: 0.5, y: 0.5, ratio: isMobile ? 1.0 : 0.9 }, { duration: 400 }); }
Sidebar
Handles sidebar content fetching, tooltip positioning, resize handle drag, and menu toggle. Uses AbortController to cancel superseded fetch requests.
const sidebar = document.getElementById("graph-sidebar"); const sidebarContent = document.getElementById("graph-sidebar-content"); const menuBtn = document.getElementById("graph-menu-btn"); const menu = document.getElementById("graph-menu"); if (window._sidebarCache) window._sidebarCache.clear(); window._sidebarCache = new Map(); if (window._sidebarListenerAbort) window._sidebarListenerAbort.abort(); window._sidebarListenerAbort = new AbortController(); const listenerSignal = { signal: window._sidebarListenerAbort.signal }; const tooltip = document.createElement("div"); tooltip.className = "graph-link-tooltip"; document.body.appendChild(tooltip); sidebarContent.addEventListener("click", (e) => { const a = e.target.closest("a[href^='/docs/']"); if (!a) return; e.preventDefault(); tooltip.style.display = "none"; const targetLnk = a.getAttribute("href").replace(/^\/docs\//, "").replace(/\/$/, ""); const targetNode = nodeByLink[targetLnk]; if (targetNode) { showNode(targetNode); syncFocusUrl(graph.getNodeAttribute(targetNode, "lnk")); } }, listenerSignal); function positionTooltip(anchor) { const rect = anchor.getBoundingClientRect(); const tw = tooltip.offsetWidth; const th = tooltip.offsetHeight; const margin = 8; // preferred: above the link, centered horizontally let left = rect.left + (rect.width / 2) - (tw / 2); let top = rect.top - th - margin; // flip to bottom if not enough space above if (top < margin) { top = rect.bottom + margin; } // clamp horizontally to stay within viewport left = Math.max(margin, Math.min(left, window.innerWidth - tw - margin)); tooltip.style.left = left + "px"; tooltip.style.top = top + "px"; } sidebarContent.addEventListener("mouseover", (e) => { const a = e.target.closest("a[href^='/docs/']"); if (!a) { tooltip.style.display = "none"; return; } const targetLnk = a.getAttribute("href").replace(/^\/docs\//, "").replace(/\/$/, ""); const targetNode = nodeByLink[targetLnk]; if (!targetNode) return; const excerpt = graph.getNodeAttribute(targetNode, "excerpt"); if (excerpt) { tooltip.textContent = excerpt; tooltip.style.display = "block"; positionTooltip(a); } }, listenerSignal); sidebarContent.addEventListener("mouseout", (e) => { if (!sidebarContent.contains(e.relatedTarget)) { tooltip.style.display = "none"; } }, listenerSignal); // Includes code for Search integration if (menuBtn) { menuBtn.addEventListener("click", () => { const isHidden = getComputedStyle(menu).display === "none"; menu.style.display = isHidden ? "block" : "none"; if (!isHidden) { document.getElementById("elbi-input").value = ""; document.getElementById("elbi-results").innerHTML = ""; if (currentNode) showNode(currentNode); else focusByTerm(""); } }); } const resizeHandle = document.getElementById("graph-resize-handle"); resizeHandle.addEventListener("mousedown", (e) => { e.preventDefault(); document.body.classList.add("is-resizing"); renderer.getCamera().disable(); let _resizeFrame = null; function onResizeDrag(e) { if (_resizeFrame) cancelAnimationFrame(_resizeFrame); _resizeFrame = requestAnimationFrame(() => { const newWidth = e.clientX; if (newWidth > 150 && newWidth < 600) { sidebar.style.width = newWidth + "px"; sidebar.style.minWidth = newWidth + "px"; } _resizeFrame = null; }); } function onResizeEnd() { document.body.classList.remove("is-resizing"); renderer.getCamera().enable(); renderer.resize(); if (currentNode) centreOnNode(currentNode); else centreOnGraph(); document.removeEventListener("mousemove", onResizeDrag); document.removeEventListener("mouseup", onResizeEnd); document.removeEventListener("mouseleave", onResizeEnd); } document.addEventListener("mousemove", onResizeDrag); document.addEventListener("mouseup", onResizeEnd); document.addEventListener("mouseleave", onResizeEnd); }, listenerSignal); function updateSidebarContent(path) { if (window._sidebarCache.has(path)) { sidebarContent.innerHTML = window._sidebarCache.get(path); return; } if (window._sidebarAbort) window._sidebarAbort.abort(); const controller = new AbortController(); window._sidebarAbort = controller; sidebarContent.innerHTML = "Loading..."; fetch(path, { signal: controller.signal }) .then(r => r.text()) .then(html => { if (window._sidebarAbort !== controller) return; const doc = new DOMParser().parseFromString(html, "text/html"); doc.querySelectorAll("script").forEach(el => el.remove()); const title = doc.getElementById("page-title"); const body = doc.getElementById("page-body") ?? doc.querySelector("main.content") ?? doc.body; body.querySelectorAll("img, video, audio, iframe").forEach(el => el.remove()); const processedHTML = (title ? title.outerHTML : "") + body.innerHTML; if (window._sidebarCache.size > 50) window._sidebarCache.delete(window._sidebarCache.keys().next().value); window._sidebarCache.set(path, processedHTML); sidebarContent.innerHTML = processedHTML; }) .catch(e => { if (e.name === 'AbortError' || controller.signal.aborted) return; throw e; }); } function resetGraph() { currentNode = null; clearHighlight(); graph.forEachNode((nodeId) => { graph.setNodeAttribute(nodeId, "x", graph.getNodeAttribute(nodeId, "origX")); graph.setNodeAttribute(nodeId, "y", graph.getNodeAttribute(nodeId, "origY")); }); renderer.refresh(); updateSidebarContent("/graph-info.html"); } function syncFocusUrl(lnk) { const url = new URL(window.location.href); url.searchParams.set("focus", lnk); history.replaceState(null, "", url); } function focusByTerm(term) { if (!term || term.length < 2) { clearHighlight(); return; } const lowerTerm = term.toLowerCase(); _searchTerm = term; _activeNodes = new Set(); _activeEdges.clear(); graph.forEachNode((id, attrs) => { const label = (attrs.label || "").toLowerCase(); const link = (attrs.lnk || "").toLowerCase(); if (label.includes(lowerTerm) || (link && link.includes(lowerTerm))) _activeNodes.add(id); }); _graphState = GRAPH_STATE.SEARCHED; renderer.refresh(); }
Sigma Renderer
Initialises the Sigma WebGL renderer. Visual state (dim, highlight, search) is handled entirely via nodeReducer and edgeReducer — Graphology holds only structural data and is never mutated for rendering purposes.
const container = document.getElementById("main-graph"); const renderer = new Sigma(graph, container, { defaultNodeColor: theme.nodeDefault, defaultEdgeColor: theme.edgeDefault, labelColor: { color: theme.label }, labelFont: "system-ui, sans-serif", labelSize: isMobile ? 9 : 10, labelWeight: "400", labelRenderedSizeThreshold: isMobile ? 10 : 5, renderEdgeLabels: false, stagePadding: isMobile ? 10 : 40, zoomToSizeRatioFunction: () => 1, zOrderedRendering: true, nodeReducer: (id, attrs) => { if (_graphState === GRAPH_STATE.IDLE) return attrs; if (_activeNodes.has(id)) return { ...attrs, size: attrs.size * 1.5, forceLabel: true, zIndex: id === _focusNodeId ? 2 : 1, }; return { ...attrs, color: theme.nodeDim, size: attrs.size * 0.5, label: null, forceLabel: false, zIndex: 0 }; }, edgeReducer: (eid, attrs) => { if (_graphState === GRAPH_STATE.IDLE) return attrs; if (_activeEdges.has(eid)) return { ...attrs, color: theme.edgeHi, size: 0.4 }; return { ...attrs, color: theme.edgeDim, size: 0.1 }; }, hoverRenderer: (context, data) => { context.beginPath(); context.arc(data.x, data.y, data.size + 3, 0, Math.PI * 2); context.fillStyle = data.color; context.fill(); context.strokeStyle = "#ffffff"; context.lineWidth = 1.5; context.stroke(); }, }); window._graphRenderer = renderer; renderer.getCamera().setState({ x: 0.5, y: 0.5, ratio: isMobile ? 1.0 : 0.9 });
Hover Interaction
enterNode is RAF-throttled to cap highlight updates at display refresh rate. GRAPH_STATE tracks whether the graph is idle, highlighted, or searched so leaveNode restores the correct visual state.
let currentNode = null; let dragNodeId = null; function endDrag() { renderer.getCamera().enable(); dragNodeId = null; } function setHighlight(focusNodeId) { _focusNodeId = focusNodeId; const newNodes = new Set([focusNodeId]); graph.forEachNeighbor(focusNodeId, nid => newNodes.add(nid)); _activeNodes = newNodes; _activeEdges = new Set(graph.edges(focusNodeId)); _graphState = GRAPH_STATE.HIGHLIGHTED; renderer.refresh(); } if (!isMobile) { let _enterFrame = null; renderer.on("enterNode", ({ node }) => { if (dragNodeId) return; if (_enterFrame) cancelAnimationFrame(_enterFrame); _enterFrame = requestAnimationFrame(() => { setHighlight(node); _enterFrame = null; }); }); renderer.on("leaveNode", () => { if (dragNodeId) return; if (currentNode) return; if (_graphState === GRAPH_STATE.SEARCHED) { focusByTerm(_searchTerm); return; } clearHighlight(); }); } function showNode(nodeId) { currentNode = nodeId; updateSidebarContent(`/docs/${graph.getNodeAttribute(nodeId, "lnk")}/`); setHighlight(nodeId); centreOnNode(nodeId); } renderer.on("clickNode", ({ node }) => { showNode(node) syncFocusUrl(graph.getNodeAttribute(node, "lnk")); }); renderer.on("downNode", ({ node }) => { dragNodeId = node; renderer.getCamera().disable(); }); renderer.getMouseCaptor().on("mousemove", (event) => { if (!dragNodeId) return; const { x, y } = renderer.viewportToGraph({ x: event.x, y: event.y }); graph.setNodeAttribute(dragNodeId, "x", x); graph.setNodeAttribute(dragNodeId, "y", y); renderer.refresh(); }); renderer.getMouseCaptor().on("mouseup", () => { if (!dragNodeId) return; endDrag(); }); renderer.getMouseCaptor().on("mouseleave", endDrag);
Focus Integration
Reads the ?focus= URL parameter on load. If present, highlights matching nodes via focusByTerm and navigates the sidebar to the exact node. Allows direct linking to a specific node from external pages.
const focusId = new URLSearchParams(window.location.search).get('focus'); if (focusId) { focusByTerm(focusId); const targetNode = graph.findNode((id, attrs) => attrs.lnk && attrs.lnk.toLowerCase() === focusId.toLowerCase() ); if (targetNode) showNode(targetNode); }
Home Button
Resets graph to initial state — clears highlight, search input, and the focus URL parameter. Loads the default graph-info.html into the sidebar.
const homeBtn = document.getElementById("graph-home-btn"); if (homeBtn) { homeBtn.addEventListener("click", () => { resetGraph(); const searchInput = document.getElementById("elbi-input"); const searchResults = document.getElementById("elbi-results"); if (searchInput) searchInput.value = ""; if (searchResults) searchResults.innerHTML = ""; const url = new URL(window.location.href); url.searchParams.delete("focus"); history.replaceState(null, "", url); centreOnGraph(); }); } if (!focusId) { updateSidebarContent("/graph-info.html"); }
Resize
Re-fits the camera on viewport resize. Centres on the pinned node if one is active, otherwise recentres on the full graph.
window.addEventListener("resize", () => { renderer.resize(); if (currentNode) centreOnNode(currentNode); else centreOnGraph(); }, listenerSignal);
Public API
These API are used by search.js and external callers. Any new graph hooks needs to be added here.
window.nodeByLink = nodeByLink; window.showNode = showNode; window.graphMenu = menu || null; window.focusByTerm = focusByTerm; window.centreOnGraph = centreOnGraph; window.resetGraph = resetGraph; window.getCurrentNode = () => currentNode; window.getNodeLnk = (nodeId) => graph.getNodeAttribute(nodeId, "lnk");
Assembly
The script runs inside an async IIFE to allow top-level await for the graph.json fetch. The guard section runs synchronously before it.
The complete tangled version can be found here.
<<guard>> (async () => { // palette → metrics → sidebar → sigma → hover // focus → home_button → resize → public_api })();