262 lines
9 KiB
JavaScript
262 lines
9 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
},
|
|
};
|
|
})();
|