d3graph.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. // Adopted from the following example, and patched to work with React v18 and D3 v7.6:
  2. // https://github.com/korydondzila/React-TypeScript-D3/tree/master/src
  3. import * as React from 'react';
  4. import * as d3 from "d3";
  5. import {useConst} from "../use_const";
  6. export type D3NodeData<NodeType> = {
  7. id: string,
  8. label: string,
  9. color: string,
  10. obj: NodeType;
  11. x?: number,
  12. y?: number,
  13. bold: boolean,
  14. };
  15. export type D3LinkData<LinkType> = {
  16. source: any, // initially string, but d3 replaces it by a d3Node<NodeType> (lol)
  17. target: any, // initially string, but d3 replaces it by a d3Node<NodeType> (lol)
  18. label: string,
  19. color: string,
  20. bidirectional?: boolean,
  21. obj: LinkType;
  22. };
  23. export type D3GraphData<NodeType,LinkType> = {
  24. nodes: D3NodeData<NodeType>[],
  25. links: D3LinkData<LinkType>[],
  26. };
  27. export const emptyGraph: D3GraphData<any,any> = {
  28. nodes: [],
  29. links: [],
  30. };
  31. export const defaultGraphForces = {charge: -200, center: 0.1, link: 1};
  32. class D3Link<LinkType> extends React.Component<{ link: D3LinkData<LinkType>, svgDefs: any }, {}> {
  33. ref: React.RefObject<SVGLineElement> = React.createRef<SVGLineElement>();
  34. refLabel: React.RefObject<SVGTextElement> = React.createRef<SVGTextElement>();
  35. componentDidMount() {
  36. d3.select(this.ref.current).data([this.props.link]);
  37. d3.select(this.refLabel.current).data([this.props.link]);
  38. }
  39. ticked() {
  40. d3.select(this.ref.current)
  41. .attr("x1", (d: any) => d.source.x)
  42. .attr("y1", (d: any) => d.source.y)
  43. .attr("x2", (d: any) => d.target.x)
  44. .attr("y2", (d: any) => d.target.y)
  45. ;
  46. d3.select(this.refLabel.current)
  47. .attr("x", (d: any) => (d.source.x + d.target.x)/2)
  48. .attr("y", (d: any) => (d.source.y + d.target.y)/2)
  49. ;
  50. }
  51. componentDidUpdate() {
  52. d3.select(this.ref.current).data([this.props.link]);
  53. d3.select(this.refLabel.current).data([this.props.link]);
  54. }
  55. render() {
  56. const textStyle = {
  57. fill: this.props.link.color,
  58. };
  59. const arrowStyle = {
  60. stroke: this.props.link.color,
  61. }
  62. return (
  63. <g>
  64. <line className="graphLink" ref={this.ref} style={arrowStyle} markerEnd={this.props.link.bidirectional ? "" : `url(#${this.props.svgDefs.arrowEndId})`}/>
  65. <text className="graphLinkLabel" ref={this.refLabel} style={textStyle}>{this.props.link.label}</text>
  66. </g>);
  67. }
  68. }
  69. class D3Links<LinkType> extends React.Component<{ links: D3LinkData<LinkType>[], svgDefs: any }, {}> {
  70. links: Array<D3Link<LinkType> | null> = [];
  71. ticked() {
  72. this.links.forEach(l => l ? l.ticked() : null);
  73. }
  74. render() {
  75. const nodeId = sourceOrTarget => sourceOrTarget.id ? sourceOrTarget.id : sourceOrTarget;
  76. const key = link => nodeId(link.source)+nodeId(link.target)+link.label;
  77. const links = this.props.links.map((link: D3LinkData<LinkType>, index: number) => {
  78. return <D3Link ref={link => this.links.push(link)} key={key(link)} link={link} svgDefs={this.props.svgDefs} />;
  79. });
  80. return (
  81. <g>
  82. {links}
  83. </g>
  84. );
  85. }
  86. }
  87. interface D3NodeProps<NodeType> {
  88. node: D3NodeData<NodeType>;
  89. simulation: any;
  90. mouseDownHandler: (event) => void;
  91. mouseUpHandler: (event) => void;
  92. }
  93. interface D3NodeState {
  94. dragging: boolean;
  95. }
  96. class D3Node<NodeType> extends React.Component<D3NodeProps<NodeType>, D3NodeState> {
  97. ref: React.RefObject<SVGCircleElement> = React.createRef<SVGCircleElement>();
  98. constructor(props) {
  99. super(props);
  100. this.state = {dragging: false};
  101. }
  102. componentDidMount() {
  103. d3.select(this.ref.current).data([this.props.node]);
  104. const onDragStart = (event, d: any) => {
  105. if (!event.active) {
  106. this.props.simulation.alphaTarget(0.1).restart();
  107. }
  108. d.fx = d.x;
  109. d.fy = d.y;
  110. this.setState({dragging: true});
  111. }
  112. const onDrag = (event, d: any) => {
  113. d.fx = event.x;
  114. d.fy = event.y;
  115. }
  116. const onDragEnd = (event, d: any) => {
  117. if (!event.active) {
  118. this.props.simulation.alphaTarget(0);
  119. }
  120. d.fx = null;
  121. d.fy = null;
  122. this.setState({dragging: false});
  123. }
  124. const dragBehavior = d3.drag()
  125. .on("start", onDragStart)
  126. .on("drag", onDrag)
  127. .on("end", onDragEnd);
  128. if (this.ref.current !== null) {
  129. // @ts-ignore: Doesn't work
  130. dragBehavior(d3.select(this.ref.current));
  131. }
  132. }
  133. componentDidUpdate() {
  134. d3.select(this.ref.current).data([this.props.node]);
  135. }
  136. ticked() {
  137. if (this.ref.current !== null)
  138. d3.select(this.ref.current)
  139. .attr("cx", (d: any) => d.x)
  140. .attr("cy", (d: any) => d.y)
  141. ;
  142. }
  143. render() {
  144. return (
  145. <circle
  146. ref={this.ref}
  147. className={"graphNode" + (this.state.dragging ? " dragging" : "")}
  148. onMouseDown={this.props.mouseDownHandler}
  149. onMouseUp={this.props.mouseUpHandler}
  150. r={5}
  151. fill={this.props.node.color}
  152. style={{strokeWidth:this.props.node.bold?"3px":"1px"}}
  153. >
  154. <title>{this.props.node.id}</title>
  155. </circle>
  156. );
  157. }
  158. }
  159. interface D3NodesProps<NodeType> {
  160. nodes: D3NodeData<NodeType>[],
  161. simulation: any,
  162. mouseDownHandler: (event, node) => void;
  163. mouseUpHandler: (event, node) => void;
  164. }
  165. class D3Nodes<NodeType> extends React.Component<D3NodesProps<NodeType>, {}> {
  166. ref: React.RefObject<SVGGElement> = React.createRef<SVGGElement>();
  167. nodes: Array<D3Node<NodeType> | null> = [];
  168. ticked() {
  169. this.nodes.forEach(n => n ? n.ticked() : null);
  170. }
  171. render() {
  172. this.nodes = [];
  173. const nodes = this.props.nodes.map((node: D3NodeData<NodeType>, index: number) => {
  174. return <D3Node
  175. key={node.id}
  176. ref={node => this.nodes.push(node)}
  177. mouseDownHandler={event => this.props.mouseDownHandler(event, node)}
  178. mouseUpHandler={event => this.props.mouseUpHandler(event, node)}
  179. node={node} simulation={this.props.simulation} />;
  180. });
  181. return (
  182. <g ref={this.ref}>
  183. {nodes}
  184. </g>
  185. );
  186. }
  187. }
  188. class D3Label<NodeType> extends React.Component<{ node: D3NodeData<NodeType> }, {}> {
  189. ref: React.RefObject<SVGTextElement>;
  190. constructor(props) {
  191. super(props);
  192. this.ref = React.createRef<SVGTextElement>();
  193. }
  194. componentDidMount() {
  195. d3.select(this.ref.current).data([this.props.node]);
  196. }
  197. ticked() {
  198. d3.select(this.ref.current)
  199. .attr("x", (d: any) => d.x + 10)
  200. .attr("y", (d: any) => d.y + 5);
  201. }
  202. componentDidUpdate() {
  203. d3.select(this.ref.current).data([this.props.node]);
  204. }
  205. render() {
  206. return <text className="graphNodeLabel" ref={this.ref} fontWeight={this.props.node.bold?"bold":"normal"}>
  207. {this.props.node.label}
  208. </text>;
  209. }
  210. }
  211. class D3Labels<NodeType> extends React.Component<{ nodes: D3NodeData<NodeType>[] }, {}> {
  212. labels: Array<D3Label<NodeType> | null> = [];
  213. ticked() {
  214. this.labels.forEach(l => l ? l.ticked() : null);
  215. }
  216. render() {
  217. const labels = this.props.nodes.map((node: D3NodeData<NodeType>, index: number) => {
  218. return <D3Label ref={label => this.labels.push(label)} key={node.id} node={node} />;
  219. });
  220. return (
  221. <g>
  222. {labels}
  223. </g>
  224. );
  225. }
  226. }
  227. export interface D3Forces {
  228. charge: number;
  229. center: number;
  230. link: number;
  231. }
  232. interface Props<NodeType,LinkType> {
  233. graph: D3GraphData<NodeType,LinkType>;
  234. forces: D3Forces;
  235. mouseDownHandler?: (e: React.SyntheticEvent, svgCoords: {x: number, y: number}, node?: D3NodeData<NodeType>) => void;
  236. mouseUpHandler?: (e: React.SyntheticEvent, svgCoords: {x: number, y: number}, node?: D3NodeData<NodeType>) => void;
  237. }
  238. export function D3Graph<NodeType,LinkType>(props: Props<NodeType,LinkType>) {
  239. const refSVG = React.useRef<SVGSVGElement>(null);
  240. const refNodes = React.useRef<D3Nodes<NodeType>>(null);
  241. const refLabels = React.useRef<D3Labels<NodeType>>(null);
  242. const refLinks = React.useRef<D3Links<LinkType>>(null);
  243. const [zoom, setZoom] = React.useState<number>(1.5);
  244. const simulation = useConst<any>(() => {
  245. const s = d3.forceSimulation()
  246. .force("link", d3.forceLink().id((d: any) => d.id).strength(props.forces.link))
  247. .force("charge", d3.forceManyBody().strength(props.forces.charge))
  248. .force("x", d3.forceX().strength(props.forces.center))
  249. .force("y", d3.forceY().strength(props.forces.center))
  250. s.on("tick", () => ticked());
  251. return s;
  252. });
  253. const update = () => {
  254. simulation.nodes(props.graph.nodes);
  255. simulation.force("link").links(props.graph.links);
  256. }
  257. const ticked = () => {
  258. refLinks.current?.ticked();
  259. refNodes.current?.ticked();
  260. refLabels.current?.ticked();
  261. }
  262. React.useEffect(() => {
  263. refSVG.current?.addEventListener('wheel', e => e.preventDefault());
  264. return () => {
  265. refSVG.current?.removeEventListener('wheel', e => e.preventDefault());
  266. };
  267. }, []);
  268. React.useEffect(() => {
  269. update();
  270. // "warmup" simulation a bit:
  271. simulation.alpha(0.1).restart().tick();
  272. ticked();
  273. }, [props.graph]);
  274. const clientToSvgCoords = React.useCallback((event, callback) => {
  275. // Translate mouse event to SVG coordinates:
  276. if (refSVG.current !== null) {
  277. const ctm = refSVG.current?.getScreenCTM();
  278. if (ctm !== undefined && ctm !== null) {
  279. const pt = refSVG.current!.createSVGPoint();
  280. pt.x = event.clientX;
  281. pt.y = event.clientY;
  282. callback(event, pt.matrixTransform(ctm.inverse()));
  283. }
  284. }
  285. }, []);
  286. const svgDefs = {
  287. arrowStartId: React.useId(),
  288. arrowEndId: React.useId(),
  289. };
  290. return <svg
  291. className="canvas"
  292. ref={refSVG}
  293. viewBox={`${-200/zoom} ${-200/zoom} ${400/zoom} ${400/zoom}`}
  294. onMouseDown={e => props.mouseDownHandler ? clientToSvgCoords(e, props.mouseDownHandler) : null}
  295. onMouseUp={e => props.mouseUpHandler ? clientToSvgCoords(e, props.mouseUpHandler) : null}
  296. onContextMenu={e => e.preventDefault()}
  297. onWheel={e => {
  298. setZoom(prevZoom => prevZoom - prevZoom * Math.min(Math.max(e.deltaY*0.5,-150), 150) / 200);
  299. }}
  300. >
  301. <defs>
  302. <marker id={svgDefs.arrowStartId} markerWidth="10" markerHeight="10" refX="14" refY="3" orient="auto-start-reverse" markerUnits="strokeWidth">
  303. <path d="M0,0 L0,6 L9,3 z" className="arrowHead" />
  304. </marker>
  305. <marker id={svgDefs.arrowEndId} markerWidth="10" markerHeight="10" refX="14" refY="3" orient="auto" markerUnits="strokeWidth">
  306. <path d="M0,0 L0,6 L9,3 z" className="arrowHead" />
  307. </marker>
  308. </defs>
  309. <D3Links ref={refLinks} links={props.graph.links} svgDefs={svgDefs} />
  310. <D3Labels ref={refLabels} nodes={props.graph.nodes} />
  311. <D3Nodes ref={refNodes} nodes={props.graph.nodes} simulation={simulation}
  312. mouseDownHandler={(e, node) => clientToSvgCoords(e, (e,coords) => props.mouseDownHandler?.(e,coords,node))}
  313. mouseUpHandler={(e, node) => clientToSvgCoords(e, (e,coords) => props.mouseUpHandler?.(e,coords,node))}
  314. />
  315. </svg>;
  316. }