website-htmx-rustelo-code/site/public/js/content-graph-shim.js

263 lines
9 KiB
JavaScript
Raw Permalink Normal View History

2026-07-10 03:44:13 +01:00
/**
* 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:
* <script type="application/json" id="content-graph-data">{ nodes, edges }</script>
*
* 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);
}
},
};
})();