({ nodes, links, connCount })
| 507 | } |
| 508 | |
| 509 | function renderGraph({ nodes, links, connCount }) { |
| 510 | const svgEl = document.getElementById("graph-svg"); |
| 511 | const tooltip = document.getElementById("graph-tooltip"); |
| 512 | const svg = d3.select(svgEl); |
| 513 | svg.selectAll("*").remove(); |
| 514 | |
| 515 | const W = svgEl.clientWidth || 800; |
| 516 | const H = svgEl.clientHeight || 600; |
| 517 | |
| 518 | // Zoom container |
| 519 | const g = svg.append("g"); |
| 520 | |
| 521 | const zoom = d3.zoom() |
| 522 | .scaleExtent([0.2, 4]) |
| 523 | .on("zoom", e => g.attr("transform", e.transform)); |
| 524 | svg.call(zoom); |
| 525 | |
| 526 | // Simulation |
| 527 | if (graphSimulation) graphSimulation.stop(); |
| 528 | graphSimulation = d3.forceSimulation(nodes) |
| 529 | .force("link", d3.forceLink(links).id(d => d.id).distance(90).strength(0.4)) |
| 530 | .force("charge", d3.forceManyBody().strength(-220)) |
| 531 | .force("center", d3.forceCenter(W / 2, H / 2)) |
| 532 | .force("collision", d3.forceCollide().radius(d => graphNodeRadius(connCount[d.id]) + 10)); |
| 533 | |
| 534 | // ── Defs: glow filter ── |
| 535 | const defs = svg.append("defs"); |
| 536 | |
| 537 | const glowFilter = defs.append("filter").attr("id", "node-glow").attr("x", "-50%").attr("y", "-50%").attr("width", "200%").attr("height", "200%"); |
| 538 | glowFilter.append("feGaussianBlur").attr("stdDeviation", "4").attr("result", "blur"); |
| 539 | const feMerge = glowFilter.append("feMerge"); |
| 540 | feMerge.append("feMergeNode").attr("in", "blur"); |
| 541 | feMerge.append("feMergeNode").attr("in", "SourceGraphic"); |
| 542 | |
| 543 | const activeGlow = defs.append("filter").attr("id", "active-glow").attr("x", "-80%").attr("y", "-80%").attr("width", "260%").attr("height", "260%"); |
| 544 | activeGlow.append("feGaussianBlur").attr("stdDeviation", "7").attr("result", "blur"); |
| 545 | const feMerge2 = activeGlow.append("feMerge"); |
| 546 | feMerge2.append("feMergeNode").attr("in", "blur"); |
| 547 | feMerge2.append("feMergeNode").attr("in", "SourceGraphic"); |
| 548 | |
| 549 | // ── Links ── |
| 550 | const linkGroup = g.append("g").attr("class", "links"); |
| 551 | const linkSel = linkGroup.selectAll("line") |
| 552 | .data(links) |
| 553 | .join("line") |
| 554 | .attr("stroke", "rgba(0,201,177,0.18)") |
| 555 | .attr("stroke-width", 1); |
| 556 | |
| 557 | // ── Nodes ── |
| 558 | const nodeGroup = g.append("g").attr("class", "nodes"); |
| 559 | const nodeSel = nodeGroup.selectAll("g") |
| 560 | .data(nodes) |
| 561 | .join("g") |
| 562 | .attr("class", "graph-node") |
| 563 | .style("cursor", "pointer") |
| 564 | .call( |
| 565 | d3.drag() |
| 566 | .on("start", (e, d) => { |
no test coverage detected