1function renderGraph(containerId, graph, colorMap) { 2 let initialSimulationEnded = false; 3 const baseColors = { 4 page: '#1f77b4', 5 media: '#ff7f0e', 6 default: '#999999' 7 }; 8 9 const container = document.getElementById(containerId); 10 const width = container.clientWidth; 11 const height = container.clientHeight; 12 const radius = 18; 13 14 const svg = d3.select('#' + containerId) 15 .append('svg') 16 .attr('width', width) 17 .attr('height', height); 18 19 const g = svg.append('g'); 20 21 let currentTransform = d3.zoomIdentity; 22 23 const zoom = d3.zoom().on('zoom', event => { 24 currentTransform = event.transform; 25 g.attr('transform', currentTransform); 26 updateVisibility(); 27 }); 28 29 svg.call(zoom); 30 31 svg.append('defs').append('marker') 32 .attr('id', 'arrowhead') 33 .attr('viewBox', '-0 -5 10 10') 34 .attr('refX', 23) 35 .attr('refY', 0) 36 .attr('orient', 'auto') 37 .attr('markerWidth', 6) 38 .attr('markerHeight', 6) 39 .attr('xoverflow', 'visible') 40 .append('svg:path') 41 .attr('d', 'M 0,-5 L 10,0 L 0,5') 42 .attr('fill', '#999') 43 .style('stroke', 'none'); 44 45 const defs = svg.append('defs'); 46 const gradientCache = new Map(); 47 let gradientCounter = 0; 48 49 graph.nodes.forEach((d) => { 50 d.namespace = extractNamespace(d.id); 51 52 const nsColor = getColorForNode(d, colorMap).toLowerCase(); 53 const baseColor = (baseColors[d.group] || baseColors.default).toLowerCase(); 54 const key = `${baseColor}_${nsColor}`; 55 56 if (!gradientCache.has(key)) { 57 const gradientId = `grad_${containerId}_${gradientCounter++}`; 58 const gradient = defs.append('radialGradient') 59 .attr('id', gradientId) 60 .attr('cx', '50%') 61 .attr('cy', '50%') 62 .attr('r', '50%'); 63 64 gradient.append('stop') 65 .attr('offset', '80%') 66 .attr('stop-color', nsColor); 67 68 gradient.append('stop') 69 .attr('offset', '100%') 70 .attr('stop-color', baseColor); 71 72 gradientCache.set(key, gradientId); 73 } 74 75 d.gradientId = gradientCache.get(key); 76 }); 77 78 const degreeMap = {}; 79 graph.links.forEach(link => { 80 degreeMap[link.source] = (degreeMap[link.source] || 0) + 1; 81 degreeMap[link.target] = (degreeMap[link.target] || 0) + 1; 82 }); 83 84 const simulation = d3.forceSimulation(graph.nodes) 85 .force('link', d3.forceLink(graph.links) 86 .id(d => d.id) 87 .distance(d => { 88 const sourceDegree = degreeMap[d.source.id] || 1; 89 const targetDegree = degreeMap[d.target.id] || 1; 90 const avgDegree = (sourceDegree + targetDegree) / 2; 91 return 100 + avgDegree * 10; 92 })) 93 .force('charge', d3.forceManyBody().strength(-250)) 94 .force('center', d3.forceCenter(width / 2, height / 2)) 95 .force('collision', d3.forceCollide().radius(radius + 10)); 96 97 const link = g.selectAll('.link') 98 .data(graph.links) 99 .enter().append('path') 100 .attr('class', 'link') 101 .attr('fill', 'none') 102 .style('stroke-width', 1.5) 103 .style('stroke', '#999') 104 .attr('marker-end', 'url(#arrowhead)'); 105 106 const node = g.selectAll('.node') 107 .data(graph.nodes) 108 .enter().append('circle') 109 .attr('class', 'node') 110 .attr('r', radius) 111 .style('fill', d => `url(#${d.gradientId})`) 112 .style('stroke', d => d.group === 'media' ? '#d62728' : 'none') 113 .style('stroke-width', d => d.group === 'media' ? 3 : 0) 114 .call(d3.drag() 115 .on('start', dragStarted) 116 .on('drag', dragged) 117 .on('end', dragEnded)); 118 119 const label = g.selectAll('.label') 120 .data(graph.nodes) 121 .enter().append('text') 122 .attr('class', 'label') 123 .attr('text-anchor', 'middle') 124 .attr('dy', -25) 125 .style('font-size', '12px') 126 .text(d => d.label); 127 128 node.on('mouseover', function(event, d) { 129 node.style('opacity', o => (o === d ? 1 : 0.2)); 130 131 link.style('stroke', l => (l.source === d || l.target === d ? '#d62728' : '#999')) 132 .style('stroke-width', l => (l.source === d || l.target === d ? 3 : 1.5)) 133 .style('opacity', l => (l.source === d || l.target === d ? 1 : 0.2)); 134 135 node.style('stroke', o => (o === d || graph.links.some(l => (l.source === d && l.target === o) || (l.target === d && l.source === o)) ? '#d62728' : 'none')) 136 .style('stroke-width', o => (o === d || graph.links.some(l => (l.source === d && l.target === o) || (l.target === d && l.source === o)) ? 3 : 0)); 137 }).on('mouseout', function(event, d) { 138 node.style('opacity', 1) 139 .style('stroke', d => d.group === 'media' ? '#d62728' : 'none') 140 .style('stroke-width', d => d.group === 'media' ? 3 : 0); 141 142 link.style('stroke', '#999') 143 .style('stroke-width', 1.5) 144 .style('opacity', 1); 145 }); 146 147 simulation.on('tick', () => { 148 link.attr('d', d => `M${d.source.x},${d.source.y} L${d.target.x},${d.target.y}`); 149 node.attr('cx', d => d.x).attr('cy', d => d.y); 150 label.attr('x', d => d.x).attr('y', d => d.y); 151 updateVisibility(); 152 }); 153 154 simulation.on('end', () => { 155 if (!initialSimulationEnded) { 156 fitGraphToViewport(svg, g, width, height); 157 initialSimulationEnded = true; 158 } 159 }); 160 161 function dragStarted(event, d) { 162 if (!event.active) simulation.alphaTarget(0.3).restart(); 163 d.fx = d.x; 164 d.fy = d.y; 165 } 166 167 function dragged(event, d) { 168 d.fx = event.x; 169 d.fy = event.y; 170 } 171 172 function dragEnded(event, d) { 173 if (!event.active) simulation.alphaTarget(0); 174 d.fx = null; 175 d.fy = null; 176 } 177 178 function fitGraphToViewport(svg, g, width, height) { 179 const bounds = g.node().getBBox(); 180 const fullWidth = bounds.width; 181 const fullHeight = bounds.height; 182 const midX = bounds.x + fullWidth / 2; 183 const midY = bounds.y + fullHeight / 2; 184 185 if (fullWidth === 0 || fullHeight === 0) return; 186 187 const scale = 0.85 / Math.max(fullWidth / width, fullHeight / height); 188 const translate = [width / 2 - scale * midX, height / 2 - scale * midY]; 189 190 currentTransform = d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale); 191 192 svg.transition() 193 .duration(750) 194 .call(zoom.transform, currentTransform) 195 .on('end', () => { 196 updateVisibility(); 197 }); 198 } 199 200 function isInsideViewport(x, y) { 201 return x >= 0 && x <= width && y >= 0 && y <= height; 202 } 203 204 function updateVisibility() { 205 node.style('visibility', d => { 206 const [x, y] = currentTransform.apply([d.x, d.y]); 207 return isInsideViewport(x, y) ? 'visible' : 'hidden'; 208 }); 209 210 label.style('visibility', d => { 211 const [x, y] = currentTransform.apply([d.x, d.y]); 212 return isInsideViewport(x, y) ? 'visible' : 'hidden'; 213 }); 214 215 link.style('visibility', d => { 216 const [sx, sy] = currentTransform.apply([d.source.x, d.source.y]); 217 const [tx, ty] = currentTransform.apply([d.target.x, d.target.y]); 218 return isInsideViewport(sx, sy) || isInsideViewport(tx, ty); 219 }); 220 } 221 222 function extractNamespace(id) { 223 const parts = id.split(':'); 224 parts.pop(); 225 return parts.join(':'); 226 } 227 228 function getColorForNode(d, colorMap) { 229 if (!d.namespace) return baseColors[d.group] || baseColors.default; 230 const parts = d.namespace.split(':'); 231 while (parts.length > 0) { 232 const ns = parts.join(':'); 233 if (colorMap[ns]) return colorMap[ns]; 234 parts.pop(); 235 } 236 return baseColors[d.group] || baseColors.default; 237 } 238} 239