/** * content-graph-shim.js * * Thin bridge between Leptos/WASM and Cytoscape.js. * Exposes window.ContentGraph with a single render() entry point. * * Called from GraphView component after mount: * window.ContentGraph.render(containerId, focusNodeId, theme) * * Reads graph data from: * * * Requires (loaded before this script via assets pipeline): * - cytoscape.min.js * - cytoscape-cose-bilkent.min.js */ (function () { 'use strict'; // ── Theme tokens ──────────────────────────────────────────────────────────── function buildPalette(isDark) { return { // Content node variants contentFill: isDark ? '#3b82f6' : '#2563eb', // Ontology node variants by level axiomFill: isDark ? '#8b5cf6' : '#7c3aed', tensionFill: isDark ? '#f59e0b' : '#d97706', practiceFill: isDark ? '#10b981' : '#059669', projectFill: isDark ? '#06b6d4' : '#0891b2', // ADR adrFill: isDark ? '#f97316' : '#ea580c', // Ego node ring egoStroke: isDark ? '#facc15' : '#ca8a04', // Edge colours by kind group declaredEdge: isDark ? '#6b7280' : '#9ca3af', autoEdge: isDark ? '#4b5563' : '#d1d5db', implicitEdge: isDark ? '#374151' : '#e5e7eb', // Labels labelColor: isDark ? '#f9fafb' : '#111827', labelBg: isDark ? 'rgba(17,24,39,0.75)' : 'rgba(255,255,255,0.75)', // Background bg: isDark ? '#111827' : '#ffffff', }; } // ── Cytoscape stylesheet ───────────────────────────────────────────────────── function buildStylesheet(palette, egoId) { const label = { label: 'data(title)', color: palette.labelColor, 'background-color':palette.labelBg, 'background-opacity': 0.75, 'background-padding': '3px', 'font-size': '11px', 'text-valign': 'bottom', 'text-halign': 'center', 'text-margin-y': '4px', }; return [ // Default node { selector: 'node', style: { ...label, shape: 'ellipse', width: '36px', height: '36px', 'background-color': palette.contentFill, 'border-width': '0px', 'cursor': 'pointer', }, }, // Ego node (focused) { selector: `node[id="${egoId}"]`, style: { width: '48px', height: '48px', 'border-width': '3px', 'border-color': palette.egoStroke, 'font-size': '13px', 'font-weight': 'bold', }, }, // Ontology variants { selector: 'node[level="Axiom"]', style: { 'background-color': palette.axiomFill, shape: 'diamond' } }, { selector: 'node[level="Tension"]', style: { 'background-color': palette.tensionFill, shape: 'triangle' } }, { selector: 'node[level="Practice"]', style: { 'background-color': palette.practiceFill, shape: 'rectangle' } }, { selector: 'node[level="Project"]', style: { 'background-color': palette.projectFill, shape: 'rectangle' } }, // ADR { selector: 'node[type="adr"]', style: { 'background-color': palette.adrFill, shape: 'pentagon' } }, // Hover { selector: 'node:selected', style: { 'border-width': '3px', 'border-color': palette.egoStroke }, }, // Default edge { selector: 'edge', style: { width: '1.5px', 'line-color': palette.declaredEdge, 'target-arrow-color':palette.declaredEdge, 'target-arrow-shape':'triangle', 'curve-style': 'bezier', opacity: 0.7, }, }, // Auto-backlink edges (lighter) { selector: 'edge[source="auto_backlink"]', style: { 'line-color': palette.autoEdge, 'target-arrow-color': palette.autoEdge, opacity: 0.5 }, }, // Implicit / SharedTag edges (dashed, very light) { selector: 'edge[kind="shared_tag"]', style: { 'line-style': 'dashed', 'line-color': palette.implicitEdge, 'target-arrow-color':palette.implicitEdge, opacity: 0.35, }, }, ]; } // ── Element conversion ─────────────────────────────────────────────────────── function toCytoscapeElements(graphData) { const nodes = (graphData.nodes || []).map(n => ({ data: { id: n.id, title: n.title || n.name || n.id, type: n.type, level: n.level || null, url: n.url || null, instance: n.instance || null, }, })); const edges = (graphData.edges || []).map((e, i) => ({ data: { id: `e-${i}`, source: e.from, target: e.to, kind: e.kind, source_type: e.source, // "declared" | "auto_backlink" | "implicit" }, })); return [...nodes, ...edges]; } // ── Layout config ──────────────────────────────────────────────────────────── function buildLayout(nodeCount) { // cose-bilkent for larger graphs; grid fallback for tiny graphs if (nodeCount <= 4) { return { name: 'circle', animate: false, padding: 40 }; } return { name: 'cose-bilkent', animate: false, nodeDimensionsIncludeLabels: true, idealEdgeLength: 100, nodeRepulsion: 8000, padding: 30, }; } // ── Instances registry ─────────────────────────────────────────────────────── // Keeps track of active cy instances so they can be destroyed on re-render. const _instances = new Map(); // ── Public API ─────────────────────────────────────────────────────────────── window.ContentGraph = { /** * Render the knowledge graph into `containerId`. * * @param {string} containerId - DOM id of the container element * @param {string} focusNodeId - node id to highlight/center; may be empty * @param {string} theme - "dark" | "light" */ render(containerId, focusNodeId, theme) { const container = document.getElementById(containerId); if (!container) { console.warn('[ContentGraph] container not found:', containerId); return; } // Destroy previous instance if re-rendering if (_instances.has(containerId)) { _instances.get(containerId).destroy(); _instances.delete(containerId); } // Read graph data embedded by SSR const dataEl = document.getElementById('content-graph-data'); if (!dataEl) { console.warn('[ContentGraph] #content-graph-data not found'); return; } let graphData; try { graphData = JSON.parse(dataEl.textContent); } catch (e) { console.error('[ContentGraph] failed to parse graph data:', e); return; } const isDark = theme === 'dark'; const palette = buildPalette(isDark); const egoId = focusNodeId || ''; const cy = window.cytoscape({ container, elements: toCytoscapeElements(graphData), style: buildStylesheet(palette, egoId), layout: buildLayout((graphData.nodes || []).length), minZoom: 0.3, maxZoom: 4, }); _instances.set(containerId, cy); // Navigate to content URL on node tap cy.on('tap', 'node', evt => { const url = evt.target.data('url'); if (url) window.location.href = url; }); // Pointer cursor on hover cy.on('mouseover', 'node[?url]', () => { container.style.cursor = 'pointer'; }); cy.on('mouseout', 'node', () => { container.style.cursor = 'default'; }); // Focus and center on ego node if (egoId) { const egoNode = cy.$(`#${CSS.escape(egoId)}`); if (egoNode.length) { cy.animate({ fit: { eles: egoNode.neighborhood().add(egoNode), padding: 60 } }, { duration: 400 }); } } }, /** * Destroy the Cytoscape instance for a given container (cleanup on unmount). */ destroy(containerId) { if (_instances.has(containerId)) { _instances.get(containerId).destroy(); _instances.delete(containerId); } }, }; })();