457 lines
15 KiB
JavaScript
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();
|
|
}
|
|
});
|