function renderGraph(containerId, graph, colorMap) { let initialSimulationEnded = false; const baseColors = { page: '#1f77b4', media: '#ff7f0e', default: '#999999' }; const container = document.getElementById(containerId); const width = container.clientWidth; const height = container.clientHeight; const radius = 18; const svg = d3.select('#' + containerId) .append('svg') .attr('width', width) .attr('height', height); const g = svg.append('g'); let currentTransform = d3.zoomIdentity; const zoom = d3.zoom().on('zoom', event => { currentTransform = event.transform; g.attr('transform', currentTransform); updateVisibility(); }); svg.call(zoom); svg.append('defs').append('marker') .attr('id', 'arrowhead') .attr('viewBox', '-0 -5 10 10') .attr('refX', 23) .attr('refY', 0) .attr('orient', 'auto') .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('xoverflow', 'visible') .append('svg:path') .attr('d', 'M 0,-5 L 10,0 L 0,5') .attr('fill', '#999') .style('stroke', 'none'); const defs = svg.append('defs'); const gradientCache = new Map(); let gradientCounter = 0; graph.nodes.forEach((d) => { d.namespace = extractNamespace(d.id); const nsColor = getColorForNode(d, colorMap).toLowerCase(); const baseColor = (baseColors[d.group] || baseColors.default).toLowerCase(); const key = `${baseColor}_${nsColor}`; if (!gradientCache.has(key)) { const gradientId = `grad_${containerId}_${gradientCounter++}`; const gradient = defs.append('radialGradient') .attr('id', gradientId) .attr('cx', '50%') .attr('cy', '50%') .attr('r', '50%'); gradient.append('stop') .attr('offset', '80%') .attr('stop-color', nsColor); gradient.append('stop') .attr('offset', '100%') .attr('stop-color', baseColor); gradientCache.set(key, gradientId); } d.gradientId = gradientCache.get(key); }); const degreeMap = {}; graph.links.forEach(link => { degreeMap[link.source] = (degreeMap[link.source] || 0) + 1; degreeMap[link.target] = (degreeMap[link.target] || 0) + 1; }); const simulation = d3.forceSimulation(graph.nodes) .force('link', d3.forceLink(graph.links) .id(d => d.id) .distance(d => { const sourceDegree = degreeMap[d.source.id] || 1; const targetDegree = degreeMap[d.target.id] || 1; const avgDegree = (sourceDegree + targetDegree) / 2; return 100 + avgDegree * 10; })) .force('charge', d3.forceManyBody().strength(-250)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collision', d3.forceCollide().radius(radius + 10)); const link = g.selectAll('.link') .data(graph.links) .enter().append('path') .attr('class', 'link') .attr('fill', 'none') .style('stroke-width', 1.5) .style('stroke', '#999') .attr('marker-end', 'url(#arrowhead)'); const node = g.selectAll('.node') .data(graph.nodes) .enter().append('circle') .attr('class', 'node') .attr('r', radius) .style('fill', d => `url(#${d.gradientId})`) .style('stroke', d => d.group === 'media' ? '#d62728' : 'none') .style('stroke-width', d => d.group === 'media' ? 3 : 0) .call(d3.drag() .on('start', dragStarted) .on('drag', dragged) .on('end', dragEnded)); const label = g.selectAll('.label') .data(graph.nodes) .enter().append('text') .attr('class', 'label') .attr('text-anchor', 'middle') .attr('dy', -25) .style('font-size', '12px') .text(d => d.label); node.on('mouseover', function(event, d) { node.style('opacity', o => (o === d ? 1 : 0.2)); link.style('stroke', l => (l.source === d || l.target === d ? '#d62728' : '#999')) .style('stroke-width', l => (l.source === d || l.target === d ? 3 : 1.5)) .style('opacity', l => (l.source === d || l.target === d ? 1 : 0.2)); node.style('stroke', o => (o === d || graph.links.some(l => (l.source === d && l.target === o) || (l.target === d && l.source === o)) ? '#d62728' : 'none')) .style('stroke-width', o => (o === d || graph.links.some(l => (l.source === d && l.target === o) || (l.target === d && l.source === o)) ? 3 : 0)); }).on('mouseout', function(event, d) { node.style('opacity', 1) .style('stroke', d => d.group === 'media' ? '#d62728' : 'none') .style('stroke-width', d => d.group === 'media' ? 3 : 0); link.style('stroke', '#999') .style('stroke-width', 1.5) .style('opacity', 1); }); simulation.on('tick', () => { link.attr('d', d => `M${d.source.x},${d.source.y} L${d.target.x},${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); updateVisibility(); }); simulation.on('end', () => { if (!initialSimulationEnded) { fitGraphToViewport(svg, g, width, height); initialSimulationEnded = true; } }); 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 fitGraphToViewport(svg, g, width, height) { const bounds = g.node().getBBox(); const fullWidth = bounds.width; const fullHeight = bounds.height; const midX = bounds.x + fullWidth / 2; const midY = bounds.y + fullHeight / 2; if (fullWidth === 0 || fullHeight === 0) return; const scale = 0.85 / Math.max(fullWidth / width, fullHeight / height); const translate = [width / 2 - scale * midX, height / 2 - scale * midY]; currentTransform = d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale); svg.transition() .duration(750) .call(zoom.transform, currentTransform) .on('end', () => { updateVisibility(); }); } function isInsideViewport(x, y) { return x >= 0 && x <= width && y >= 0 && y <= height; } function updateVisibility() { node.style('visibility', d => { const [x, y] = currentTransform.apply([d.x, d.y]); return isInsideViewport(x, y) ? 'visible' : 'hidden'; }); label.style('visibility', d => { const [x, y] = currentTransform.apply([d.x, d.y]); return isInsideViewport(x, y) ? 'visible' : 'hidden'; }); link.style('visibility', d => { const [sx, sy] = currentTransform.apply([d.source.x, d.source.y]); const [tx, ty] = currentTransform.apply([d.target.x, d.target.y]); return isInsideViewport(sx, sy) || isInsideViewport(tx, ty); }); } function extractNamespace(id) { const parts = id.split(':'); parts.pop(); return parts.join(':'); } function getColorForNode(d, colorMap) { if (!d.namespace) return baseColors[d.group] || baseColors.default; const parts = d.namespace.split(':'); while (parts.length > 0) { const ns = parts.join(':'); if (colorMap[ns]) return colorMap[ns]; parts.pop(); } return baseColors[d.group] || baseColors.default; } }