(data, options = {})
| 185 | } |
| 186 | |
| 187 | function renderGraph(data, options = {}) { |
| 188 | const { svg: svgEl, g: gEl } = ensureGraphSvg(); |
| 189 | svg = svgEl; |
| 190 | g = gEl; |
| 191 | window.g = g; // Ensure global assignment for static export |
| 192 | if (!g) { |
| 193 | console.warn('D3 group (g) is null in renderGraph. Aborting render.'); |
| 194 | return; |
| 195 | } |
| 196 | // Preserve zoom/pan |
| 197 | let prevTransform = null; |
| 198 | if (!svg.empty()) { |
| 199 | const gZoom = svg.select('g'); |
| 200 | if (!gZoom.empty()) { |
| 201 | const transform = gZoom.attr('transform'); |
| 202 | if (transform) prevTransform = transform; |
| 203 | } |
| 204 | } |
| 205 | g.selectAll("*").remove(); |
| 206 | |
| 207 | // Keep simulation alive and update nodes/links |
| 208 | if (!simulation) { |
| 209 | simulation = d3.forceSimulation(data.nodes) |
| 210 | .force("link", d3.forceLink(data.edges).id(d => d.id).distance(80)) |
| 211 | .force("charge", d3.forceManyBody().strength(-200)) |
| 212 | .force("center", d3.forceCenter(width / 2, height / 2)); |
| 213 | } else { |
| 214 | simulation.nodes(data.nodes); |
| 215 | simulation.force("link").links(data.edges); |
| 216 | simulation.alpha(0.7).restart(); |
| 217 | } |
| 218 | |
| 219 | const link = g.append("g") |
| 220 | .attr("stroke", "#999") |
| 221 | .attr("stroke-opacity", 0.6) |
| 222 | .selectAll("line") |
| 223 | .data(data.edges) |
| 224 | .enter().append("line") |
| 225 | .attr("stroke-width", 2); |
| 226 | |
| 227 | const metric = getSelectedMetric(); |
| 228 | const highlightFilter = document.getElementById('highlight-select').value; |
| 229 | const highlightNodes = getHighlightNodes(data.nodes, highlightFilter, metric); |
| 230 | const highlightIds = new Set(highlightNodes.map(n => n.id)); |
| 231 | |
| 232 | const node = g.append("g") |
| 233 | .attr("stroke", getComputedStyle(document.documentElement).getPropertyValue('--node-stroke').trim() || "#fff") |
| 234 | .attr("stroke-width", 1.5) |
| 235 | .selectAll("circle") |
| 236 | .data(data.nodes) |
| 237 | .enter().append("circle") |
| 238 | .attr("r", d => getNodeRadius(d)) |
| 239 | .attr("fill", d => getNodeColor(d)) |
| 240 | .attr("class", d => [ |
| 241 | highlightIds.has(d.id) ? 'node-highlighted' : '', |
| 242 | selectedProgramId === d.id ? 'node-selected' : '' |
| 243 | ].join(' ').trim()) |
| 244 | .attr('stroke', d => selectedProgramId === d.id ? 'red' : (highlightIds.has(d.id) ? '#2196f3' : '#333')) |
no test coverage detected