graph.js
The entire 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
})();
© Prabu Anand K 2020-2026