123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- // Adopted from the following example, and patched to work with React v18 and D3 v7.6:
- // https://github.com/korydondzila/React-TypeScript-D3/tree/master/src
- import * as React from 'react';
- import * as d3 from "d3";
- import {useConst} from "../use_const";
- export type D3NodeData<NodeType> = {
- id: string,
- label: string,
- color: string,
- obj: NodeType;
- x?: number,
- y?: number,
- bold: boolean,
- };
- export type D3LinkData<LinkType> = {
- source: any, // initially string, but d3 replaces it by a d3Node<NodeType> (lol)
- target: any, // initially string, but d3 replaces it by a d3Node<NodeType> (lol)
- label: string,
- color: string,
- bidirectional?: boolean,
- obj: LinkType;
- };
- export type D3GraphData<NodeType,LinkType> = {
- nodes: D3NodeData<NodeType>[],
- links: D3LinkData<LinkType>[],
- };
- export const emptyGraph: D3GraphData<any,any> = {
- nodes: [],
- links: [],
- };
- export const defaultGraphForces = {charge: -200, center: 0.1, link: 1};
- class D3Link<LinkType> extends React.Component<{ link: D3LinkData<LinkType>, svgDefs: any }, {}> {
- ref: React.RefObject<SVGLineElement> = React.createRef<SVGLineElement>();
- refLabel: React.RefObject<SVGTextElement> = React.createRef<SVGTextElement>();
- componentDidMount() {
- d3.select(this.ref.current).data([this.props.link]);
- d3.select(this.refLabel.current).data([this.props.link]);
- }
- ticked() {
- d3.select(this.ref.current)
- .attr("x1", (d: any) => d.source.x)
- .attr("y1", (d: any) => d.source.y)
- .attr("x2", (d: any) => d.target.x)
- .attr("y2", (d: any) => d.target.y)
- ;
- d3.select(this.refLabel.current)
- .attr("x", (d: any) => (d.source.x + d.target.x)/2)
- .attr("y", (d: any) => (d.source.y + d.target.y)/2)
- ;
- }
- componentDidUpdate() {
- d3.select(this.ref.current).data([this.props.link]);
- d3.select(this.refLabel.current).data([this.props.link]);
- }
- render() {
- const textStyle = {
- fill: this.props.link.color,
- };
- const arrowStyle = {
- stroke: this.props.link.color,
- }
- return (
- <g>
- <line className="graphLink" ref={this.ref} style={arrowStyle} markerEnd={this.props.link.bidirectional ? "" : `url(#${this.props.svgDefs.arrowEndId})`}/>
- <text className="graphLinkLabel" ref={this.refLabel} style={textStyle}>{this.props.link.label}</text>
- </g>);
- }
- }
- class D3Links<LinkType> extends React.Component<{ links: D3LinkData<LinkType>[], svgDefs: any }, {}> {
- links: Array<D3Link<LinkType> | null> = [];
- ticked() {
- this.links.forEach(l => l ? l.ticked() : null);
- }
- render() {
- const nodeId = sourceOrTarget => sourceOrTarget.id ? sourceOrTarget.id : sourceOrTarget;
- const key = link => nodeId(link.source)+nodeId(link.target)+link.label;
- const links = this.props.links.map((link: D3LinkData<LinkType>, index: number) => {
- return <D3Link ref={link => this.links.push(link)} key={key(link)} link={link} svgDefs={this.props.svgDefs} />;
- });
- return (
- <g>
- {links}
- </g>
- );
- }
- }
- interface D3NodeProps<NodeType> {
- node: D3NodeData<NodeType>;
- simulation: any;
- mouseDownHandler: (event) => void;
- mouseUpHandler: (event) => void;
- }
- interface D3NodeState {
- dragging: boolean;
- }
- class D3Node<NodeType> extends React.Component<D3NodeProps<NodeType>, D3NodeState> {
- ref: React.RefObject<SVGCircleElement> = React.createRef<SVGCircleElement>();
- constructor(props) {
- super(props);
- this.state = {dragging: false};
- }
- componentDidMount() {
- d3.select(this.ref.current).data([this.props.node]);
- const onDragStart = (event, d: any) => {
- if (!event.active) {
- this.props.simulation.alphaTarget(0.1).restart();
- }
- d.fx = d.x;
- d.fy = d.y;
- this.setState({dragging: true});
- }
- const onDrag = (event, d: any) => {
- d.fx = event.x;
- d.fy = event.y;
- }
- const onDragEnd = (event, d: any) => {
- if (!event.active) {
- this.props.simulation.alphaTarget(0);
- }
- d.fx = null;
- d.fy = null;
- this.setState({dragging: false});
- }
- const dragBehavior = d3.drag()
- .on("start", onDragStart)
- .on("drag", onDrag)
- .on("end", onDragEnd);
- if (this.ref.current !== null) {
- // @ts-ignore: Doesn't work
- dragBehavior(d3.select(this.ref.current));
- }
- }
- componentDidUpdate() {
- d3.select(this.ref.current).data([this.props.node]);
- }
- ticked() {
- if (this.ref.current !== null)
- d3.select(this.ref.current)
- .attr("cx", (d: any) => d.x)
- .attr("cy", (d: any) => d.y)
- ;
- }
- render() {
- return (
- <circle
- ref={this.ref}
- className={"graphNode" + (this.state.dragging ? " dragging" : "")}
- onMouseDown={this.props.mouseDownHandler}
- onMouseUp={this.props.mouseUpHandler}
- r={5}
- fill={this.props.node.color}
- style={{strokeWidth:this.props.node.bold?"3px":"1px"}}
- >
- <title>{this.props.node.id}</title>
- </circle>
- );
- }
- }
- interface D3NodesProps<NodeType> {
- nodes: D3NodeData<NodeType>[],
- simulation: any,
- mouseDownHandler: (event, node) => void;
- mouseUpHandler: (event, node) => void;
- }
- class D3Nodes<NodeType> extends React.Component<D3NodesProps<NodeType>, {}> {
- ref: React.RefObject<SVGGElement> = React.createRef<SVGGElement>();
- nodes: Array<D3Node<NodeType> | null> = [];
- ticked() {
- this.nodes.forEach(n => n ? n.ticked() : null);
- }
- render() {
- this.nodes = [];
- const nodes = this.props.nodes.map((node: D3NodeData<NodeType>, index: number) => {
- return <D3Node
- key={node.id}
- ref={node => this.nodes.push(node)}
- mouseDownHandler={event => this.props.mouseDownHandler(event, node)}
- mouseUpHandler={event => this.props.mouseUpHandler(event, node)}
- node={node} simulation={this.props.simulation} />;
- });
- return (
- <g ref={this.ref}>
- {nodes}
- </g>
- );
- }
- }
- class D3Label<NodeType> extends React.Component<{ node: D3NodeData<NodeType> }, {}> {
- ref: React.RefObject<SVGTextElement>;
- constructor(props) {
- super(props);
- this.ref = React.createRef<SVGTextElement>();
- }
- componentDidMount() {
- d3.select(this.ref.current).data([this.props.node]);
- }
- ticked() {
- d3.select(this.ref.current)
- .attr("x", (d: any) => d.x + 10)
- .attr("y", (d: any) => d.y + 5);
- }
- componentDidUpdate() {
- d3.select(this.ref.current).data([this.props.node]);
- }
- render() {
- return <text className="graphNodeLabel" ref={this.ref} fontWeight={this.props.node.bold?"bold":"normal"}>
- {this.props.node.label}
- </text>;
- }
- }
- class D3Labels<NodeType> extends React.Component<{ nodes: D3NodeData<NodeType>[] }, {}> {
- labels: Array<D3Label<NodeType> | null> = [];
- ticked() {
- this.labels.forEach(l => l ? l.ticked() : null);
- }
- render() {
- const labels = this.props.nodes.map((node: D3NodeData<NodeType>, index: number) => {
- return <D3Label ref={label => this.labels.push(label)} key={node.id} node={node} />;
- });
- return (
- <g>
- {labels}
- </g>
- );
- }
- }
- export interface D3Forces {
- charge: number;
- center: number;
- link: number;
- }
- interface Props<NodeType,LinkType> {
- graph: D3GraphData<NodeType,LinkType>;
- forces: D3Forces;
- mouseDownHandler?: (e: React.SyntheticEvent, svgCoords: {x: number, y: number}, node?: D3NodeData<NodeType>) => void;
- mouseUpHandler?: (e: React.SyntheticEvent, svgCoords: {x: number, y: number}, node?: D3NodeData<NodeType>) => void;
- }
- export function D3Graph<NodeType,LinkType>(props: Props<NodeType,LinkType>) {
- const refSVG = React.useRef<SVGSVGElement>(null);
- const refNodes = React.useRef<D3Nodes<NodeType>>(null);
- const refLabels = React.useRef<D3Labels<NodeType>>(null);
- const refLinks = React.useRef<D3Links<LinkType>>(null);
- const [zoom, setZoom] = React.useState<number>(1.5);
- const simulation = useConst<any>(() => {
- const s = d3.forceSimulation()
- .force("link", d3.forceLink().id((d: any) => d.id).strength(props.forces.link))
- .force("charge", d3.forceManyBody().strength(props.forces.charge))
- .force("x", d3.forceX().strength(props.forces.center))
- .force("y", d3.forceY().strength(props.forces.center))
- s.on("tick", () => ticked());
- return s;
- });
- const update = () => {
- simulation.nodes(props.graph.nodes);
- simulation.force("link").links(props.graph.links);
- }
- const ticked = () => {
- refLinks.current?.ticked();
- refNodes.current?.ticked();
- refLabels.current?.ticked();
- }
- React.useEffect(() => {
- refSVG.current?.addEventListener('wheel', e => e.preventDefault());
- return () => {
- refSVG.current?.removeEventListener('wheel', e => e.preventDefault());
- };
- }, []);
- React.useEffect(() => {
- update();
- // "warmup" simulation a bit:
- simulation.alpha(0.1).restart().tick();
- ticked();
- }, [props.graph]);
- const clientToSvgCoords = React.useCallback((event, callback) => {
- // Translate mouse event to SVG coordinates:
- if (refSVG.current !== null) {
- const ctm = refSVG.current?.getScreenCTM();
- if (ctm !== undefined && ctm !== null) {
- const pt = refSVG.current!.createSVGPoint();
- pt.x = event.clientX;
- pt.y = event.clientY;
- callback(event, pt.matrixTransform(ctm.inverse()));
- }
- }
- }, []);
- const svgDefs = {
- arrowStartId: React.useId(),
- arrowEndId: React.useId(),
- };
- return <svg
- className="canvas"
- ref={refSVG}
- viewBox={`${-200/zoom} ${-200/zoom} ${400/zoom} ${400/zoom}`}
- onMouseDown={e => props.mouseDownHandler ? clientToSvgCoords(e, props.mouseDownHandler) : null}
- onMouseUp={e => props.mouseUpHandler ? clientToSvgCoords(e, props.mouseUpHandler) : null}
- onContextMenu={e => e.preventDefault()}
- onWheel={e => {
- setZoom(prevZoom => prevZoom - prevZoom * Math.min(Math.max(e.deltaY*0.5,-150), 150) / 200);
- }}
- >
- <defs>
- <marker id={svgDefs.arrowStartId} markerWidth="10" markerHeight="10" refX="14" refY="3" orient="auto-start-reverse" markerUnits="strokeWidth">
- <path d="M0,0 L0,6 L9,3 z" className="arrowHead" />
- </marker>
- <marker id={svgDefs.arrowEndId} markerWidth="10" markerHeight="10" refX="14" refY="3" orient="auto" markerUnits="strokeWidth">
- <path d="M0,0 L0,6 L9,3 z" className="arrowHead" />
- </marker>
- </defs>
- <D3Links ref={refLinks} links={props.graph.links} svgDefs={svgDefs} />
- <D3Labels ref={refLabels} nodes={props.graph.nodes} />
- <D3Nodes ref={refNodes} nodes={props.graph.nodes} simulation={simulation}
- mouseDownHandler={(e, node) => clientToSvgCoords(e, (e,coords) => props.mouseDownHandler?.(e,coords,node))}
- mouseUpHandler={(e, node) => clientToSvgCoords(e, (e,coords) => props.mouseUpHandler?.(e,coords,node))}
- />
- </svg>;
- }
|