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 });
}

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