NeuroScope / frontend /src /components /AttributionGraph.js
Gaurav711's picture
feat: Migrate NeuroScope v2 to Firebase and Google Gemini Explainer
ca6c0e5
Raw
History Blame Contribute Delete
3.59 kB
import { useEffect, useRef } from "react";
import * as d3 from "d3";
import { CATEGORICAL_8 } from "@/lib/colors";
export default function AttributionGraph({ graph, width = 520, height = 320 }) {
const ref = useRef(null);
useEffect(() => {
if (!graph || !ref.current) return;
const svg = d3.select(ref.current);
svg.selectAll("*").remove();
const nodes = graph.nodes.map((n) => ({ ...n }));
const edges = (graph.edges || []).map((e) => ({
source: e.source,
target: e.target,
weight: e.weight,
}));
const sim = d3
.forceSimulation(nodes)
.force(
"link",
d3
.forceLink(edges)
.id((d) => d.id)
.distance((d) => 90 - Math.abs(d.weight) * 60)
.strength((d) => Math.abs(d.weight) * 0.6),
)
.force("charge", d3.forceManyBody().strength(-180))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(18));
const link = svg
.append("g")
.attr("stroke-opacity", 0.4)
.selectAll("line")
.data(edges)
.enter()
.append("line")
.attr("stroke", (d) => (d.weight > 0 ? "#6EE7B7" : "#F87171"))
.attr("stroke-width", (d) => Math.max(0.5, Math.abs(d.weight) * 2));
const maxActivation = Math.max(
1e-6,
...nodes.map((n) => Math.abs(n.activation || 0)),
);
const node = svg
.append("g")
.selectAll("g")
.data(nodes)
.enter()
.append("g");
node.append("circle")
.attr("r", (d) => 5 + 14 * (Math.abs(d.activation || 0) / maxActivation))
.attr("fill", (_, i) => CATEGORICAL_8[i % CATEGORICAL_8.length])
.attr("stroke", "var(--ns-bg-canvas)")
.attr("stroke-width", 1.5);
node.append("text")
.text((d) => `#${d.id}`)
.attr("dy", 4)
.attr("dx", 14)
.attr("font-family", "JetBrains Mono, monospace")
.attr("font-size", 10)
.attr("fill", "var(--ns-fg-secondary)");
node.call(
d3
.drag()
.on("start", (event, d) => {
if (!event.active) sim.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) sim.alphaTarget(0);
d.fx = null;
d.fy = null;
}),
);
sim.on("tick", () => {
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);
node.attr("transform", (d) => `translate(${d.x}, ${d.y})`);
});
return () => sim.stop();
}, [graph, width, height]);
return (
<div
className="rounded-md border"
style={{ background: "var(--ns-bg-codeblock)", borderColor: "var(--ns-border-subtle)" }}
data-testid="attribution-graph"
>
<svg ref={ref} width={width} height={height} />
</div>
);
}