545 lines
17 KiB
HTML
545 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Memory Graph Visualization</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background-color: #f5f5f5;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.container {
|
|
width: 100%;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.header {
|
|
background: white;
|
|
padding: 15px 20px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 24px;
|
|
color: #333;
|
|
margin: 0;
|
|
}
|
|
|
|
.stats {
|
|
display: flex;
|
|
gap: 20px;
|
|
font-size: 14px;
|
|
color: #666;
|
|
}
|
|
|
|
.stat-item {
|
|
background: #f8f9fa;
|
|
padding: 5px 10px;
|
|
border-radius: 4px;
|
|
border: 1px solid #e9ecef;
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.control-btn {
|
|
background: #007bff;
|
|
color: white;
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.control-btn:hover {
|
|
background: #0056b3;
|
|
}
|
|
|
|
.control-btn:disabled {
|
|
background: #6c757d;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.graph-container {
|
|
flex: 1;
|
|
background: white;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.graph-svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: grab;
|
|
}
|
|
|
|
.graph-svg:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.node {
|
|
stroke: #333;
|
|
stroke-width: 1.5px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.node:hover {
|
|
stroke-width: 3px;
|
|
stroke: #007bff;
|
|
}
|
|
|
|
.link {
|
|
stroke: #999;
|
|
stroke-opacity: 0.6;
|
|
stroke-width: 1.5px;
|
|
}
|
|
|
|
.link:hover {
|
|
stroke: #007bff;
|
|
stroke-opacity: 1;
|
|
stroke-width: 3px;
|
|
}
|
|
|
|
.node-label {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
font-size: 11px;
|
|
fill: #333;
|
|
text-anchor: middle;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.relationship-label {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
font-size: 9px;
|
|
fill: #666;
|
|
text-anchor: middle;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
}
|
|
|
|
.status-indicator {
|
|
position: absolute;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: #28a745;
|
|
color: white;
|
|
padding: 5px 10px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status-indicator.loading {
|
|
background: #ffc107;
|
|
color: #333;
|
|
}
|
|
|
|
.status-indicator.error {
|
|
background: #dc3545;
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(255, 255, 255, 0.8);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
font-size: 18px;
|
|
color: #666;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.tooltip {
|
|
position: absolute;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
padding: 8px 12px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
pointer-events: none;
|
|
z-index: 1001;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>Memory Graph Visualization</h1>
|
|
<div class="stats">
|
|
<div class="stat-item">
|
|
Memories: <span id="totalMemories">0</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
Relationships: <span id="totalRelationships">0</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
Entities: <span id="totalEntities">0</span>
|
|
</div>
|
|
</div>
|
|
<div class="controls">
|
|
<button class="control-btn" id="pauseBtn">Pause</button>
|
|
<button class="control-btn" id="refreshBtn">Refresh</button>
|
|
<button class="control-btn" id="resetBtn">Reset View</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="graph-container">
|
|
<div class="loading-overlay" id="loadingOverlay">
|
|
Loading graph data...
|
|
</div>
|
|
<div class="status-indicator" id="statusIndicator">Connecting...</div>
|
|
<div class="tooltip" id="tooltip"></div>
|
|
<svg class="graph-svg" id="graphSvg"></svg>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Configuration
|
|
const API_BASE = 'http://localhost:8000';
|
|
const USER_ID = 'pratik';
|
|
const POLL_INTERVAL = 30000; // 30 seconds
|
|
|
|
// Global variables
|
|
let simulation;
|
|
let svg, g;
|
|
let nodes = [];
|
|
let links = [];
|
|
let isPolling = true;
|
|
let pollTimer;
|
|
let currentData = null;
|
|
|
|
// DOM elements
|
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
|
const statusIndicator = document.getElementById('statusIndicator');
|
|
const totalMemories = document.getElementById('totalMemories');
|
|
const totalRelationships = document.getElementById('totalRelationships');
|
|
const totalEntities = document.getElementById('totalEntities');
|
|
const pauseBtn = document.getElementById('pauseBtn');
|
|
const refreshBtn = document.getElementById('refreshBtn');
|
|
const resetBtn = document.getElementById('resetBtn');
|
|
const tooltip = document.getElementById('tooltip');
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initializeGraph();
|
|
setupEventListeners();
|
|
loadGraphData();
|
|
startPolling();
|
|
});
|
|
|
|
function setupEventListeners() {
|
|
pauseBtn.addEventListener('click', togglePolling);
|
|
refreshBtn.addEventListener('click', loadGraphData);
|
|
resetBtn.addEventListener('click', resetView);
|
|
}
|
|
|
|
function initializeGraph() {
|
|
const container = document.querySelector('.graph-container');
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
svg = d3.select('#graphSvg')
|
|
.attr('width', width)
|
|
.attr('height', height);
|
|
|
|
// Create main group for zoom/pan
|
|
g = svg.append('g');
|
|
|
|
// Setup zoom behavior
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.1, 10])
|
|
.on('zoom', (event) => {
|
|
g.attr('transform', event.transform);
|
|
});
|
|
|
|
svg.call(zoom);
|
|
|
|
// Initialize force simulation
|
|
simulation = d3.forceSimulation()
|
|
.force('link', d3.forceLink().id(d => d.id).distance(80))
|
|
.force('charge', d3.forceManyBody().strength(-300))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collision', d3.forceCollide().radius(20));
|
|
}
|
|
|
|
async function loadGraphData() {
|
|
try {
|
|
showStatus('Loading...', 'loading');
|
|
|
|
const response = await fetch(`${API_BASE}/graph/relationships/${USER_ID}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
currentData = data;
|
|
|
|
// Update stats display
|
|
totalMemories.textContent = data.total_memories || 0;
|
|
totalRelationships.textContent = data.total_relationships || 0;
|
|
totalEntities.textContent = data.entities ? data.entities.length : 0;
|
|
|
|
// Process data for D3
|
|
processDataAndUpdateGraph(data);
|
|
|
|
showStatus('Connected', 'success');
|
|
hideLoading();
|
|
|
|
} catch (error) {
|
|
console.error('Error loading graph data:', error);
|
|
showStatus('Error: ' + error.message, 'error');
|
|
hideLoading();
|
|
}
|
|
}
|
|
|
|
function processDataAndUpdateGraph(data) {
|
|
// Extract unique entities from relationships and entities array
|
|
const entitySet = new Set();
|
|
|
|
// Add entities from the entities array
|
|
if (data.entities) {
|
|
data.entities.forEach(entity => {
|
|
entitySet.add(entity.name);
|
|
});
|
|
}
|
|
|
|
// Add entities from relationships (in case some are missing from entities array)
|
|
if (data.relationships) {
|
|
data.relationships.forEach(rel => {
|
|
entitySet.add(rel.source);
|
|
entitySet.add(rel.target);
|
|
});
|
|
}
|
|
|
|
// Create nodes array
|
|
nodes = Array.from(entitySet).map(name => ({
|
|
id: name,
|
|
name: name
|
|
}));
|
|
|
|
// Create links array from relationships
|
|
links = data.relationships ? data.relationships.map(rel => ({
|
|
source: rel.source,
|
|
target: rel.target,
|
|
relationship: rel.relationship
|
|
})) : [];
|
|
|
|
updateGraph();
|
|
}
|
|
|
|
function updateGraph() {
|
|
// Clear existing elements
|
|
g.selectAll('.link').remove();
|
|
g.selectAll('.node').remove();
|
|
g.selectAll('.node-label').remove();
|
|
|
|
// Create links
|
|
const link = g.selectAll('.link')
|
|
.data(links)
|
|
.enter().append('line')
|
|
.attr('class', 'link')
|
|
.on('mouseover', showLinkTooltip)
|
|
.on('mouseout', hideTooltip);
|
|
|
|
// Create nodes
|
|
const node = g.selectAll('.node')
|
|
.data(nodes)
|
|
.enter().append('circle')
|
|
.attr('class', 'node')
|
|
.attr('r', 8)
|
|
.attr('fill', '#69b3ff')
|
|
.on('mouseover', showNodeTooltip)
|
|
.on('mouseout', hideTooltip)
|
|
.call(d3.drag()
|
|
.on('start', dragStarted)
|
|
.on('drag', dragged)
|
|
.on('end', dragEnded));
|
|
|
|
// Create node labels
|
|
const label = g.selectAll('.node-label')
|
|
.data(nodes)
|
|
.enter().append('text')
|
|
.attr('class', 'node-label')
|
|
.text(d => d.name)
|
|
.attr('dy', -12);
|
|
|
|
// Update simulation
|
|
simulation.nodes(nodes);
|
|
simulation.force('link').links(links);
|
|
simulation.alpha(1).restart();
|
|
|
|
// Update positions on each tick
|
|
simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x)
|
|
.attr('y2', d => d.target.y);
|
|
|
|
node
|
|
.attr('cx', d => d.x)
|
|
.attr('cy', d => d.y);
|
|
|
|
label
|
|
.attr('x', d => d.x)
|
|
.attr('y', d => d.y);
|
|
});
|
|
}
|
|
|
|
function showNodeTooltip(event, d) {
|
|
const connections = links.filter(link =>
|
|
link.source.id === d.id || link.target.id === d.id
|
|
).length;
|
|
|
|
tooltip.innerHTML = `
|
|
<strong>${d.name}</strong><br>
|
|
Connections: ${connections}
|
|
`;
|
|
tooltip.style.opacity = 1;
|
|
tooltip.style.left = (event.pageX + 10) + 'px';
|
|
tooltip.style.top = (event.pageY - 10) + 'px';
|
|
}
|
|
|
|
function showLinkTooltip(event, d) {
|
|
tooltip.innerHTML = `
|
|
<strong>${d.source.name}</strong><br>
|
|
<em>${d.relationship}</em><br>
|
|
<strong>${d.target.name}</strong>
|
|
`;
|
|
tooltip.style.opacity = 1;
|
|
tooltip.style.left = (event.pageX + 10) + 'px';
|
|
tooltip.style.top = (event.pageY - 10) + 'px';
|
|
}
|
|
|
|
function hideTooltip() {
|
|
tooltip.style.opacity = 0;
|
|
}
|
|
|
|
function dragStarted(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
}
|
|
|
|
function dragged(event, d) {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
}
|
|
|
|
function dragEnded(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
}
|
|
|
|
function startPolling() {
|
|
if (pollTimer) clearInterval(pollTimer);
|
|
|
|
pollTimer = setInterval(() => {
|
|
if (isPolling) {
|
|
loadGraphData();
|
|
}
|
|
}, POLL_INTERVAL);
|
|
}
|
|
|
|
function togglePolling() {
|
|
isPolling = !isPolling;
|
|
pauseBtn.textContent = isPolling ? 'Pause' : 'Resume';
|
|
|
|
if (isPolling) {
|
|
startPolling();
|
|
showStatus('Connected', 'success');
|
|
} else {
|
|
clearInterval(pollTimer);
|
|
showStatus('Paused', 'loading');
|
|
}
|
|
}
|
|
|
|
function resetView() {
|
|
const container = document.querySelector('.graph-container');
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
svg.transition()
|
|
.duration(750)
|
|
.call(
|
|
d3.zoom().transform,
|
|
d3.zoomIdentity.translate(0, 0).scale(1)
|
|
);
|
|
|
|
// Restart simulation to recenter
|
|
if (simulation) {
|
|
simulation.force('center', d3.forceCenter(width / 2, height / 2));
|
|
simulation.alpha(1).restart();
|
|
}
|
|
}
|
|
|
|
function showStatus(message, type) {
|
|
statusIndicator.textContent = message;
|
|
statusIndicator.className = 'status-indicator';
|
|
if (type) {
|
|
statusIndicator.classList.add(type);
|
|
}
|
|
}
|
|
|
|
function hideLoading() {
|
|
loadingOverlay.style.display = 'none';
|
|
}
|
|
|
|
// Handle window resize
|
|
window.addEventListener('resize', () => {
|
|
const container = document.querySelector('.graph-container');
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
svg.attr('width', width).attr('height', height);
|
|
|
|
if (simulation) {
|
|
simulation.force('center', d3.forceCenter(width / 2, height / 2));
|
|
simulation.alpha(1).restart();
|
|
}
|
|
});
|
|
|
|
// Handle page visibility change (pause polling when tab is hidden)
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden && isPolling) {
|
|
clearInterval(pollTimer);
|
|
} else if (!document.hidden && isPolling) {
|
|
startPolling();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|