ruvector-extensions
Version:
Advanced features for ruvector: embeddings, UI, exports, temporal tracking, and persistence
583 lines (480 loc) • 19.2 kB
JavaScript
// RuVector Graph Explorer - Client-side Application
class GraphExplorer {
constructor() {
this.nodes = [];
this.links = [];
this.simulation = null;
this.svg = null;
this.g = null;
this.zoom = null;
this.selectedNode = null;
this.ws = null;
this.apiUrl = window.location.origin;
this.init();
}
async init() {
this.setupUI();
this.setupD3();
this.setupWebSocket();
this.setupEventListeners();
await this.loadInitialData();
}
setupUI() {
// Show loading overlay
this.showLoading(true);
// Update connection status
this.updateConnectionStatus('connecting');
}
setupD3() {
const container = d3.select('#graph-canvas');
const width = container.node().getBoundingClientRect().width;
const height = container.node().getBoundingClientRect().height;
// Create SVG
this.svg = container.append('svg')
.attr('width', width)
.attr('height', height)
.style('background', 'transparent');
// Create zoom behavior
this.zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on('zoom', (event) => {
this.g.attr('transform', event.transform);
});
this.svg.call(this.zoom);
// Create main group
this.g = this.svg.append('g');
// Create force simulation
this.simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(30));
}
setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.updateConnectionStatus('connected');
this.showToast('Connected to server', 'success');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.updateConnectionStatus('error');
this.showToast('Connection error', 'error');
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.updateConnectionStatus('disconnected');
this.showToast('Disconnected from server', 'warning');
// Attempt reconnection after 3 seconds
setTimeout(() => this.setupWebSocket(), 3000);
};
}
handleWebSocketMessage(data) {
switch (data.type) {
case 'update':
this.handleGraphUpdate(data.payload);
break;
case 'node_added':
this.handleNodeAdded(data.payload);
break;
case 'node_updated':
this.handleNodeUpdated(data.payload);
break;
case 'similarity_result':
this.handleSimilarityResult(data.payload);
break;
default:
console.log('Unknown message type:', data.type);
}
}
async loadInitialData() {
try {
const response = await fetch(`${this.apiUrl}/api/graph`);
if (!response.ok) throw new Error('Failed to load graph data');
const data = await response.json();
this.updateGraph(data.nodes, data.links);
this.showLoading(false);
this.showToast('Graph loaded successfully', 'success');
} catch (error) {
console.error('Error loading data:', error);
this.showLoading(false);
this.showToast('Failed to load graph data', 'error');
}
}
updateGraph(nodes, links) {
this.nodes = nodes;
this.links = links;
this.updateStatistics();
this.renderGraph();
}
renderGraph() {
// Remove existing elements
this.g.selectAll('.link').remove();
this.g.selectAll('.node').remove();
this.g.selectAll('.node-label').remove();
// Create links
const link = this.g.selectAll('.link')
.data(this.links)
.enter().append('line')
.attr('class', 'link')
.attr('stroke-width', d => Math.sqrt(d.similarity * 5) || 1);
// Create nodes
const node = this.g.selectAll('.node')
.data(this.nodes)
.enter().append('circle')
.attr('class', 'node')
.attr('r', 15)
.attr('fill', d => this.getNodeColor(d))
.call(this.drag(this.simulation))
.on('click', (event, d) => this.handleNodeClick(event, d))
.on('dblclick', (event, d) => this.handleNodeDoubleClick(event, d));
// Create labels
const label = this.g.selectAll('.node-label')
.data(this.nodes)
.enter().append('text')
.attr('class', 'node-label')
.attr('dy', -20)
.text(d => d.label || d.id.substring(0, 8));
// Update simulation
this.simulation.nodes(this.nodes);
this.simulation.force('link').links(this.links);
this.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);
});
this.simulation.alpha(1).restart();
}
getNodeColor(node) {
// Color based on metadata or cluster
if (node.metadata && node.metadata.category) {
const categories = ['research', 'code', 'documentation', 'test'];
const index = categories.indexOf(node.metadata.category);
const colors = ['#667eea', '#f093fb', '#4caf50', '#ff9800'];
return colors[index] || '#667eea';
}
return '#667eea';
}
drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
}
handleNodeClick(event, node) {
event.stopPropagation();
// Deselect previous node
this.g.selectAll('.node').classed('selected', false);
// Select new node
this.selectedNode = node;
d3.select(event.currentTarget).classed('selected', true);
// Show metadata panel
this.showMetadata(node);
this.updateStatistics();
}
handleNodeDoubleClick(event, node) {
event.stopPropagation();
this.findSimilarNodes(node.id);
}
showMetadata(node) {
const panel = document.getElementById('metadata-panel');
const content = document.getElementById('metadata-content');
let html = `
<div class="metadata-item">
<strong>ID:</strong>
<div>${node.id}</div>
</div>
`;
if (node.metadata) {
for (const [key, value] of Object.entries(node.metadata)) {
html += `
<div class="metadata-item">
<strong>${key}:</strong>
<div>${JSON.stringify(value, null, 2)}</div>
</div>
`;
}
}
content.innerHTML = html;
panel.style.display = 'block';
}
async findSimilarNodes(nodeId) {
if (!nodeId && this.selectedNode) {
nodeId = this.selectedNode.id;
}
if (!nodeId) {
this.showToast('Please select a node first', 'warning');
return;
}
this.showLoading(true);
try {
const minSimilarity = parseFloat(document.getElementById('min-similarity').value);
const response = await fetch(
`${this.apiUrl}/api/similarity/${nodeId}?threshold=${minSimilarity}`
);
if (!response.ok) throw new Error('Failed to find similar nodes');
const data = await response.json();
this.highlightSimilarNodes(data.similar);
this.showToast(`Found ${data.similar.length} similar nodes`, 'success');
} catch (error) {
console.error('Error finding similar nodes:', error);
this.showToast('Failed to find similar nodes', 'error');
} finally {
this.showLoading(false);
}
}
highlightSimilarNodes(similarNodes) {
// Reset highlights
this.g.selectAll('.node').classed('highlighted', false);
this.g.selectAll('.link').classed('highlighted', false);
const similarIds = new Set(similarNodes.map(n => n.id));
// Highlight nodes
this.g.selectAll('.node')
.classed('highlighted', d => similarIds.has(d.id));
// Highlight links
this.g.selectAll('.link')
.classed('highlighted', d =>
similarIds.has(d.source.id) && similarIds.has(d.target.id)
);
}
async searchNodes(query) {
if (!query.trim()) {
this.renderGraph();
return;
}
try {
const response = await fetch(
`${this.apiUrl}/api/search?q=${encodeURIComponent(query)}`
);
if (!response.ok) throw new Error('Search failed');
const data = await response.json();
this.highlightSearchResults(data.results);
this.showToast(`Found ${data.results.length} matches`, 'success');
} catch (error) {
console.error('Search error:', error);
this.showToast('Search failed', 'error');
}
}
highlightSearchResults(results) {
const resultIds = new Set(results.map(r => r.id));
this.g.selectAll('.node')
.style('opacity', d => resultIds.has(d.id) ? 1 : 0.2);
this.g.selectAll('.link')
.style('opacity', d =>
resultIds.has(d.source.id) || resultIds.has(d.target.id) ? 0.6 : 0.1
);
}
updateStatistics() {
document.getElementById('stat-nodes').textContent = this.nodes.length;
document.getElementById('stat-edges').textContent = this.links.length;
document.getElementById('stat-selected').textContent =
this.selectedNode ? this.selectedNode.id.substring(0, 8) : 'None';
}
updateConnectionStatus(status) {
const statusEl = document.getElementById('connection-status');
const dot = statusEl.querySelector('.status-dot');
const text = statusEl.querySelector('.status-text');
const statusMap = {
connecting: { text: 'Connecting...', class: '' },
connected: { text: 'Connected', class: 'connected' },
disconnected: { text: 'Disconnected', class: '' },
error: { text: 'Error', class: '' }
};
const config = statusMap[status] || statusMap.disconnected;
text.textContent = config.text;
dot.className = `status-dot ${config.class}`;
}
showLoading(show) {
const overlay = document.getElementById('loading-overlay');
overlay.classList.toggle('hidden', !show);
}
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease-out reverse';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async exportPNG() {
try {
const svgElement = this.svg.node();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const bbox = svgElement.getBBox();
canvas.width = bbox.width + 40;
canvas.height = bbox.height + 40;
// Fill background
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const svgString = new XMLSerializer().serializeToString(svgElement);
const img = new Image();
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = () => {
ctx.drawImage(img, 20, 20);
canvas.toBlob((blob) => {
const link = document.createElement('a');
link.download = `graph-${Date.now()}.png`;
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(url);
this.showToast('Graph exported as PNG', 'success');
});
};
img.src = url;
} catch (error) {
console.error('Export error:', error);
this.showToast('Failed to export PNG', 'error');
}
}
exportSVG() {
try {
const svgElement = this.svg.node();
const svgString = new XMLSerializer().serializeToString(svgElement);
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const link = document.createElement('a');
link.download = `graph-${Date.now()}.svg`;
link.href = URL.createObjectURL(blob);
link.click();
this.showToast('Graph exported as SVG', 'success');
} catch (error) {
console.error('Export error:', error);
this.showToast('Failed to export SVG', 'error');
}
}
resetView() {
this.svg.transition()
.duration(750)
.call(this.zoom.transform, d3.zoomIdentity);
}
fitView() {
const bounds = this.g.node().getBBox();
const parent = this.svg.node().getBoundingClientRect();
const fullWidth = parent.width;
const fullHeight = parent.height;
const width = bounds.width;
const height = bounds.height;
const midX = bounds.x + width / 2;
const midY = bounds.y + height / 2;
const scale = 0.85 / Math.max(width / fullWidth, height / fullHeight);
const translate = [fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY];
this.svg.transition()
.duration(750)
.call(this.zoom.transform, d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale));
}
zoomIn() {
this.svg.transition().call(this.zoom.scaleBy, 1.3);
}
zoomOut() {
this.svg.transition().call(this.zoom.scaleBy, 0.7);
}
setupEventListeners() {
// Search
const searchInput = document.getElementById('node-search');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.searchNodes(e.target.value), 300);
});
document.getElementById('clear-search').addEventListener('click', () => {
searchInput.value = '';
this.renderGraph();
});
// Filters
const similaritySlider = document.getElementById('min-similarity');
similaritySlider.addEventListener('input', (e) => {
document.getElementById('similarity-value').textContent =
parseFloat(e.target.value).toFixed(2);
});
document.getElementById('apply-filters').addEventListener('click', () => {
this.loadInitialData();
});
// Metadata panel
document.getElementById('find-similar').addEventListener('click', () => {
this.findSimilarNodes();
});
document.getElementById('close-metadata').addEventListener('click', () => {
document.getElementById('metadata-panel').style.display = 'none';
this.selectedNode = null;
this.g.selectAll('.node').classed('selected', false);
this.updateStatistics();
});
// Export
document.getElementById('export-png').addEventListener('click', () => this.exportPNG());
document.getElementById('export-svg').addEventListener('click', () => this.exportSVG());
// View controls
document.getElementById('reset-view').addEventListener('click', () => this.resetView());
document.getElementById('zoom-in').addEventListener('click', () => this.zoomIn());
document.getElementById('zoom-out').addEventListener('click', () => this.zoomOut());
document.getElementById('fit-view').addEventListener('click', () => this.fitView());
// Window resize
window.addEventListener('resize', () => {
const container = d3.select('#graph-canvas');
const width = container.node().getBoundingClientRect().width;
const height = container.node().getBoundingClientRect().height;
this.svg
.attr('width', width)
.attr('height', height);
this.simulation
.force('center', d3.forceCenter(width / 2, height / 2))
.alpha(0.3)
.restart();
});
}
handleGraphUpdate(data) {
this.updateGraph(data.nodes, data.links);
}
handleNodeAdded(node) {
this.nodes.push(node);
this.renderGraph();
this.showToast('New node added', 'info');
}
handleNodeUpdated(node) {
const index = this.nodes.findIndex(n => n.id === node.id);
if (index !== -1) {
this.nodes[index] = { ...this.nodes[index], ...node };
this.renderGraph();
this.showToast('Node updated', 'info');
}
}
handleSimilarityResult(data) {
this.highlightSimilarNodes(data.similar);
}
}
// Initialize application when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.graphExplorer = new GraphExplorer();
});