457 lines
15 KiB
JavaScript

let networkGraph = null;
let simulation = null;
let showLabels = true;
let pinnedNodes = new Set();
let hiddenCategories = new Set();
let showIsolated = true;
const categoryColors = {
'core_value': { fill: '#ef4444', stroke: '#fee2e2', gradient: ['#ef4444', '#dc2626'] },
'hard_constraint': { fill: '#f59e0b', stroke: '#fef3c7', gradient: ['#f59e0b', '#d97706'] },
'soft_constraint': { fill: '#10b981', stroke: '#d1fae5', gradient: ['#10b981', '#059669'] },
'factor': { fill: '#3b82f6', stroke: '#dbeafe', gradient: ['#3b82f6', '#2563eb'] }
};
document.addEventListener('DOMContentLoaded', function() {
const networkTabButton = document.querySelector('[data-tab="network"]');
networkTabButton.addEventListener('click', function() {
setTimeout(() => {
initializeNetworkGraph();
}, 100);
});
document.getElementById('reset-zoom').addEventListener('click', resetZoom);
document.getElementById('toggle-labels').addEventListener('click', toggleLabels);
const showIsolatedBtn = document.getElementById('show-isolated');
if (showIsolatedBtn) {
showIsolatedBtn.addEventListener('click', toggleIsolated);
}
document.querySelectorAll('.legend-item').forEach(item => {
item.addEventListener('click', function() {
const category = this.dataset.category;
if (hiddenCategories.has(category)) {
hiddenCategories.delete(category);
this.classList.remove('hidden');
} else {
hiddenCategories.add(category);
this.classList.add('hidden');
}
if (networkGraph) {
filterByCategory();
}
});
});
});
function initializeNetworkGraph() {
const container = document.getElementById('network-graph');
if (!container || !window.appData.graph) {
return;
}
container.innerHTML = '';
const width = container.clientWidth;
const height = container.clientHeight;
const svg = d3.select('#network-graph')
.append('svg')
.attr('width', width)
.attr('height', height)
.style('background', '#1a1a1a');
const defs = svg.append('defs');
const gradientKeys = Object.keys(categoryColors);
gradientKeys.forEach(key => {
const grad = defs.append('radialGradient')
.attr('id', `grad-${key}`)
.attr('cx', '50%')
.attr('cy', '50%')
.attr('r', '50%')
.attr('fx', '50%')
.attr('fy', '50%');
grad.append('stop')
.attr('offset', '0%')
.attr('stop-color', categoryColors[key].gradient[0]);
grad.append('stop')
.attr('offset', '100%')
.attr('stop-color', categoryColors[key].gradient[1]);
});
const arrowMarker = defs.append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '-0 -5 10 10')
.attr('refX', 25)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.append('path')
.attr('d', 'M 0,-5 L 10 ,0 L 0,5')
.attr('fill', '#666');
const g = svg.append('g');
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
const graph = window.appData.graph;
if (!graph.nodes || !graph.edges) {
container.innerHTML = '<div style="padding: 2rem; text-align: center; color: var(--text-secondary);">No graph data available</div>';
return;
}
const nodeConnections = new Map();
graph.nodes.forEach(n => nodeConnections.set(n.id, new Set()));
graph.edges.forEach(e => {
nodeConnections.get(e.source.id || e.source)?.add(e.target.id || e.target);
nodeConnections.get(e.target.id || e.target)?.add(e.source.id || e.source);
});
const link = g.append('g')
.attr('class', 'links')
.selectAll('line')
.data(graph.edges)
.enter()
.append('line')
.attr('stroke-width', d => Math.max(1, Math.min(3, Math.sqrt(d.weight))))
.attr('stroke', '#666')
.attr('stroke-opacity', 0.6)
.attr('marker-end', 'url(#arrowhead)');
const node = g.append('g')
.attr('class', 'nodes')
.selectAll('g')
.data(graph.nodes)
.enter()
.append('g')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
const nodeGroups = node.append('circle')
.attr('r', d => Math.max(5, d.size))
.attr('fill', d => `url(#grad-${d.category})`)
.attr('stroke', d => categoryColors[d.category]?.stroke || '#fff')
.attr('stroke-width', 2)
.style('filter', 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))')
.style('cursor', 'grab')
.on('mouseover', (event, d) => {
highlightConnections(d, nodeConnections);
showNodeTooltip(event, d);
})
.on('mouseout', () => {
resetHighlight();
hideNodeTooltip();
})
.on('click', (event, d) => {
togglePin(d);
showNodeDetail(event, d);
})
.on('dblclick', (event, d) => {
event.stopPropagation();
zoomToNode(d);
});
const pinIcon = node.append('text')
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.attr('font-size', '12px')
.attr('fill', 'white')
.style('pointer-events', 'none')
.style('opacity', d => pinnedNodes.has(d.id) ? 1 : 0)
.text('📌');
const labels = g.append('g')
.attr('class', 'labels')
.selectAll('text')
.data(graph.nodes)
.enter()
.append('text')
.text(d => d.name.replace(/\n/g, ' ').substring(0, 20) + (d.name.length > 20 ? '...' : ''))
.attr('font-size', '11px')
.attr('fill', '#e0e0e0')
.attr('text-anchor', 'middle')
.attr('dy', d => -Math.max(8, d.size) - 5)
.style('pointer-events', 'none')
.style('text-shadow', '0 1px 3px rgba(0,0,0,0.8)')
.style('opacity', 0.9);
simulation = d3.forceSimulation(graph.nodes)
.force('link', d3.forceLink(graph.edges).id(d => d.id).distance(80).strength(0.5))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(d => Math.max(8, d.size) + 5))
.force('x', d3.forceX(width / 2).strength(0.05))
.force('y', d3.forceY(height / 2).strength(0.05))
.on('tick', ticked);
function ticked() {
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);
nodeGroups
.attr('cx', d => d.x)
.attr('cy', d => d.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
labels
.attr('x', d => d.x)
.attr('y', d => d.y);
}
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
nodeGroups.style('cursor', 'grabbing');
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
if (!pinnedNodes.has(d.id)) {
d.fx = null;
d.fy = null;
}
nodeGroups.style('cursor', 'grab');
}
function highlightConnections(d, connections) {
const connectedIds = connections.get(d.id) || new Set();
connectedIds.add(d.id);
nodeGroups
.style('opacity', n => connectedIds.has(n.id) ? 1 : 0.1)
.style('filter', n => {
if (connectedIds.has(n.id) && n.id !== d.id) {
return 'drop-shadow(0 0 8px ' + categoryColors[n.category]?.fill + ')';
}
if (n.id === d.id) {
return 'drop-shadow(0 0 12px white) drop-shadow(0 4px 8px rgba(0,0,0,0.5))';
}
return 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))';
});
link.style('opacity', l => {
return (l.source.id === d.id || l.target.id === d.id) ? 1 : 0.1;
});
labels.style('opacity', n => connectedIds.has(n.id) ? 0.9 : 0.1);
}
function resetHighlight() {
nodeGroups
.style('opacity', 1)
.style('filter', 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))');
link.style('opacity', 0.6);
labels.style('opacity', showLabels ? 0.9 : 0);
}
function filterByCategory() {
const visibleNodes = graph.nodes.filter(n => !hiddenCategories.has(n.category));
const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
nodeGroups.style('display', n => hiddenCategories.has(n.category) ? 'none' : 'block');
labels.style('display', n => hiddenCategories.has(n.category) ? 'none' : 'block');
link.style('display', l => {
const sourceVisible = visibleNodeIds.has(l.source.id);
const targetVisible = visibleNodeIds.has(l.target.id);
return sourceVisible && targetVisible ? 'block' : 'none';
});
if (!showIsolated) {
const connectedNodeIds = new Set();
graph.edges.forEach(e => {
connectedNodeIds.add(e.source.id);
connectedNodeIds.add(e.target.id);
});
nodeGroups.style('display', n => {
if (hiddenCategories.has(n.category)) return 'none';
if (!showIsolated && !connectedNodeIds.has(n.id)) return 'none';
return 'block';
});
labels.style('display', n => {
if (hiddenCategories.has(n.category)) return 'none';
if (!showIsolated && !connectedNodeIds.has(n.id)) return 'none';
return 'block';
});
}
simulation.alpha(0.3).restart();
}
function toggleIsolated() {
showIsolated = !showIsolated;
const btn = document.getElementById('show-isolated');
if (btn) {
btn.textContent = showIsolated ? 'Hide Isolated' : 'Show Isolated';
}
filterByCategory();
}
function togglePin(d) {
if (pinnedNodes.has(d.id)) {
pinnedNodes.delete(d.id);
pinIcon.style('opacity', 0);
d.fx = null;
d.fy = null;
} else {
pinnedNodes.add(d.id);
pinIcon.style('opacity', 1);
d.fx = d.x;
d.fy = d.y;
}
simulation.alpha(0.3).restart();
}
function zoomToNode(d) {
const padding = 100;
const scale = Math.min(width, height) / (d.size * 10);
networkGraph.svg.transition()
.duration(750)
.call(networkGraph.zoom.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-d.x, -d.y)
);
}
networkGraph = { svg, g, zoom, node, labels, nodeGroups, pinIcon };
}
function showNodeTooltip(event, d) {
const tooltip = d3.select('body').append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('background', '#242424')
.style('color', '#e0e0e0')
.style('padding', '10px')
.style('border-radius', '4px')
.style('pointer-events', 'none')
.style('z-index', '1000')
.style('border', '1px solid #444')
.html(`
<strong>${d.name}</strong><br/>
Category: ${d.category}<br/>
Frequency: ${d.frequency}<br/>
Coefficient: ${d.coefficient.toFixed(3)}
`);
tooltip.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px');
}
function hideNodeTooltip() {
d3.selectAll('.tooltip').remove();
}
function showNodeDetail(event, d) {
const variable = window.appData.variables.find(v => v.name === d.name);
if (variable) {
if (typeof showVariableDetail === 'function') {
showVariableDetail(variable);
}
}
}
function resetZoom() {
if (networkGraph && networkGraph.zoom) {
networkGraph.svg.transition()
.duration(750)
.call(networkGraph.zoom.transform, d3.zoomIdentity);
}
}
function toggleLabels() {
showLabels = !showLabels;
if (networkGraph && networkGraph.labels) {
networkGraph.labels
.transition()
.duration(300)
.style('opacity', showLabels ? 0.9 : 0);
}
}
function showNodeTooltip(event, d) {
const connections = Array.from(networkGraph.svg.selectAll('line')
.filter(l => l.source.id === d.id || l.target.id === d.id)
.data())
.map(l => l.source.id === d.id ? l.target.name : l.source.name)
.slice(0, 5);
const tooltip = d3.select('body').append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('background', 'rgba(26, 26, 26, 0.95)')
.style('color', '#e0e0e0')
.style('padding', '12px')
.style('border-radius', '8px')
.style('pointer-events', 'none')
.style('z-index', '1000')
.style('border', '1px solid #444')
.style('box-shadow', '0 4px 12px rgba(0,0,0,0.4)')
.style('max-width', '250px')
.html(`
<div style="font-weight: 600; font-size: 14px; margin-bottom: 8px; color: ${categoryColors[d.category]?.fill || '#fff'}">${d.name}</div>
<div style="font-size: 12px; color: #999; margin-bottom: 6px;">Category: <span style="color: #e0e0e0">${d.category}</span></div>
<div style="font-size: 12px; color: #999; margin-bottom: 6px;">Frequency: <span style="color: #e0e0e0">${d.frequency}</span></div>
<div style="font-size: 12px; color: #999; margin-bottom: 6px;">Coefficient: <span style="color: #e0e0e0">${d.coefficient.toFixed(3)}</span></div>
${connections.length > 0 ? `<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #444;">
<div style="font-size: 11px; color: #999; margin-bottom: 4px;">Connected to:</div>
${connections.map(c => `<div style="font-size: 11px; color: #ccc;">• ${c.substring(0, 25)}${c.length > 25 ? '...' : ''}</div>`).join('')}
</div>` : ''}
`);
const tooltipWidth = 250;
const tooltipHeight = 150;
let left = event.pageX + 15;
let top = event.pageY + 15;
if (left + tooltipWidth > window.innerWidth) {
left = event.pageX - tooltipWidth - 15;
}
if (top + tooltipHeight > window.innerHeight) {
top = event.pageY - tooltipHeight - 15;
}
tooltip.style('left', left + 'px')
.style('top', top + 'px');
}
window.addEventListener('resize', function() {
if (currentTab === 'network') {
initializeNetworkGraph();
}
});