D3 seems so complicated to make work with basically any framework... Specially when it comes to TypeScript, since they apparently keep changing their APIs, rendering so many past examples from gists and other posts close to useless.
At the moment, I would like to make work with TypeScript and SolidJS the canonical force graph example.
I feel like trying to solve a jigsaw puzzle since a lot of the types just won't match. The one I've had the most trouble with was the drag()
arguments, I believe. I've also tried simply ignoring types, of course, but I couldn't make it work either.
Also, does anyone know if it is possible to append any arbitrary component as a graph node?
To create a FDG (Force Directed Graph) like the one in the docs example using SolidJS, there's very little that's SolidJS specific, it's mostly about knowing how d3 works and what function invocation does what and through that one knows where to do what (whether to use a side effect, or do something on mounting etc., which is what I'm referring to as "where").
Most of the methods make use of typescript generics and infer the types from the arguments, however, at times it's better to just add the type parameters. To not to have issues with types, here's a little tip -
Always check the return, parameter types of the function calls, go to library source if you need to and then add the typings accordingly
You had issues with the d3.drag
because by default, the generics take an unknown
type (unless they're assigned a default type), if you're explicit in the call with the type parameters, ts doesn't complain.
Here's how you can create a graph like the one in example -
import * as d3 from "d3";
import { onMount } from "solid-js";
interface FDGNode extends d3.SimulationNodeDatum {
id: string;
group: number;
}
interface FDGLink extends d3.SimulationLinkDatum<FDGNode> {
value: number;
}
export type FDGData = {
nodes: Array<FDGNode>;
links: Array<FDGLink>;
};
let svgRef: SVGSVGElement | undefined;
export default function ForceDirectedGraph(props: {
children?: never;
data: FDGData;
}) {
const width = 928;
const height = 600;
const color = d3.scaleOrdinal(d3.schemeCategory10);
const links = props.data.links.map((d) => ({ ...d }));
const nodes = props.data.nodes.map((d) => ({ ...d }));
onMount(() => {
if (svgRef) {
const simulation = d3
.forceSimulation(nodes)
.force(
"link",
d3.forceLink<FDGNode, FDGLink>(links).id((d) => d.id)
)
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(width / 2, height / 2));
const link = d3
.select<SVGSVGElement, FDGNode>(svgRef)
.selectAll<SVGLineElement, FDGLink>("line")
.data(links);
const node = d3
.select<SVGSVGElement, FDGNode>(svgRef)
.selectAll<SVGCircleElement, FDGNode>("circle")
.data(nodes);
function ticked() {
link
// The source property comes from the SimulationLinkDatum interface, and by default,
// has a type of TypeOfNode | string | number, where TypeOfNode is FDGNode in this example
// so we need to make an as assertion. The x,y properties added by the SimulationNodeDatum
// interface are of type number | undefined, so a non-null assertion operator is used
.attr("x1", (d) => (d.source as FDGNode).x!)
.attr("y1", (d) => (d.source as FDGNode).y!)
.attr("x2", (d) => (d.target as FDGNode).x!)
.attr("y2", (d) => (d.target as FDGNode).y!);
node.attr("cx", (d) => d.x!).attr("cy", (d) => d.y!);
}
simulation.on("tick", ticked);
function dragstarted(
event: d3.D3DragEvent<SVGCircleElement, FDGNode, FDGNode>
) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(
event: d3.D3DragEvent<SVGCircleElement, FDGNode, FDGNode>
) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(
event: d3.D3DragEvent<SVGCircleElement, FDGNode, FDGNode>
) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
node.call(
d3
.drag<SVGCircleElement, FDGNode>()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended)
);
}
});
return (
<svg
ref={svgRef}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
style={{
"max-width": "100%",
height: "auto"
}}
>
<g stroke="#999" stroke-opacity={0.6}>
{links.map((link) => {
return <line stroke-width={Math.sqrt(link.value)} />;
})}
</g>
<g stroke={"#fff"} stroke-width={1.5}>
{nodes.map((node) => (
<circle r={5} fill={color(`${node.group}`)}>
<title>{node.id}</title>
</circle>
))}
</g>
</svg>
);
}
Once a simulation is rendered (in the particular example from the docs, using an svg container) the interaction inside of the FDG is handled by d3 only, so not much is required in terms of re-rendering from SolidJS. Since it's an svg container, it can only have svg elements as children.
However, it's entirely possible to be able to use any arbitrary component as a graph node, for that, one will have to not use an svg
container (unless you're willing to use path
elements to draw paths, then use an svg
container), so maybe a div
container? With that, the configuration for all the interactions has to be done by adding all the different handlers, a tad bit like the CM-Tech's D3 Graph for a SolidJS Debugger (in TypeScript) example that you've linked in your question. Check this question for more details.