Update graph ui

This commit is contained in:
2026-03-30 21:38:18 +11:00
parent f5051ad1ea
commit 9f9f96e86f
+447 -197
View File
@@ -4,17 +4,86 @@
{% block extra_css %}
<style>
#sigma-container {
height: calc(100vh - 64px);
width: 100%;
background: #f8fafc;
/* ------------------------------------------------------------------
Full-bleed graph canvas that fills the viewport below the navbar.
base.html wraps content in div.container.mx-auto.mt-20.p-2
So the effective top offset is ~88px. We break out by using
negative margins so the graph can fill edge-to-edge.
------------------------------------------------------------------ */
#kg-page-wrapper {
margin: -8px calc(-50vw + 50%); /* cancel container padding & centering */
height: calc(100vh - 80px);
position: relative;
overflow: hidden;
background: #f1f5f9;
}
#sigma-canvas-wrapper {
position: absolute;
inset: 0;
background: #f1f5f9;
/* The container background shows through transparent WebGL pixels.
Do NOT set background on the individual canvas elements — sigma's
label canvas is transparent by design so you can see the WebGL
node/edge canvas beneath it. Making it opaque would hide everything. */
}
/* Side panel */
/* ------------------------------------------------------------------
Top toolbar — single compact row, never wraps
------------------------------------------------------------------ */
#kg-toolbar {
position: absolute;
top: 12px;
left: 12px;
z-index: 30;
display: flex;
align-items: center;
gap: 8px;
/* prevents toolbar from ever covering the node panel */
max-width: calc(100% - 320px);
}
/* ------------------------------------------------------------------
Bottom-left legend / filter bar
------------------------------------------------------------------ */
#kg-legend {
position: absolute;
bottom: 14px;
left: 12px;
z-index: 30;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(226, 232, 240, 0.9);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
max-width: calc(100% - 340px);
}
.filter-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
border: 1.5px solid transparent;
transition: opacity 0.15s;
user-select: none;
white-space: nowrap;
}
.filter-badge.inactive { opacity: 0.3; }
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
/* ------------------------------------------------------------------
Right side panel
------------------------------------------------------------------ */
#node-panel {
position: absolute;
top: 0;
@@ -26,95 +95,149 @@
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 20;
z-index: 30;
}
#node-panel.open {
transform: translateX(0);
}
/* Toolbar */
#kg-toolbar {
#node-panel.open { transform: translateX(0); }
/* ------------------------------------------------------------------
Layout-computing overlay (shown while ForceAtlas2 runs)
------------------------------------------------------------------ */
#graph-loading-overlay {
position: absolute;
top: 12px;
left: 12px;
inset: 0;
background: rgba(241, 245, 249, 0.88);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 20;
display: flex;
gap: 8px;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
max-width: calc(100% - 330px);
justify-content: center;
gap: 12px;
}
.filter-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
/* ------------------------------------------------------------------
Bottom-right zoom control
------------------------------------------------------------------ */
#kg-zoom-ctl {
position: absolute;
bottom: 14px;
right: 14px;
z-index: 30;
transition: right 0.25s ease;
}
/* style the range thumb */
#kg-zoom-ctl input[type=range] {
-webkit-appearance: none;
appearance: none;
width: 100px;
height: 4px;
border-radius: 2px;
background: #e2e8f0;
outline: none;
cursor: pointer;
border: 2px solid transparent;
transition: opacity 0.15s;
user-select: none;
}
.filter-badge.inactive { opacity: 0.4; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
#kg-zoom-ctl input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
#kg-zoom-ctl input[type=range]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
[x-cloak] { display: none !important; }
</style>
{% endblock %}
{% block content %}
<div id="sigma-container" x-data="kgApp()" x-init="init()">
<div id="kg-page-wrapper" x-data="kgApp()" x-init="init()">
<!-- Toolbar -->
<!-- ── Top toolbar (single row, never wraps) ───────────────────── -->
<div id="kg-toolbar">
<!-- Build button -->
<button
@click="showBuildModal = true"
class="inline-flex items-center gap-1.5 px-4 py-2 rounded bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium shadow transition duration-150 ease-in-out">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium shadow-sm transition flex-shrink-0">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{% trans "Build Graph" %}
</button>
<!-- Status badge -->
<span class="px-2.5 py-1 rounded-lg bg-white border border-gray-200 text-xs text-gray-600 shadow-sm"
<span class="px-2.5 py-1 rounded-full bg-white/90 border border-gray-200 text-xs text-gray-600 shadow-sm whitespace-nowrap flex-shrink-0"
x-text="statusText"></span>
<!-- Last built timestamp -->
{% if snapshot %}
{% with built_at=snapshot.completed_at|default:snapshot.created_at %}
{% if snapshot %}{% with built_at=snapshot.completed_at|default:snapshot.created_at %}
<a href="{% url 'jobs' %}?tab=knowledge_graph"
class="px-2.5 py-1 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 shadow-sm hover:bg-amber-100 transition"
class="px-2.5 py-1 rounded-full bg-amber-50/90 border border-amber-200 text-xs text-amber-700 shadow-sm hover:bg-amber-100 transition whitespace-nowrap flex-shrink-0 hidden sm:inline-flex"
title="{{ built_at|date:'Y-m-d H:i:s' }}">
{% trans "Last built" %}: {{ built_at|timesince }} {% trans "ago" %}
{{ built_at|timesince }} {% trans "ago" %}
</a>
{% endwith %}
{% endif %}
{% endwith %}{% endif %}
</div>
<!-- Node type filters -->
<!-- ── Sigma canvas ──────────────────────────────────────────────── -->
<div id="sigma-canvas-wrapper"></div>
<!-- ── Layout-computing overlay ──────────────────────────────────── -->
<div id="graph-loading-overlay" x-show="graphComputing" x-cloak>
<svg class="w-9 h-9 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
</svg>
<p class="text-sm font-semibold text-gray-700" x-text="statusText"></p>
<p class="text-xs text-gray-400">{% trans "Computing force-directed layout…" %}</p>
</div>
<!-- ── Zoom control (bottom-right) ─────────────────────────────────── -->
<div id="kg-zoom-ctl"
x-show="graphLoaded" x-cloak
:style="selectedNode ? 'right:314px' : 'right:14px'"
class="flex items-center gap-1 bg-white/95 backdrop-blur border border-gray-200 rounded-lg shadow-sm px-2 py-1.5">
<button @click="zoomOut()"
title="{% trans 'Zoom out' %}"
class="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500 text-base font-semibold leading-none select-none"></button>
<input type="range" min="0" max="100" step="1"
:value="zoomSlider"
@input="setZoomFromSlider(+$event.target.value)"
title="{% trans 'Zoom level' %}">
<button @click="zoomIn()"
title="{% trans 'Zoom in' %}"
class="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500 text-base font-semibold leading-none select-none">+</button>
<span class="text-xs text-gray-500 tabular-nums w-11 text-right"
x-text="Math.round(100 / (zoomRatio || 1)) + '%'"></span>
</div>
<!-- ── Bottom-left legend / type filters ─────────────────────────── -->
<div id="kg-legend" x-show="graphLoaded" x-cloak>
<template x-for="f in filters" :key="f.type">
<span class="filter-badge shadow-sm"
<span class="filter-badge"
:class="f.active ? '' : 'inactive'"
:style="`background:${f.color}22; border-color:${f.color}; color:${f.color};`"
:style="`background:${f.color}22; border-color:${f.color}; color:${f.color}`"
@click="toggleFilter(f.type)">
<span class="dot" :style="`background:${f.color}`"></span>
<span x-text="f.label"></span>
<span class="ml-0.5 font-normal opacity-70" x-text="'('+f.count+')'"></span>
<span x-text="f.label + ' (' + f.count + ')'"></span>
</span>
</template>
<!-- Reset view -->
<span class="h-4 w-px bg-gray-200 mx-0.5 flex-shrink-0"></span>
<button @click="resetCamera()"
class="px-2.5 py-1 rounded-lg bg-white border border-gray-200 text-xs text-gray-500 hover:text-gray-800 shadow-sm transition">
{% trans "Reset View" %}
class="px-2 py-0.5 text-xs text-gray-500 hover:text-gray-800 bg-white rounded border border-gray-200 transition flex-shrink-0">
{% trans "Reset" %}
</button>
</div>
<!-- Sigma canvas -->
<div id="sigma-canvas-wrapper"></div>
<!-- Side panel -->
<!-- ── Right side panel ──────────────────────────────────────────── -->
<div id="node-panel" :class="selectedNode ? 'open' : ''">
<div class="p-4 border-b border-gray-100 flex items-center justify-between">
<h3 class="font-semibold text-gray-800 text-sm">{% trans "Node Details" %}</h3>
@@ -125,40 +248,29 @@
</button>
</div>
<div x-show="selectedNode" class="p-4 space-y-3">
<!-- Type badge -->
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold"
:style="`background:${selectedNode?.color}22; color:${selectedNode?.color};`">
<span class="dot" :style="`background:${selectedNode?.color}`"></span>
<span x-text="selectedNode?.node_type?.toUpperCase()"></span>
</span>
<!-- Label -->
<p class="font-semibold text-gray-900 text-sm break-words" x-text="selectedNode?.label"></p>
<!-- Description -->
<p class="text-xs text-gray-500 break-words" x-show="selectedNode?.description"
x-text="selectedNode?.description?.slice(0,200) + (selectedNode?.description?.length > 200 ? '…' : '')"></p>
<!-- Original URL -->
<div x-show="selectedNode?.original_url">
<p class="text-xs text-gray-400 mb-0.5">{% trans "URL" %}</p>
<a :href="selectedNode?.original_url" target="_blank" rel="noopener noreferrer"
class="text-xs text-blue-500 hover:underline break-all"
x-text="selectedNode?.original_url?.slice(0,80) + (selectedNode?.original_url?.length > 80 ? '…' : '')"></a>
</div>
<!-- View item button -->
<a :href="selectedNode?.item_url" target="_blank" rel="noopener noreferrer"
x-show="selectedNode?.item_url"
class="inline-flex items-center gap-1.5 w-full justify-center px-3 py-2 rounded bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium transition duration-150 ease-in-out">
class="inline-flex items-center gap-1.5 w-full justify-center px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
{% trans "View Item" %}
</a>
<!-- Connected nodes -->
<div x-show="connectedNodes.length > 0">
<p class="text-xs font-semibold text-gray-500 mb-1">
{% trans "Connected" %} (<span x-text="connectedNodes.length"></span>)
@@ -179,8 +291,8 @@
</div>
</div>
<!-- Empty state -->
<div x-show="!graphLoaded && !building"
<!-- ── Empty state ───────────────────────────────────────────────── -->
<div x-show="!graphLoaded && !building && !graphComputing"
class="absolute inset-0 flex flex-col items-center justify-center gap-4 text-gray-400">
<svg class="w-16 h-16 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
@@ -188,17 +300,15 @@
</svg>
<p class="text-sm font-medium">{% trans "No knowledge graph yet." %}</p>
<button @click="showBuildModal = true"
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded transition duration-150 ease-in-out">
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition">
{% trans "Build Graph Now" %}
</button>
</div>
<!-- Building progress panel -->
<!-- ── Building progress panel ───────────────────────────────────── -->
<div x-show="building"
class="absolute inset-0 flex items-center justify-center p-6">
class="absolute inset-0 flex items-center justify-center p-6 z-10">
<div class="bg-white rounded-xl shadow-lg border border-gray-100 w-full max-w-lg p-6 space-y-4">
<!-- Header -->
<div class="flex items-center gap-3">
<svg class="w-5 h-5 animate-spin text-blue-500 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
@@ -206,8 +316,6 @@
</svg>
<span class="font-semibold text-gray-800 text-sm">{% trans "Building Knowledge Graph" %}</span>
</div>
<!-- Progress bar -->
<div>
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span x-text="progress.step || '{% trans "Working" %}'" class="truncate max-w-xs"></span>
@@ -218,16 +326,12 @@
:style="'width:' + (progress.pct || 0) + '%'"></div>
</div>
</div>
<!-- Timing row -->
<div class="flex gap-4 text-xs text-gray-500">
<span>⏱ {% trans "Elapsed" %}: <span class="font-medium text-gray-700" x-text="fmtSecs(elapsedSecs)"></span></span>
<span x-show="progress.pct > 5 && progress.pct < 100">
{% trans "Est. remaining" %}: <span class="font-medium text-gray-700" x-text="fmtSecs(estRemaining)"></span>
</span>
</div>
<!-- Foldable logs -->
<div>
<button @click="logsExpanded = !logsExpanded"
class="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-700 transition">
@@ -241,24 +345,21 @@
class="mt-2 max-h-40 overflow-y-auto rounded-lg bg-gray-900 p-3 space-y-0.5 font-mono">
<template x-for="(log, i) in buildLogs" :key="i">
<div class="flex gap-2 text-xs leading-5">
<span class="text-gray-500 flex-shrink-0"
x-text="'+' + log.elapsed_s.toFixed(1) + 's'"></span>
<span class="text-gray-500 flex-shrink-0" x-text="'+' + log.elapsed_s.toFixed(1) + 's'"></span>
<span class="text-green-400" x-text="log.msg"></span>
</div>
</template>
<div x-show="buildLogs.length === 0" class="text-xs text-gray-500">{% trans "No log entries yet…" %}</div>
</div>
</div>
</div>
</div>
<!-- Build modal -->
<!-- ── Build modal ──────────────────────────────────────────────── -->
<div x-show="showBuildModal" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6 space-y-4">
<h2 class="text-lg font-bold text-gray-900">{% trans "Build Knowledge Graph" %}</h2>
<div class="space-y-3">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" x-model="buildUseLLM" :disabled="!llmAvailable"
@@ -276,14 +377,13 @@
{% trans "Semantic edges (pink) connect items with similar meaning using embeddings. Without LLM, the graph uses tag and domain edges only." %}
</p>
</div>
<div class="flex gap-3 pt-2">
<button @click="showBuildModal = false"
class="flex-1 py-2 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 transition">
{% trans "Cancel" %}
</button>
<button @click="startBuild()"
class="flex-1 py-2 rounded bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium transition duration-150 ease-in-out">
class="flex-1 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition">
{% trans "Start Build" %}
</button>
</div>
@@ -294,7 +394,7 @@
{% endblock %}
{% block extra_js %}
<!-- graphology + sigma.js + ForceAtlas2 from CDN -->
<!-- graphology + sigma + ForceAtlas2 -->
<script src="https://cdn.jsdelivr.net/npm/graphology@0.25.4/dist/graphology.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sigma@2.4.0/build/sigma.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/graphology-layout-forceatlas2@0.10.1/build/graphology-layout-forceatlas2.umd.min.js"></script>
@@ -302,10 +402,13 @@
<script>
function kgApp() {
return {
// ── state ──────────────────────────────────────────────────────────
// ── state ──────────────────────────────────────────────────────
sigmaInstance: null,
graph: null,
graphLoaded: false,
graphComputing: false,
zoomRatio: 1,
zoomSlider: 50,
building: {% if latest_building %}true{% else %}false{% endif %},
showBuildModal: false,
buildUseLLM: false,
@@ -321,17 +424,25 @@ function kgApp() {
estRemaining: 0,
_buildStartMs: null,
_elapsedTimer: null,
_cameraRefreshTimer: null,
filters: [
{ type: 'link', label: '{% trans "Links" %}', color: '#4B9CD3', active: true, count: 0 },
{ type: 'page', label: '{% trans "Pages" %}', color: '#22C55E', active: true, count: 0 },
{ type: 'link', label: '{% trans "Links" %}', color: '#3B82F6', active: true, count: 0 },
{ type: 'page', label: '{% trans "Pages" %}', color: '#10B981', active: true, count: 0 },
{ type: 'post', label: '{% trans "Posts" %}', color: '#F97316', active: true, count: 0 },
{ type: 'tag', label: '{% trans "Tags" %}', color: '#8B5CF6', active: true, count: 0 },
],
// ── init ───────────────────────────────────────────────────────────
// ── init ───────────────────────────────────────────────────────
init() {
// Ensure only one KG renderer instance is active on the page.
// If Alpine re-initializes this block, tear down the old instance first.
if (window.__kgActive && window.__kgActive !== this && typeof window.__kgActive.destroy === 'function') {
window.__kgActive.destroy();
}
window.__kgActive = this;
this.llmAvailable = '{{ site_settings.llm_provider }}' !== 'none';
this.buildUseLLM = this.llmAvailable;
this.buildUseLLM = this.llmAvailable;
{% if snapshot %}
this.loadGraph();
{% elif latest_building %}
@@ -339,166 +450,311 @@ function kgApp() {
{% endif %}
},
// ── load & render graph ────────────────────────────────────────────
async loadGraph() {
try {
const resp = await fetch('/api/knowledge-graph/data/');
if (!resp.ok) return;
const data = await resp.json();
this.renderGraph(data);
} catch(e) {
console.error('KG load error:', e);
}
},
destroy() {
clearInterval(this.pollTimer);
clearInterval(this._elapsedTimer);
clearTimeout(this._cameraRefreshTimer);
renderGraph(data) {
if (this.sigmaInstance) {
this.sigmaInstance.kill();
this.sigmaInstance = null;
}
const container = document.getElementById('sigma-canvas-wrapper');
if (container) container.innerHTML = '';
if (window.__kgActive === this) window.__kgActive = null;
},
// ── load graph data ────────────────────────────────────────────
async loadGraph() {
try {
const resp = await fetch('/api/knowledge-graph/data/');
if (!resp.ok) return;
const data = await resp.json();
await this.renderGraph(data);
} catch(e) {
console.error('KG load error:', e);
this.statusText = '{% trans "Load error" %}';
this.graphComputing = false;
}
},
// ── render + layout (async so the UI stays responsive) ─────────
async renderGraph(data) {
// Kill previous instance and wipe all canvas/event elements sigma
// may have left in the container — without this, the old graph
// remains visible as a ghost underneath the new one.
const container = document.getElementById('sigma-canvas-wrapper');
if (this.sigmaInstance) {
this.sigmaInstance.kill();
this.sigmaInstance = null;
}
clearTimeout(this._cameraRefreshTimer);
this._cameraRefreshTimer = null;
container.innerHTML = '';
const g = new graphology.Graph({ multi: false });
// Add nodes
for (const n of (data.nodes || [])) {
if (!g.hasNode(n.key)) {
g.addNode(n.key, n.attributes || {});
}
if (!g.hasNode(n.key)) g.addNode(n.key, n.attributes || {});
}
// Add edges (skip if source/target missing)
for (const e of (data.edges || [])) {
if (g.hasNode(e.source) && g.hasNode(e.target) && !g.hasEdge(e.key)) {
if (g.hasNode(e.source) && g.hasNode(e.target)) {
try {
g.addEdgeWithKey(e.key, e.source, e.target, e.attributes || {});
if (!g.hasEdge(e.key)) g.addEdgeWithKey(e.key, e.source, e.target, e.attributes || {});
} catch(_) {}
}
}
// Run ForceAtlas2 layout (in-browser, sync, 150 iterations)
try {
graphologyLayoutForceAtlas2.assign(g, {
iterations: 150,
settings: {
gravity: 2,
scalingRatio: 8,
slowDown: 2,
barnesHutOptimize: g.order > 200,
},
});
} catch(e) {
console.warn('ForceAtlas2 failed, using random layout:', e);
}
this.graph = g;
this.graphLoaded = true;
this.building = false;
// Update filter counts
// Count nodes by type for the legend
const typeCounts = {};
g.forEachNode((key, attrs) => {
typeCounts[attrs.node_type] = (typeCounts[attrs.node_type] || 0) + 1;
g.forEachNode((k, a) => {
typeCounts[a.node_type] = (typeCounts[a.node_type] || 0) + 1;
});
this.filters.forEach(f => { f.count = typeCounts[f.type] || 0; });
// Update status text
// Show the computing overlay while ForceAtlas2 runs
const nodeCount = g.order;
const edgeCount = g.size;
this.statusText = `${nodeCount} {% trans "nodes" %} · ${edgeCount} {% trans "edges" %}`;
this.statusText = `{% trans "Laying out" %} ${nodeCount.toLocaleString()} {% trans "nodes" %}…`;
this.graphComputing = true;
this.building = false;
// Yield to let Alpine re-render the overlay before blocking computation
await new Promise(r => setTimeout(r, 80));
// Check whether nodes already have meaningful positions from the server
let withPos = 0;
g.forEachNode((k, a) => {
if (typeof a.x === 'number' && typeof a.y === 'number' && Math.abs(a.x) + Math.abs(a.y) > 0.001) withPos++;
});
const hasMeaningfulPositions = withPos > nodeCount * 0.3;
if (!hasMeaningfulPositions) {
// ── Step 1: pre-position in a circle for fast FA2 convergence ──
// Starting from random/zero positions causes FA2 to produce a tight
// blob. A circular seed gives every node room to "settle" outward.
const nks = g.nodes();
const r0 = Math.sqrt(nks.length) * 12;
nks.forEach((nk, i) => {
const θ = (2 * Math.PI * i) / nks.length;
// small jitter breaks the rotational symmetry
g.setNodeAttribute(nk, 'x', r0 * Math.cos(θ) + (Math.random() - 0.5) * 3);
g.setNodeAttribute(nk, 'y', r0 * Math.sin(θ) + (Math.random() - 0.5) * 3);
});
// ── Step 2: ForceAtlas2 with settings tuned for dense graphs ──
// Key choices:
// linLogMode=true → log-scale attraction; hubs don't collapse to a point
// outboundAttractionDistrib → attraction force ÷ degree; prevents mass clustering
// gravity=0.3 → gentle pull to center; nodes can spread freely
// barnesHutOptimize=true → O(n log n) repulsion; essential for 1000+ nodes
try {
graphologyLayoutForceAtlas2.assign(g, {
iterations: 250,
settings: {
gravity: 0.3,
scalingRatio: 2,
slowDown: 20,
barnesHutOptimize: true,
barnesHutTheta: 0.7,
linLogMode: true,
outboundAttractionDistribution:true,
adjustSizes: false,
strongGravityMode: false,
},
});
} catch(e) {
console.warn('ForceAtlas2 failed, continuing with circle layout:', e);
}
}
this.graph = g;
this.graphLoaded = true;
this.graphComputing = false;
this.statusText = `${nodeCount.toLocaleString()} {% trans "nodes" %} · ${g.size.toLocaleString()} {% trans "edges" %}`;
// Init sigma
const container = document.getElementById('sigma-canvas-wrapper');
this.sigmaInstance = new Sigma(g, container, {
labelRenderedSizeThreshold: 6,
defaultEdgeColor: '#e2e8f0',
labelColor: { color: '#374151' },
// Hide edge/label layers while moving. We'll force-clear and repaint
// once interaction settles to avoid stale ghost frames.
hideEdgesOnMove: true,
hideLabelsOnMove: true,
// Only show labels for nodes that appear >= 12px on screen.
labelRenderedSizeThreshold: 12,
labelColor: { color: '#1e293b' },
labelSize: 11,
// ── Defaults ─────────────────────────────────────────────────
defaultNodeColor: '#6366f1',
renderEdgeLabels: false,
edgeReducer: (edge, data) => {
return { ...data, hidden: data._hidden || false };
// ── Node reducer ─────────────────────────────────────────────
// Dim nodes that are NOT connected to the currently selected node.
// "Dim" = render in a near-transparent gray so the selected node's
// neighbourhood stands out clearly.
nodeReducer: (node, attrs) => {
const res = { ...attrs, hidden: attrs._hidden || false };
if (attrs._dim) {
res.color = '#cbd5e1'; // light slate
res.size = Math.max(1.5, (attrs.size || 4) * 0.55);
res.label = null; // suppress label for dimmed nodes
}
return res;
},
nodeReducer: (node, data) => {
return { ...data, hidden: data._hidden || false };
// ── Edge reducer ─────────────────────────────────────────────
// Default: edges are almost invisible (dense graph = unreadable otherwise).
// Highlighted edges (connected to selected node) are shown at full opacity.
edgeReducer: (edge, attrs) => {
const res = { ...attrs, hidden: attrs._hidden || false };
if (attrs._highlighted) {
res.color = attrs.color || '#64748b';
res.size = Math.max(1, (attrs.size || 0.5) * 2);
} else {
// Sigma's floatColor() only parses 3/6-digit hex. Use a light
// slate-400 hex so edges are visible but don't overwhelm nodes.
res.color = '#94a3b8';
res.size = 0.5;
}
return res;
},
});
// Click node
this.sigmaInstance.on('clickNode', ({ node }) => {
this.selectNodeByKey(node);
});
this.sigmaInstance.on('clickNode', ({ node }) => this.selectNodeByKey(node));
this.sigmaInstance.on('clickStage', () => this.deselectNode());
// Click background to deselect
this.sigmaInstance.on('clickStage', () => {
this.deselectNode();
// Sync zoom slider with touchpad / programmatic camera changes.
// Throttled to ~30fps to avoid flooding Alpine with reactive updates
// (which would trigger DOM reconciliation and potentially interfere
// with sigma's canvas clearing between frames).
let _lastZoomSync = 0;
this.sigmaInstance.getCamera().on('updated', (state) => {
const now = Date.now();
if (now - _lastZoomSync > 33) {
_lastZoomSync = now;
this._syncZoomFromCamera(state.ratio);
}
// Force a clean repaint when zoom/pan settles.
clearTimeout(this._cameraRefreshTimer);
this._cameraRefreshTimer = setTimeout(() => {
this.forceCanvasClear();
if (this.sigmaInstance) this.sigmaInstance.refresh();
}, 80);
});
this._syncZoomFromCamera(this.sigmaInstance.getCamera().ratio);
},
forceCanvasClear() {
const wrapper = document.getElementById('sigma-canvas-wrapper');
if (!wrapper) return;
const canvases = wrapper.querySelectorAll('canvas');
canvases.forEach((canvas) => {
const ctx2d = canvas.getContext('2d');
if (ctx2d) {
ctx2d.clearRect(0, 0, canvas.width, canvas.height);
return;
}
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (gl) {
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
});
},
// ── node selection & highlight ─────────────────────────────────────
// ── node selection ─────────────────────────────────────────────
selectNodeByKey(nodeKey) {
if (!this.graph || !this.sigmaInstance) return;
if (!this.graph.hasNode(nodeKey)) return;
if (!this.graph || !this.sigmaInstance || !this.graph.hasNode(nodeKey)) return;
const attrs = this.graph.getNodeAttributes(nodeKey);
this.selectedNode = { key: nodeKey, ...attrs };
// Find neighbors
const neighborKeys = new Set();
this.graph.forEachNeighbor(nodeKey, (nbr) => neighborKeys.add(nbr));
this.graph.forEachNeighbor(nodeKey, nbr => neighborKeys.add(nbr));
this.connectedNodes = Array.from(neighborKeys).map(k => ({
key: k,
...this.graph.getNodeAttributes(k),
}));
key: k, ...this.graph.getNodeAttributes(k),
})).sort((a, b) => (b.size || 4) - (a.size || 4)); // largest first
// Dim non-connected nodes/edges
this.graph.forEachNode((n, a) => {
const isRelevant = n === nodeKey || neighborKeys.has(n);
this.graph.setNodeAttribute(n, '_hidden', !isRelevant);
this.graph.forEachNode((n) => {
const relevant = n === nodeKey || neighborKeys.has(n);
this.graph.setNodeAttribute(n, '_dim', !relevant);
this.graph.setNodeAttribute(n, '_hidden', false);
});
this.graph.forEachEdge((e, a, s, t) => {
const isRelevant = s === nodeKey || t === nodeKey;
this.graph.setEdgeAttribute(e, '_hidden', !isRelevant);
const isConn = s === nodeKey || t === nodeKey;
this.graph.setEdgeAttribute(e, '_highlighted', isConn);
this.graph.setEdgeAttribute(e, '_hidden', false);
});
this.sigmaInstance.refresh();
},
deselectNode() {
this.selectedNode = null;
this.selectedNode = null;
this.connectedNodes = [];
if (!this.graph || !this.sigmaInstance) return;
this.graph.forEachNode((n) => this.graph.setNodeAttribute(n, '_hidden', false));
this.graph.forEachEdge((e) => this.graph.setEdgeAttribute(e, '_hidden', false));
this.graph.forEachNode((n) => {
this.graph.setNodeAttribute(n, '_dim', false);
this.graph.setNodeAttribute(n, '_hidden', false);
});
this.graph.forEachEdge((e) => {
this.graph.setEdgeAttribute(e, '_highlighted', false);
this.graph.setEdgeAttribute(e, '_hidden', false);
});
this.sigmaInstance.refresh();
},
// ── type filter toggle ─────────────────────────────────────────────
// ── type filter toggle ─────────────────────────────────────────
toggleFilter(type) {
const f = this.filters.find(f => f.type === type);
if (!f) return;
if (!f || !this.graph || !this.sigmaInstance) return;
f.active = !f.active;
const activeTypes = new Set(this.filters.filter(f => f.active).map(f => f.type));
if (!this.graph || !this.sigmaInstance) return;
this.graph.forEachNode((n, a) => {
this.graph.setNodeAttribute(n, '_hidden', !activeTypes.has(a.node_type));
});
this.graph.forEachEdge((e, a, s, t) => {
const sa = this.graph.getNodeAttributes(s);
const ta = this.graph.getNodeAttributes(t);
const hidden = !activeTypes.has(sa.node_type) || !activeTypes.has(ta.node_type);
this.graph.setEdgeAttribute(e, '_hidden', hidden);
this.graph.setEdgeAttribute(e, '_hidden',
!activeTypes.has(sa.node_type) || !activeTypes.has(ta.node_type));
});
this.sigmaInstance.refresh();
},
// ── camera reset ───────────────────────────────────────────────────
// ── camera reset ───────────────────────────────────────────────
resetCamera() {
if (this.sigmaInstance) {
this.sigmaInstance.getCamera().animatedReset();
}
if (this.sigmaInstance) this.sigmaInstance.getCamera().animatedReset();
},
// ── build ──────────────────────────────────────────────────────────
// ── zoom controls ──────────────────────────────────────────────
zoomIn() { if (this.sigmaInstance) this.sigmaInstance.getCamera().animatedZoom({ duration: 200, factor: 1.5 }); },
zoomOut() { if (this.sigmaInstance) this.sigmaInstance.getCamera().animatedUnzoom({ duration: 200, factor: 1.5 }); },
// Slider 0..100 → camera ratio on a log scale
// val=0 → ratio=4 → 25% zoom (very zoomed out)
// val=50 → ratio=1 → 100% zoom (default)
// val=100 → ratio=0.25 → 400% zoom (very zoomed in)
setZoomFromSlider(val) {
this.zoomSlider = val;
const ratio = Math.exp(Math.log(4) + (Math.log(0.25) - Math.log(4)) * val / 100);
if (this.sigmaInstance) this.sigmaInstance.getCamera().setState({ ratio });
},
_syncZoomFromCamera(ratio) {
this.zoomRatio = ratio;
const logMin = Math.log(4); // most zoomed out
const logMax = Math.log(0.25); // most zoomed in
const raw = 100 * (Math.log(Math.max(ratio, 0.001)) - logMin) / (logMax - logMin);
this.zoomSlider = Math.max(0, Math.min(100, Math.round(raw)));
},
// ── build ──────────────────────────────────────────────────────
async startBuild() {
this.showBuildModal = false;
this.building = true;
@@ -515,9 +771,7 @@ function kgApp() {
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCsrf() },
body: JSON.stringify({ use_llm: this.buildUseLLM }),
});
if (resp.ok) {
this.pollStatus();
}
if (resp.ok) this.pollStatus();
} catch(e) {
this.building = false;
clearInterval(this._elapsedTimer);
@@ -525,7 +779,7 @@ function kgApp() {
}
},
// ── progress helpers ───────────────────────────────────────────────
// ── elapsed timer ──────────────────────────────────────────────
fmtSecs(s) {
if (s < 60) return Math.round(s) + 's';
return Math.floor(s / 60) + 'm ' + (Math.round(s) % 60) + 's';
@@ -537,13 +791,11 @@ function kgApp() {
this._elapsedTimer = setInterval(() => {
this.elapsedSecs = (Date.now() - this._buildStartMs) / 1000;
const pct = this.progress.pct || 0;
if (pct > 5 && pct < 100) {
this.estRemaining = (this.elapsedSecs / pct) * (100 - pct);
}
if (pct > 5 && pct < 100) this.estRemaining = (this.elapsedSecs / pct) * (100 - pct);
}, 500);
},
// ── status polling ─────────────────────────────────────────────────
// ── status polling ─────────────────────────────────────────────
pollStatus() {
clearInterval(this.pollTimer);
this.pollTimer = setInterval(async () => {
@@ -551,9 +803,8 @@ function kgApp() {
const resp = await fetch('/api/knowledge-graph/status/');
const data = await resp.json();
// Update live progress
if (data.progress_data && data.progress_data.pct !== undefined) {
this.progress = data.progress_data;
if (data.progress_data?.pct !== undefined) {
this.progress = data.progress_data;
this.buildLogs = data.progress_data.logs || [];
if (this.logsExpanded && this.buildLogs.length) {
this.$nextTick(() => {
@@ -563,7 +814,6 @@ function kgApp() {
}
}
// Start elapsed timer from server-reported created_at
if (data.status === 'building' && data.created_at && !this._buildStartMs) {
this._startElapsedTimer(new Date(data.created_at).getTime());
}
@@ -586,10 +836,10 @@ function kgApp() {
}, 2000);
},
// ── CSRF helper ────────────────────────────────────────────────────
// ── CSRF helper ────────────────────────────────────────────────
getCsrf() {
const cookie = document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='));
return cookie ? cookie.trim().split('=')[1] : '';
const c = document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='));
return c ? c.trim().split('=')[1] : '';
},
};
}