// Knowledge graph renderer: interactive 2D force graph with // zoom/pan, search, neighbor highlighting, curved links with arrows, // responsive resize, and type/relationship legends. (() => { const D3_SRC = "/assets/d3.min.js"; let d3Loading = null; function ensureD3() { if (window.d3) return Promise.resolve(); if (d3Loading) return d3Loading; d3Loading = new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = D3_SRC; s.async = true; s.onload = () => resolve(); s.onerror = () => reject(new Error("Failed to load D3")); document.head.appendChild(s); }); return d3Loading; } // Simple palettes (kept deterministic across renders) const PALETTE_A = [ "#60A5FA", "#34D399", "#F59E0B", "#A78BFA", "#F472B6", "#F87171", "#22D3EE", "#84CC16", "#FB7185", ]; const PALETTE_B = [ "#94A3B8", "#A3A3A3", "#9CA3AF", "#C084FC", "#FDA4AF", "#FCA5A5", "#67E8F9", "#A3E635", "#FDBA74", ]; function buildMap(values) { const unique = Array.from(new Set(values.filter(Boolean))); const map = new Map(); unique.forEach((v, i) => map.set(v, PALETTE_A[i % PALETTE_A.length])); return map; } function linkColorMap(values) { const unique = Array.from(new Set(values.filter(Boolean))); const map = new Map(); unique.forEach((v, i) => map.set(v, PALETTE_B[i % PALETTE_B.length])); return map; } function radiusForDegree(deg) { const d = Math.max(0, +deg || 0); const r = 6 + Math.sqrt(d) * 3; return Math.max(6, Math.min(r, 24)); } function curvedPath(d) { const sx = d.source.x, sy = d.source.y, tx = d.target.x, ty = d.target.y; const dx = tx - sx, dy = ty - sy; const mx = (sx + tx) / 2; const my = (sy + ty) / 2; const nx = -dy / (Math.hypot(dx, dy) || 1); const ny = dx / (Math.hypot(dx, dy) || 1); const cx = mx + nx * 20; const cy = my + ny * 20; return `M ${sx},${sy} Q ${cx},${cy} ${tx},${ty}`; } function buildAdjacency(nodes, links) { const idToNode = new Map(nodes.map((n) => [n.id, n])); const neighbors = new Map(); nodes.forEach((n) => neighbors.set(n.id, new Set())); links.forEach((l) => { const s = typeof l.source === "object" ? l.source.id : l.source; const t = typeof l.target === "object" ? l.target.id : l.target; if (neighbors.has(s)) neighbors.get(s).add(t); if (neighbors.has(t)) neighbors.get(t).add(s); }); return { idToNode, neighbors }; } function buildColorMaps(nodes, links) { return { typeColor: buildMap(nodes.map((n) => n.entity_type)), relColor: linkColorMap(links.map((l) => l.relationship_type)), }; } // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- async function loadGraphData(container) { const et = container.dataset.entityType || ""; const cc = container.dataset.contentCategory || ""; const qs = new URLSearchParams(); if (et) qs.set("entity_type", et); if (cc) qs.set("content_category", cc); const url = "/knowledge/graph.json" + (qs.toString() ? "?" + qs.toString() : ""); const res = await fetch(url, { headers: { Accept: "application/json" } }); if (!res.ok) throw new Error("Failed to load graph data"); return res.json(); } // --------------------------------------------------------------------------- // SVG scaffolding // --------------------------------------------------------------------------- function buildSvg(container, width, height) { const svg = d3 .select(container) .append("svg") .attr("width", "100%") .attr("height", height) .attr("viewBox", [0, 0, width, height]) .attr( "style", "cursor: grab; touch-action: none; background: transparent;", ); return { svg, g: svg.append("g"), defs: svg.append("defs") }; } function createArrowMarker(defs, relationshipType, color) { const id = `arrow-${relationshipType.replace(/[^a-z0-9_-]/gi, "_")}`; if (document.getElementById(id)) return id; defs .append("marker") .attr("id", id) .attr("viewBox", "0 -5 10 10") .attr("refX", 16) .attr("refY", 0) .attr("markerWidth", 6) .attr("markerHeight", 6) .attr("orient", "auto") .append("path") .attr("d", "M0,-5L10,0L0,5") .attr("fill", color); return id; } // --------------------------------------------------------------------------- // Simulation // --------------------------------------------------------------------------- function createSimulation(nodes, links, width, height) { return d3 .forceSimulation(nodes) .force( "link", d3 .forceLink(links) .id((d) => d.id) .distance(70) .strength(0.5), ) .force("charge", d3.forceManyBody().strength(-220)) .force("center", d3.forceCenter(width / 2, height / 2)) .force( "collision", d3.forceCollide().radius((d) => radiusForDegree(d.degree) + 6), ) .force("y", d3.forceY(height / 2).strength(0.02)) .force("x", d3.forceX(width / 2).strength(0.02)); } // --------------------------------------------------------------------------- // Drawing helpers // --------------------------------------------------------------------------- function drawLinks(g, links, relColor, markerFor) { return g .append("g") .attr("fill", "none") .attr("stroke-opacity", 0.7) .selectAll("path") .data(links) .join("path") .attr("stroke", (d) => relColor.get(d.relationship_type) || "#CBD5E1") .attr("stroke-width", 1.5) .attr("marker-end", (d) => markerFor( d.relationship_type || "rel", relColor.get(d.relationship_type) || "#CBD5E1", ), ); } function drawLinkLabels(g, links) { return g .append("g") .selectAll("text") .data(links) .join("text") .attr("font-size", 9) .attr("fill", "#475569") .attr("text-anchor", "middle") .attr("opacity", 0.7) .text((d) => d.relationship_type || ""); } function drawNodes( g, nodes, typeColor, { setHighlight, clearHighlight }, simulation, ) { const node = g .append("g") .attr("stroke", "#fff") .attr("stroke-width", 1.5) .selectAll("circle") .data(nodes) .join("circle") .attr("r", (d) => radiusForDegree(d.degree)) .attr("fill", (d) => typeColor.get(d.entity_type) || "#94A3B8") .attr("cursor", "pointer") .on("mouseenter", (_evt, d) => { setHighlight(d); }) .on("mouseleave", () => { clearHighlight(); }) .on("click", function (_evt, d) { if (d.fx == null) { d.fx = d.x; d.fy = d.y; this.setAttribute("data-pinned", "true"); } else { d.fx = null; d.fy = null; this.removeAttribute("data-pinned"); } }) .call( d3 .drag() .on("start", (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on("drag", (event, d) => { d.fx = event.x; d.fy = event.y; }) .on("end", (event, _d) => { if (!event.active) simulation.alphaTarget(0); }), ); node .append("title") .text((d) => `${d.name} • ${d.entity_type} • deg ${d.degree}`); return node; } function drawLabels(g, nodes) { return g .append("g") .selectAll("text") .data(nodes) .join("text") .text((d) => d.name) .attr("font-size", 11) .attr("fill", "#111827") .attr("stroke", "white") .attr("paint-order", "stroke") .attr("stroke-width", 3) .attr("dx", (d) => radiusForDegree(d.degree) + 6) .attr("dy", 4); } // --------------------------------------------------------------------------- // Highlight / search // --------------------------------------------------------------------------- function createHighlighting(neighbors, relColor, markerFor) { let node, label, link, linkLabel; function setHighlight(n) { const ns = neighbors.get(n.id) || new Set(); node.attr("opacity", (d) => (d.id === n.id || ns.has(d.id) ? 1 : 0.15)); label.attr("opacity", (d) => (d.id === n.id || ns.has(d.id) ? 1 : 0.15)); link .attr("stroke-opacity", (d) => { const s = typeof d.source === "object" ? d.source.id : d.source; const t = typeof d.target === "object" ? d.target.id : d.target; return s === n.id || t === n.id || (ns.has(s) && ns.has(t)) ? 0.9 : 0.05; }) .attr("marker-end", (d) => { const c = relColor.get(d.relationship_type) || "#CBD5E1"; return markerFor(d.relationship_type || "rel", c); }); linkLabel.attr("opacity", (d) => { const s = typeof d.source === "object" ? d.source.id : d.source; const t = typeof d.target === "object" ? d.target.id : d.target; return s === n.id || t === n.id ? 0.9 : 0.05; }); } function clearHighlight() { node.attr("opacity", 1); label.attr("opacity", 1); link.attr("stroke-opacity", 0.7); linkLabel.attr("opacity", 0.7); } return { bind: (_node, _label, _link, _linkLabel) => { node = _node; label = _label; link = _link; linkLabel = _linkLabel; }, setHighlight, clearHighlight, }; } // --------------------------------------------------------------------------- // Zoom // --------------------------------------------------------------------------- function createZoom(svg, g) { const zoom = d3 .zoom() .scaleExtent([0.25, 5]) .on("zoom", (event) => { g.attr("transform", event.transform); }); svg.call(zoom); function zoomTo(k, center) { const transform = d3.zoomIdentity .translate(center[0] - k * center[0], center[1] - k * center[1]) .scale(k); svg.transition().duration(250).call(zoom.transform, transform); } return { zoom, zoomTo }; } // --------------------------------------------------------------------------- // Resize // --------------------------------------------------------------------------- function attachResize( container, svg, simulation, fallbackWidth, fallbackHeight, ) { const ro = new ResizeObserver(() => { const w = container.clientWidth || fallbackWidth; const h = container.clientHeight || fallbackHeight; svg.attr("viewBox", [0, 0, w, h]).attr("height", h); simulation.force("center", d3.forceCenter(w / 2, h / 2)); simulation.alpha(0.3).restart(); }); ro.observe(container); } // --------------------------------------------------------------------------- // Overlay controls // --------------------------------------------------------------------------- function attachOverlay( container, { onSearch, onToggleNames, onToggleEdgeLabels, onCenter }, ) { const overlay = document.createElement("div"); overlay.className = "kg-overlay"; const primaryRow = document.createElement("div"); primaryRow.className = "kg-control-row kg-control-row-primary"; const secondaryRow = document.createElement("div"); secondaryRow.className = "kg-control-row kg-control-row-secondary"; const input = document.createElement("input"); input.type = "text"; input.placeholder = "Search nodes…"; input.className = "nb-input kg-search-input"; input.addEventListener("keydown", (e) => { if (e.key === "Enter") onSearch && onSearch(input.value.trim()); }); const searchBtn = document.createElement("button"); searchBtn.className = "nb-btn btn-xs nb-cta kg-search-btn"; searchBtn.textContent = "Go"; searchBtn.addEventListener( "click", () => onSearch && onSearch(input.value.trim()), ); const namesToggle = document.createElement("button"); namesToggle.className = "nb-btn btn-xs kg-toggle"; namesToggle.type = "button"; namesToggle.textContent = "Names"; namesToggle.addEventListener( "click", () => onToggleNames && onToggleNames(), ); const labelToggle = document.createElement("button"); labelToggle.className = "nb-btn btn-xs kg-toggle"; labelToggle.type = "button"; labelToggle.textContent = "Labels"; labelToggle.addEventListener( "click", () => onToggleEdgeLabels && onToggleEdgeLabels(), ); const centerBtn = document.createElement("button"); centerBtn.className = "nb-btn btn-xs"; centerBtn.textContent = "Center"; centerBtn.addEventListener("click", () => onCenter && onCenter()); primaryRow.appendChild(input); primaryRow.appendChild(searchBtn); secondaryRow.appendChild(namesToggle); secondaryRow.appendChild(labelToggle); secondaryRow.appendChild(centerBtn); overlay.appendChild(primaryRow); overlay.appendChild(secondaryRow); container.style.position = "relative"; container.appendChild(overlay); return { input, overlay, namesToggle, labelToggle }; } // --------------------------------------------------------------------------- // Legends // --------------------------------------------------------------------------- function attachLegends(container, typeColor, relColor) { const wrap = document.createElement("div"); wrap.className = "kg-legend"; function section(title, items) { const sec = document.createElement("div"); sec.className = "nb-card kg-legend-card"; const h = document.createElement("div"); h.className = "kg-legend-heading"; h.textContent = title; sec.appendChild(h); items.forEach(([label, color]) => { const row = document.createElement("div"); row.className = "kg-legend-row"; const sw = document.createElement("span"); sw.style.background = color; sw.style.width = "12px"; sw.style.height = "12px"; sw.style.border = "2px solid #000"; const t = document.createElement("span"); t.textContent = label || "—"; row.appendChild(sw); row.appendChild(t); sec.appendChild(row); }); return sec; } const typeItems = Array.from(typeColor.entries()); if (typeItems.length) wrap.appendChild(section("Entity Type", typeItems)); const relItems = Array.from(relColor.entries()); if (relItems.length) wrap.appendChild(section("Relationship", relItems)); container.appendChild(wrap); return wrap; } // --------------------------------------------------------------------------- // Main orchestrator // --------------------------------------------------------------------------- async function renderKnowledgeGraph(root) { const container = (root || document).querySelector("#knowledge-graph"); if (!container) return; await ensureD3().catch(() => { const err = document.createElement("div"); err.className = "alert alert-error"; err.textContent = "Unable to load graph library (D3)."; container.appendChild(err); }); if (!window.d3) return; container.replaceChildren(); const width = container.clientWidth || 800; const height = container.clientHeight || 600; // 1. Load data let data; try { data = await loadGraphData(container); } catch (_e) { const err = document.createElement("div"); err.className = "alert alert-error"; err.textContent = "Unable to load graph data."; container.appendChild(err); return; } // 2. Color maps + adjacency const { typeColor, relColor } = buildColorMaps(data.nodes, data.links); const { neighbors } = buildAdjacency(data.nodes, data.links); // 3. Build SVG scaffolding const { svg, g, defs } = buildSvg(container, width, height); // 4. Arrow marker factory const markerFor = (rel, color) => `url(#${createArrowMarker(defs, rel, color)})`; // 5. Simulation const simulation = createSimulation(data.nodes, data.links, width, height); // 6. Highlighting (created before drawing so callbacks exist) const highlighting = createHighlighting(neighbors, relColor, markerFor); // 7. Draw elements const link = drawLinks(g, data.links, relColor, markerFor); const linkLabel = drawLinkLabels(g, data.links); const node = drawNodes(g, data.nodes, typeColor, highlighting, simulation); const label = drawLabels(g, data.nodes); highlighting.bind(node, label, link, linkLabel); // 8. Zoom (single instance) const { zoom, zoomTo } = createZoom(svg, g); // 9. Search helper function centerOnNode(n) { const k = 1.5; // recenter: svg dimensions / 2 minus k * node position const transform = d3.zoomIdentity .translate(width / 2 - k * n.x, height / 2 - k * n.y) .scale(k); svg.transition().duration(350).call(zoom.transform, transform); } function focusSearch(query) { if (!query) return; const q = query.toLowerCase(); const found = data.nodes.find((n) => (n.name || "").toLowerCase().includes(q), ); if (found) { highlighting.setHighlight(found); centerOnNode(found); } } // 10. Overlay controls let namesVisible = true; let edgeLabelsVisible = true; const togglePressedState = (button, state) => { if (!button) return; button.setAttribute("aria-pressed", state ? "true" : "false"); button.classList.toggle("kg-toggle-active", !!state); }; const { namesToggle, labelToggle } = attachOverlay(container, { onSearch: (q) => focusSearch(q), onToggleNames: () => { namesVisible = !namesVisible; label.style("display", namesVisible ? null : "none"); togglePressedState(namesToggle, namesVisible); }, onToggleEdgeLabels: () => { edgeLabelsVisible = !edgeLabelsVisible; linkLabel.style("display", edgeLabelsVisible ? null : "none"); togglePressedState(labelToggle, edgeLabelsVisible); }, onCenter: () => zoomTo(1, [width / 2, height / 2]), }); togglePressedState(namesToggle, namesVisible); togglePressedState(labelToggle, edgeLabelsVisible); // 11. Tick update simulation.on("tick", () => { link.attr("d", curvedPath); node.attr("cx", (d) => d.x).attr("cy", (d) => d.y); label.attr("x", (d) => d.x).attr("y", (d) => d.y); linkLabel .attr("x", (d) => (d.source.x + d.target.x) / 2) .attr("y", (d) => (d.source.y + d.target.y) / 2); }); // 12. Legends + resize attachLegends(container, typeColor, relColor); attachResize(container, svg, simulation, width, height); } // --------------------------------------------------------------------------- // Bootstrap // --------------------------------------------------------------------------- function tryRender(root) { const container = (root || document).querySelector("#knowledge-graph"); if (container) renderKnowledgeGraph(root); } window.renderKnowledgeGraph = () => renderKnowledgeGraph(document); document.addEventListener("DOMContentLoaded", () => tryRender(document)); document.body.addEventListener("knowledge-graph-refresh", () => { tryRender(document); }); document.body.addEventListener("htmx:afterSettle", (evt) => { tryRender(evt && evt.target ? evt.target : document); }); })();