<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
script-src 'unsafe-inline';
style-src 'unsafe-inline';
img-src data: blob:;">
<title>{{TITLE}}</title>
<style>
{{CSS}}
/* Graph-specific styles */
.graph-page {
padding: var(--daemon-space-lg);
height: 100vh;
display: flex;
flex-direction: column;
}
</style>
</head>
<body>
<!-- Real-time update notification badge -->
<div id="update-indicator" class="daemon-update-badge" role="status" aria-live="polite">
<span class="daemon-update-badge__dot"></span>
<span>New data available</span>
<button id="refresh-btn" class="daemon-btn daemon-btn--small">Refresh</button>
</div>
<div id="app" class="graph-page">
<!-- Header Section -->
<header class="graph-header">
<div class="graph-header__title">
<h1>{{TITLE}}</h1>
<span class="graph-header__count">
{{NODE_COUNT}} nodes, {{EDGE_COUNT}} edges
</span>
</div>
</header>
<!-- Legend -->
<div class="graph-legend">
<div class="graph-legend__item">
<span class="graph-legend__color graph-legend__color--decision"></span>
<span>Decision</span>
</div>
<div class="graph-legend__item">
<span class="graph-legend__color graph-legend__color--warning"></span>
<span>Warning</span>
</div>
<div class="graph-legend__item">
<span class="graph-legend__color graph-legend__color--pattern"></span>
<span>Pattern</span>
</div>
<div class="graph-legend__item">
<span class="graph-legend__color graph-legend__color--learning"></span>
<span>Learning</span>
</div>
</div>
<!-- Graph Container -->
<div class="graph-container" id="graph-container">
<!-- Canvas will be created by JavaScript -->
<!-- Loading indicator with progress text -->
<div class="graph-loading" id="loading">
<div class="graph-loading__spinner"></div>
<span id="loading-text">Loading graph...</span>
</div>
<!-- Empty state -->
<div class="graph-empty" id="empty" style="display: none;">
<p>No memories to display</p>
</div>
<!-- Zoom controls -->
<div class="graph-controls">
<button class="graph-controls__btn" id="zoom-in" title="Zoom in">+</button>
<button class="graph-controls__btn" id="zoom-out" title="Zoom out">-</button>
<button class="graph-controls__btn" id="zoom-reset" title="Reset zoom">R</button>
</div>
<!-- Community visibility toggle -->
<div class="graph-controls graph-controls--top">
<label class="graph-controls__toggle">
<input type="checkbox" id="show-communities" checked>
<span>Communities</span>
</label>
</div>
<!-- Details panel -->
<div class="graph-details" id="details-panel">
<button class="graph-details__close" id="details-close">×</button>
<div id="details-content"></div>
</div>
<!-- Temporal slider for bi-temporal filtering -->
<div class="graph-temporal" id="temporal-slider">
<div class="graph-temporal__label">Time range filter (as_of_time)</div>
<svg id="temporal-svg"></svg>
</div>
</div>
</div>
<script>
{{SCRIPT}}
// Memory Graph Viewer - Canvas-based Force-Directed Graph
(function() {
// Parse graph data from template
var graphData = {{GRAPH_DATA}};
// Category colors (match daemon.css custom properties)
var categoryColors = {
'decision': '#3b82f6',
'warning': '#f59e0b',
'pattern': '#8b5cf6',
'learning': '#22c55e',
'default': '#888888'
};
// Relationship styles for edges
var relationshipStyles = {
'led_to': { color: '#3b82f6', dash: [] },
'supersedes': { color: '#f59e0b', dash: [5, 5] },
'conflicts_with': { color: '#ef4444', dash: [2, 2] },
'relates_to': { color: '#888888', dash: [] },
'depends_on': { color: '#22c55e', dash: [] }
};
// DOM elements
var container = document.getElementById('graph-container');
var loadingEl = document.getElementById('loading');
var emptyEl = document.getElementById('empty');
var detailsPanel = document.getElementById('details-panel');
var detailsContent = document.getElementById('details-content');
var detailsClose = document.getElementById('details-close');
var zoomInBtn = document.getElementById('zoom-in');
var zoomOutBtn = document.getElementById('zoom-out');
var zoomResetBtn = document.getElementById('zoom-reset');
// Canvas setup
var canvas, ctx;
var width, height;
var dpr = window.devicePixelRatio || 1;
// Simulation and transform state
var simulation;
var transform = d3.zoomIdentity;
var zoomBehavior;
// Node radius and selection state
var nodeRadius = 8;
var selectedNodeId = null;
// Community visibility state
var showCommunities = true;
// Path animation state
var pathAnimationId = null;
var currentPath = null;
var pathProgress = 0;
/**
* Initialize canvas with retina support
*/
function initCanvas() {
width = container.clientWidth;
height = container.clientHeight;
canvas = document.createElement('canvas');
canvas.className = 'graph-canvas';
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// Insert canvas before loading indicator
container.insertBefore(canvas, loadingEl);
}
/**
* Draw an edge between two nodes
*/
function drawEdge(source, target, relationship) {
var style = relationshipStyles[relationship] || relationshipStyles['relates_to'];
ctx.beginPath();
ctx.strokeStyle = style.color;
ctx.lineWidth = 1;
ctx.setLineDash(style.dash);
ctx.moveTo(source.x, source.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
ctx.setLineDash([]);
}
/**
* Draw a node with category coloring and selection highlight
*/
function drawNode(node) {
var color = categoryColors[node.category] || categoryColors['default'];
var isSelected = selectedNodeId !== null && node.id === selectedNodeId;
ctx.beginPath();
ctx.arc(node.x, node.y, nodeRadius, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
// Selected node gets accent highlight stroke
if (isSelected) {
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 3;
ctx.stroke();
} else {
// Add subtle border for non-selected
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
/**
* Draw community cluster hulls using d3.polygonHull
* Renders semi-transparent convex hulls around nodes with the same community_id
*/
function drawCommunityHulls(ctx) {
// Skip if communities are hidden
if (!showCommunities) return;
// Skip if no community data
if (!graphData.nodes.some(function(n) { return n.community_id !== undefined; })) {
return;
}
// Group nodes by community
var communities = {};
graphData.nodes.forEach(function(node) {
if (node.community_id !== undefined && node.x !== undefined && node.y !== undefined) {
if (!communities[node.community_id]) {
communities[node.community_id] = [];
}
communities[node.community_id].push(node);
}
});
// Draw hull for each community with 3+ members
var communityIds = Object.keys(communities);
communityIds.forEach(function(commId, index) {
var communityNodes = communities[commId];
if (communityNodes.length < 3) return;
// Compute convex hull points
var points = communityNodes.map(function(n) {
return [n.x, n.y];
});
var hull = d3.polygonHull(points);
if (!hull || hull.length < 3) return;
// Expand hull slightly for visual padding
var centroid = d3.polygonCentroid(hull);
var expandedHull = hull.map(function(point) {
var dx = point[0] - centroid[0];
var dy = point[1] - centroid[1];
var dist = Math.sqrt(dx * dx + dy * dy);
var factor = (dist + 20) / dist; // 20px padding
return [
centroid[0] + dx * factor,
centroid[1] + dy * factor
];
});
// Draw filled hull with low opacity
ctx.beginPath();
ctx.moveTo(expandedHull[0][0], expandedHull[0][1]);
expandedHull.forEach(function(point) {
ctx.lineTo(point[0], point[1]);
});
ctx.closePath();
// Color based on community index (rotating hue)
var hue = (index * 60) % 360;
ctx.fillStyle = 'hsla(' + hue + ', 50%, 50%, 0.08)';
ctx.fill();
ctx.strokeStyle = 'hsla(' + hue + ', 50%, 50%, 0.25)';
ctx.lineWidth = 1;
ctx.stroke();
});
}
/**
* Draw path highlight for trace_chain visualization
* Shows animated path traversal with marker and node rings
*/
function drawPathHighlight(ctx) {
if (!currentPath || currentPath.length < 2) return;
// Calculate total path length and segments
var totalLength = 0;
var segments = [];
for (var i = 0; i < currentPath.length - 1; i++) {
var start = currentPath[i];
var end = currentPath[i + 1];
var dx = end.x - start.x;
var dy = end.y - start.y;
var length = Math.sqrt(dx * dx + dy * dy);
segments.push({ start: start, end: end, length: length });
totalLength += length;
}
var traveled = pathProgress * totalLength;
// Draw highlighted path segments
ctx.lineWidth = 4;
ctx.strokeStyle = '#8b5cf6'; // daemon-accent
ctx.lineCap = 'round';
var accumulated = 0;
ctx.beginPath();
ctx.moveTo(currentPath[0].x, currentPath[0].y);
var markerX = currentPath[0].x;
var markerY = currentPath[0].y;
for (var j = 0; j < segments.length; j++) {
var seg = segments[j];
if (accumulated + seg.length <= traveled) {
// Full segment traversed
ctx.lineTo(seg.end.x, seg.end.y);
accumulated += seg.length;
markerX = seg.end.x;
markerY = seg.end.y;
} else {
// Partial segment - interpolate position
var t = (traveled - accumulated) / seg.length;
markerX = seg.start.x + t * (seg.end.x - seg.start.x);
markerY = seg.start.y + t * (seg.end.y - seg.start.y);
ctx.lineTo(markerX, markerY);
break;
}
}
ctx.stroke();
// Draw marker at current position
ctx.beginPath();
ctx.arc(markerX, markerY, 8, 0, 2 * Math.PI);
ctx.fillStyle = '#8b5cf6'; // daemon-accent
ctx.fill();
// Highlight traversed path nodes with rings
accumulated = 0;
currentPath.forEach(function(node, index) {
if (index === 0) {
// First node is always traversed
drawNodeRing(ctx, node);
return;
}
// Check if we've reached this node
var nodeDistance = 0;
for (var k = 0; k < index; k++) {
nodeDistance += segments[k].length;
}
if (nodeDistance <= traveled) {
drawNodeRing(ctx, node);
}
});
}
/**
* Draw a ring highlight around a node
*/
function drawNodeRing(ctx, node) {
ctx.beginPath();
ctx.arc(node.x, node.y, 12, 0, 2 * Math.PI);
ctx.strokeStyle = '#8b5cf6'; // daemon-accent
ctx.lineWidth = 3;
ctx.stroke();
}
/**
* Animate path traversal for trace_chain visualization
* @param {Array} pathNodes - Array of path node objects with id property
* @param {number} duration - Animation duration in milliseconds (default 2000)
*/
function animatePath(pathNodes, duration) {
// Cancel any existing animation
if (pathAnimationId) {
cancelAnimationFrame(pathAnimationId);
}
// Find node objects from path data
var pathNodeObjects = pathNodes.map(function(p) {
return graphData.nodes.find(function(n) { return n.id === p.id; });
}).filter(Boolean);
if (pathNodeObjects.length < 2) {
console.warn('Path requires at least 2 nodes');
return;
}
currentPath = pathNodeObjects;
pathProgress = 0;
var startTime = null;
var animDuration = duration || 2000; // 2 seconds default
function animate(timestamp) {
if (!startTime) startTime = timestamp;
var elapsed = timestamp - startTime;
pathProgress = Math.min(elapsed / animDuration, 1);
draw(); // Redraw with path highlight
if (pathProgress < 1) {
pathAnimationId = requestAnimationFrame(animate);
} else {
// Animation complete - keep path highlighted
pathAnimationId = null;
}
}
pathAnimationId = requestAnimationFrame(animate);
}
/**
* Clear path animation and reset state
*/
function clearPath() {
if (pathAnimationId) {
cancelAnimationFrame(pathAnimationId);
pathAnimationId = null;
}
currentPath = null;
pathProgress = 0;
draw();
}
/**
* Main render function - called on each simulation tick
*/
function draw() {
ctx.save();
ctx.clearRect(0, 0, width, height);
// Apply zoom transform
ctx.translate(transform.x, transform.y);
ctx.scale(transform.k, transform.k);
// 1. Draw community hulls first (underneath everything)
drawCommunityHulls(ctx);
// 2. Draw path highlight (if active)
drawPathHighlight(ctx);
// 3. Draw edges
graphData.edges.forEach(function(edge) {
if (edge.source && edge.target && edge.source.x !== undefined) {
drawEdge(edge.source, edge.target, edge.relationship);
}
});
// 4. Draw nodes (on top)
graphData.nodes.forEach(function(node) {
if (node.x !== undefined && node.y !== undefined) {
drawNode(node);
}
});
ctx.restore();
}
/**
* Initialize D3 force simulation with provided nodes
*/
function initSimulation(nodes, edges) {
simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(edges)
.id(function(d) { return d.id; })
.strength(0.5)
.distance(80))
.force('charge', d3.forceManyBody()
.strength(-100))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide(nodeRadius + 5))
.alphaDecay(0.05)
.on('tick', draw)
.on('end', function() {
loadingEl.classList.add('graph-loading--hidden');
});
}
/**
* Update loading progress text
*/
function updateProgress(percent) {
var progressText = document.getElementById('loading-text');
if (progressText) {
progressText.textContent = 'Loading... ' + Math.round(percent * 100) + '%';
}
}
/**
* Progressive loading for large graphs using requestIdleCallback
* Loads nodes in batches during browser idle time to prevent UI freeze
*/
function loadGraphProgressively(nodes, edges, onProgress, onComplete) {
var BATCH_SIZE = 50; // Nodes per idle callback
var loadedNodes = [];
var loaded = 0;
var total = nodes.length;
// Show loading indicator
loadingEl.classList.remove('graph-loading--hidden');
function loadBatch(deadline) {
// Load nodes while we have idle time (at least 5ms remaining)
while (loaded < total && deadline.timeRemaining() > 5) {
loadedNodes.push(nodes[loaded]);
loaded++;
// Update progress and restart simulation periodically
if (loaded % BATCH_SIZE === 0) {
if (simulation) {
simulation.nodes(loadedNodes);
simulation.alpha(0.3).restart();
}
onProgress(loaded / total);
}
}
if (loaded < total) {
// More nodes to load - schedule next batch
requestIdleCallback(loadBatch);
} else {
// All nodes loaded - add edges and finalize
if (simulation) {
simulation.nodes(loadedNodes);
simulation.force('link').links(edges);
simulation.alpha(1).restart();
}
loadingEl.classList.add('graph-loading--hidden');
onComplete();
}
}
// Initialize simulation with empty nodes first
initSimulation([], []);
// Start loading
if (window.requestIdleCallback) {
requestIdleCallback(loadBatch);
} else {
// Fallback for Safari (no requestIdleCallback support)
// Load synchronously but update progress periodically
var i = 0;
function loadChunk() {
var chunkEnd = Math.min(i + BATCH_SIZE, total);
while (i < chunkEnd) {
loadedNodes.push(nodes[i]);
i++;
}
onProgress(i / total);
if (i < total) {
// Use setTimeout to yield to browser
setTimeout(loadChunk, 0);
} else {
// All loaded
simulation.nodes(loadedNodes);
simulation.force('link').links(edges);
simulation.alpha(1).restart();
loadingEl.classList.add('graph-loading--hidden');
onComplete();
}
}
initSimulation([], []);
loadChunk();
}
}
/**
* Initialize zoom behavior
*/
function initZoom() {
zoomBehavior = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', function(event) {
transform = event.transform;
draw();
});
d3.select(canvas).call(zoomBehavior);
// Zoom control buttons
zoomInBtn.addEventListener('click', function() {
d3.select(canvas).transition().duration(300)
.call(zoomBehavior.scaleBy, 1.3);
});
zoomOutBtn.addEventListener('click', function() {
d3.select(canvas).transition().duration(300)
.call(zoomBehavior.scaleBy, 0.7);
});
zoomResetBtn.addEventListener('click', function() {
d3.select(canvas).transition().duration(300)
.call(zoomBehavior.transform, d3.zoomIdentity);
});
}
/**
* Find node at given position (with transform applied)
*/
function findNodeAtPosition(x, y) {
// Inverse transform the coordinates
var tx = (x - transform.x) / transform.k;
var ty = (y - transform.y) / transform.k;
// Use simulation.find for efficient quadtree lookup
return simulation.find(tx, ty, nodeRadius * 2);
}
/**
* Handle canvas click for node selection
*/
function initClickHandler() {
canvas.addEventListener('click', function(event) {
var rect = canvas.getBoundingClientRect();
var x = event.clientX - rect.left;
var y = event.clientY - rect.top;
var node = findNodeAtPosition(x, y);
if (node) {
showNodeDetails(node);
}
});
// Close details panel and clear selection
detailsClose.addEventListener('click', function() {
detailsPanel.classList.remove('graph-details--visible');
selectedNodeId = null;
draw();
});
// Handle Focus button clicks via event delegation
detailsContent.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action="focus"]');
if (btn && window.SecureMessenger) {
var memoryId = parseInt(btn.dataset.id, 10);
SecureMessenger.send('tool_request', {
tool: 'get_graph',
args: { memory_ids: [memoryId], include_orphans: true }
});
}
});
}
/**
* Escape HTML for safe rendering
*/
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Format date for display
*/
function formatDate(dateStr) {
if (!dateStr) return '';
try {
var date = new Date(dateStr);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
return dateStr;
}
}
/**
* Show node details in side panel
*/
function showNodeDetails(node) {
// Build tags HTML
var tagsHtml = '';
if (node.tags && node.tags.length > 0) {
tagsHtml = '<div class="result-card__tags" style="margin-top: var(--daemon-space-sm);">' +
node.tags.map(function(t) {
return '<span class="result-card__tag">' + escapeHtml(t) + '</span>';
}).join('') +
'</div>';
}
// Build date HTML
var dateHtml = '';
if (node.created_at) {
dateHtml = '<div style="margin-top: var(--daemon-space-sm); font-size: 0.75rem; color: var(--daemon-text-muted);">' +
'<strong>Created:</strong> ' + escapeHtml(formatDate(node.created_at)) +
'</div>';
}
// Get full content (not truncated)
var fullContent = node.full_content || node.content || 'No content available';
var titleContent = (node.content || '').substring(0, 80);
if (titleContent.length < (node.content || '').length) {
titleContent += '...';
}
detailsContent.innerHTML = [
'<div class="daemon-card" style="padding: var(--daemon-space-md);">',
' <div class="daemon-badge daemon-badge--' + escapeHtml(node.category || 'default') + '">' + escapeHtml(node.category || 'memory') + '</div>',
' <h3 style="margin: var(--daemon-space-sm) 0;">' + escapeHtml(titleContent) + '</h3>',
' <p class="daemon-muted" style="font-size: 0.875rem; line-height: 1.6; margin-bottom: var(--daemon-space-md);">' + escapeHtml(fullContent) + '</p>',
tagsHtml,
dateHtml,
' <div style="margin-top: var(--daemon-space-sm); font-size: 0.75rem; color: var(--daemon-text-muted);">',
' <strong>ID:</strong> ' + escapeHtml(String(node.id)),
' </div>',
' <div style="margin-top: var(--daemon-space-md);">',
' <button class="daemon-btn daemon-btn--small" data-action="focus" data-id="' + escapeHtml(String(node.id)) + '">',
' Focus on this memory',
' </button>',
' </div>',
'</div>'
].join('\n');
// Update selection state and redraw to highlight
selectedNodeId = node.id;
detailsPanel.classList.add('graph-details--visible');
draw();
}
/**
* Handle window resize
*/
function handleResize() {
width = container.clientWidth;
height = container.clientHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// Update force center
if (simulation) {
simulation.force('center', d3.forceCenter(width / 2, height / 2));
simulation.alpha(0.3).restart();
}
}
// Progressive loading threshold (use for graphs larger than this)
var PROGRESSIVE_THRESHOLD = 200;
// Temporal slider reference
var temporalSlider = null;
/**
* Create temporal slider for bi-temporal date range filtering
* @param {Date} minDate - Earliest date in graph
* @param {Date} maxDate - Latest date in graph
* @param {Function} onChange - Callback when selection changes
*/
function createTemporalSlider(minDate, maxDate, onChange) {
var container = document.getElementById('temporal-slider');
var svg = d3.select('#temporal-svg');
// Slider dimensions
var margin = { left: 10, right: 10 };
var sliderWidth = container.clientWidth - margin.left - margin.right;
var sliderHeight = 30;
svg.attr('width', sliderWidth + margin.left + margin.right)
.attr('height', sliderHeight + 15); // Extra for axis
var g = svg.append('g')
.attr('transform', 'translate(' + margin.left + ', 0)');
// Time scale
var x = d3.scaleTime()
.domain([minDate, maxDate])
.range([0, sliderWidth]);
// Axis
var xAxis = d3.axisBottom(x)
.ticks(5)
.tickFormat(d3.timeFormat('%b %d'));
g.append('g')
.attr('transform', 'translate(0, ' + sliderHeight + ')')
.call(xAxis);
// Brush for range selection
var brush = d3.brushX()
.extent([[0, 0], [sliderWidth, sliderHeight]])
.on('brush end', function(event) {
if (!event.selection) {
// No selection - show all
onChange(null, null);
return;
}
var s = event.selection;
onChange(x.invert(s[0]), x.invert(s[1]));
});
g.append('g')
.attr('class', 'brush')
.call(brush)
.call(brush.move, [0, sliderWidth]); // Initial: full range
return {
reset: function() {
g.select('.brush').call(brush.move, [0, sliderWidth]);
}
};
}
// Debounced filter handler
var filterTimeout = null;
function handleTemporalFilter(startDate, endDate) {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(function() {
filterGraphByTime(startDate, endDate);
}, 300); // 300ms debounce
}
/**
* Filter graph nodes and edges by created_at date range
*/
function filterGraphByTime(startDate, endDate) {
// Filter nodes by created_at date
var filteredNodes = graphData.nodes.filter(function(node) {
if (!startDate || !endDate) return true; // No filter
if (!node.created_at) return true; // Include if no date
var nodeDate = new Date(node.created_at);
return nodeDate >= startDate && nodeDate <= endDate;
});
// Get IDs of filtered nodes
var visibleIds = new Set(filteredNodes.map(function(n) { return n.id; }));
// Filter edges to only include those between visible nodes
var filteredEdges = graphData.edges.filter(function(edge) {
var sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
var targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
return visibleIds.has(sourceId) && visibleIds.has(targetId);
});
// Update simulation with filtered data
simulation.nodes(filteredNodes);
simulation.force('link').links(filteredEdges);
simulation.alpha(0.3).restart();
// Update node/edge counts in header
updateCounts(filteredNodes.length, filteredEdges.length);
}
/**
* Update displayed node/edge counts in header
*/
function updateCounts(nodeCount, edgeCount) {
var countEl = document.querySelector('.graph-header__count');
if (countEl) {
countEl.textContent = nodeCount + ' nodes, ' + edgeCount + ' edges';
}
}
/**
* Initialize the graph viewer
*/
function init() {
// Check for empty data
if (!graphData.nodes || graphData.nodes.length === 0) {
loadingEl.style.display = 'none';
emptyEl.style.display = 'block';
return;
}
// Initialize canvas first
initCanvas();
// Use progressive loading for large graphs to prevent UI freeze
if (graphData.nodes.length > PROGRESSIVE_THRESHOLD) {
loadGraphProgressively(
graphData.nodes,
graphData.edges,
updateProgress,
function() {
console.log('Graph loaded progressively: ' + graphData.nodes.length + ' nodes');
}
);
} else {
// Small graph - load directly
initSimulation(graphData.nodes, graphData.edges);
}
// Initialize interaction handlers
initZoom();
initClickHandler();
// Community visibility toggle
var showCommunitiesCheckbox = document.getElementById('show-communities');
if (showCommunitiesCheckbox) {
showCommunitiesCheckbox.addEventListener('change', function(e) {
showCommunities = e.target.checked;
draw();
});
}
// Debounced resize handler
var resizeTimeout;
window.addEventListener('resize', function() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(handleResize, 250);
});
// Listen for path data from parent (SecureMessenger)
if (window.SecureMessenger) {
SecureMessenger.on('show_path', function(data) {
if (data.path && data.path.length > 1) {
animatePath(data.path, data.duration || 2000);
}
});
SecureMessenger.on('clear_path', function() {
clearPath();
});
}
// Initialize temporal slider if we have date data
var dates = graphData.nodes
.filter(function(n) { return n.created_at; })
.map(function(n) { return new Date(n.created_at); });
if (dates.length >= 2) {
var minDate = new Date(Math.min.apply(null, dates));
var maxDate = new Date(Math.max.apply(null, dates));
temporalSlider = createTemporalSlider(minDate, maxDate, handleTemporalFilter);
} else {
// Hide slider if insufficient date data
document.getElementById('temporal-slider').style.display = 'none';
}
}
// Start when DOM is ready
document.addEventListener('DOMContentLoaded', init);
})();
// Real-time update notification receiver
(function() {
var updateTimeout = null;
var DEBOUNCE_MS = 300;
var indicator = document.getElementById('update-indicator');
var refreshBtn = document.getElementById('refresh-btn');
if (window.SecureMessenger) {
SecureMessenger.on('data_updated', function(data) {
clearTimeout(updateTimeout);
updateTimeout = setTimeout(function() {
if (indicator) {
indicator.classList.add('daemon-update-badge--visible');
indicator.setAttribute('data-last-update', data.last_update || '');
}
}, DEBOUNCE_MS);
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', function() {
if (indicator) {
indicator.classList.remove('daemon-update-badge--visible');
}
if (window.SecureMessenger) {
SecureMessenger.send('tool_request', { tool: 'refresh_ui' });
}
});
}
})();
</script>
</body>
</html>