浏览代码

Frontend: fix some issues with d3 and React

Joeri Exelmans 3 年之前
父节点
当前提交
97a70c5e14
共有 6 个文件被更改,包括 218 次插入87 次删除
  1. 0 3
      dist/index.html
  2. 1 0
      package.json
  3. 17 0
      pnpm-lock.yaml
  4. 18 12
      src/frontend/app.tsx
  5. 119 72
      src/frontend/graph_view.tsx
  6. 63 0
      src/frontend/interactive_graph.tsx

+ 0 - 3
dist/index.html

@@ -13,9 +13,6 @@
          user-select: none;
       }
 
-      svg text{
-      }
-
       .container {
         background-color: #333;
       }

+ 1 - 0
package.json

@@ -12,6 +12,7 @@
 		"@emotion/react": "^11.10.0",
 		"@mantine/core": "^5.2.3",
 		"@mantine/hooks": "^5.2.3",
+		"@tabler/icons": "^1.87.0",
 		"@types/d3": "^7.4.0",
 		"@types/d3-drag": "^3.0.1",
 		"@types/d3-force": "^3.0.3",

+ 17 - 0
pnpm-lock.yaml

@@ -4,6 +4,7 @@ specifiers:
   '@emotion/react': ^11.10.0
   '@mantine/core': ^5.2.3
   '@mantine/hooks': ^5.2.3
+  '@tabler/icons': ^1.87.0
   '@types/d3': ^7.4.0
   '@types/d3-drag': ^3.0.1
   '@types/d3-force': ^3.0.3
@@ -33,6 +34,7 @@ dependencies:
   '@emotion/react': 11.10.0_ug65io7jkbhmo4fihdmbrh3ina
   '@mantine/core': 5.2.3_uz3hmck5bf5bg7hhupn5uzbbra
   '@mantine/hooks': 5.2.3_react@18.2.0
+  '@tabler/icons': 1.87.0_biqbaboplfbrettd7655fr4n2y
   '@types/d3': 7.4.0
   '@types/d3-drag': 3.0.1
   '@types/d3-force': 3.0.3
@@ -634,6 +636,21 @@ packages:
       react: 18.2.0
     dev: false
 
+  /@tabler/icons/1.87.0_biqbaboplfbrettd7655fr4n2y:
+    resolution: {integrity: sha512-9JSqBo0rh4/mkaf2LH7c9zzYOzI/PKTcEvJNqRHtf2Z5iomp7wPDXfC0oIufhB4NyEWEYN4K5tvu3dFVMXB9Iw==}
+    peerDependencies:
+      react: ^16.x || 17.x || 18.x
+      react-dom: ^16.x || 17.x || 18.x
+    peerDependenciesMeta:
+      react:
+        optional: true
+      react-dom:
+        optional: true
+    dependencies:
+      react: 18.2.0
+      react-dom: 18.2.0_react@18.2.0
+    dev: false
+
   /@tsconfig/node10/1.0.9:
     resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
     dev: false

+ 18 - 12
src/frontend/app.tsx

@@ -1,8 +1,10 @@
 import * as React from "react";
 import * as _ from "lodash";
 
-import {Grid, AppShell, Navbar, Header, Text, Title, MantineProvider} from "@mantine/core";
-import {GraphView} from "./graph_view"
+import {Grid, AppShell, Navbar, Header, Text, Title, MantineProvider, Tooltip, Group} from "@mantine/core";
+import {IconInfoCircle} from "@tabler/icons";
+import {Graph} from "./graph"
+import {InteractiveGraph} from "./interactive_graph";
 
 const graph = {
   "nodes": [
@@ -394,23 +396,27 @@ export class App extends React.Component {
               main: { backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0] },
             })}
           >
-        <Grid grow>
-          <Grid.Col span={1}>
-            <Text>Graph state</Text>
-            <GraphView width={600} height={600} graph={_.cloneDeep(graph)} onClick={console.log} />
+        <Grid grow columns={4}>
+          <Grid.Col span={2}>
+            <Group>
+              <Title order={4}>Graph state</Title>
+              <Tooltip label="Double click to create node">
+                <Text><IconInfoCircle/></Text>
+              </Tooltip>
+            </Group>
+            <InteractiveGraph graph={_.cloneDeep(graph2)} />
           </Grid.Col>
           <Grid.Col span={1}>
-            <Text>Version</Text>
-            <GraphView width={600} height={600} graph={_.cloneDeep(emptyGraph)} onClick={console.log} />
+            <Title order={4}>Version</Title>
+            <Graph graph={_.cloneDeep(emptyGraph)} mouseDownHandler={()=>{}} mouseUpHandler={()=>{}} />
           </Grid.Col>
           <Grid.Col span={1}>
-            <Text>Delta</Text>
-            <GraphView width={600} height={600} graph={_.cloneDeep(emptyGraph)} onClick={console.log} />
+            <Title order={4}>Delta</Title>
+            <Graph graph={_.cloneDeep(emptyGraph)} mouseDownHandler={()=>{}} mouseUpHandler={()=>{}} />
           </Grid.Col>
         </Grid>
-    </AppShell>
+      </AppShell>
     </MantineProvider>
-
     );
   }
 }

+ 119 - 72
src/frontend/graph_view.tsx

@@ -4,15 +4,15 @@
 import * as React from 'react';
 import * as d3 from "d3";
 
-namespace d3Types {
+export namespace d3Types {
   export type d3Node = {
     id: string,
     group: number
   };
 
   export type d3Link = {
-    source: string,
-    target: string,
+    source: any, // initially string, but d3 replaces it by an object (lol)
+    target: any, // initially string, but d3 replaces it by an object (lol)
     value: number
   };
 
@@ -36,14 +36,16 @@ class Link extends React.Component<{ link: d3Types.d3Link }, {}> {
   }
 
   render() {
-    return <line className="link" ref={this.ref} marker-end="url(#arrow2)"/>;
+    return <line className="link" ref={this.ref} markerEnd="url(#arrow2)"/>;
   }
 }
 
 class Links extends React.Component<{ links: d3Types.d3Link[] }, {}> {
   render() {
+    const nodeId = sourceOrTarget => sourceOrTarget.id ? sourceOrTarget.id : sourceOrTarget;
+    const key = link => 's'+nodeId(link.source)+'t'+nodeId(link.target);
     const links = this.props.links.map((link: d3Types.d3Link, index: number) => {
-      return <Link key={index} link={link} />;
+      return <Link key={key(link)} link={link} />;
     });
 
     return (
@@ -54,8 +56,16 @@ class Links extends React.Component<{ links: d3Types.d3Link[] }, {}> {
   }
 }
 
+interface NodeProps {
+  node: d3Types.d3Node;
+  color: string;
+  simulation: any;
+  mouseDownHandler: (event) => void;
+  mouseUpHandler: (event) => void;
+}
 
-class Node extends React.Component<{ node: d3Types.d3Node, color: string }, {}> {
+
+class Node extends React.Component<NodeProps, {}> {
   ref: React.RefObject<SVGCircleElement>;
 
   constructor(props) {
@@ -65,67 +75,77 @@ class Node extends React.Component<{ node: d3Types.d3Node, color: string }, {}>
 
   componentDidMount() {
     d3.select(this.ref.current).data([this.props.node]);
+
+    const onDragStart = (event, d: any) => {
+      if (!event.active) {
+        this.props.simulation.alphaTarget(0.3).restart();
+      }
+      d.fx = d.x;
+      d.fy = d.y;
+    }
+    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;
+    }
+    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));
+    }
   }
 
   render() {
+    console.log("Node.render");
+
     return (
-      <circle className="node" r={5} fill={this.props.color}
-        ref={this.ref}>
+      <circle
+        ref={this.ref}
+        className="node"
+        onMouseDown={this.props.mouseDownHandler}
+        onMouseUp={this.props.mouseUpHandler}
+        r={5}
+        fill={this.props.color}
+      >
         <title>{this.props.node.id}</title>
       </circle>
     );
   }
 }
 
-class Nodes extends React.Component<{ nodes: d3Types.d3Node[], simulation: any }, {}> {
+interface NodesProps {
+  nodes: d3Types.d3Node[],
+  simulation: any,
+  mouseDownHandler: (event, node) => void;
+  mouseUpHandler: (event, node) => void;
+}
+
+class Nodes extends React.Component<NodesProps, {}> {
   ref: React.RefObject<SVGGElement>;
-  // state: {nodes: d3Types.d3Node[]};
 
   constructor(props) {
     super(props);
     console.log("construct Nodes")
     this.ref = React.createRef<SVGGElement>();
-    // this.state = {nodes: props.nodes};
-  }
-
-  componentDidMount() {
-  }
-
-  componentDidUpdate() {
-    const simulation = this.props.simulation;
-    d3.select(this.ref.current).selectAll(".node")
-    // @ts-ignore: Doesn't work
-      .call(d3.drag()
-        .on("start", onDragStart)
-        .on("drag", onDrag)
-        .on("end", onDragEnd));
-
-    function onDragStart(event, d: any) {
-      if (!event.active) {
-        simulation.alphaTarget(0.3).restart();
-      }
-      d.fx = d.x;
-      d.fy = d.y;
-    }
-
-    function onDrag(event, d: any) {
-      d.fx = event.x;
-      d.fy = event.y;
-    }
-
-    function onDragEnd(event, d: any) {
-      if (!event.active) {
-        simulation.alphaTarget(0);
-      }
-      d.fx = null;
-      d.fy = null;
-    }    
   }
 
   render() {
     const color = d3.scaleOrdinal(d3.schemeCategory10);
     const nodes = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
-      return <Node key={index} node={node} color={color(node.group.toString())} />;
+      return <Node
+          key={node.id}
+          mouseDownHandler={event => this.props.mouseDownHandler(event, node)}
+          mouseUpHandler={event => this.props.mouseUpHandler(event, node)}
+          node={node} color={color(node.group.toString())} simulation={this.props.simulation} />;
     });
 
     return (
@@ -158,7 +178,7 @@ class Label extends React.Component<{ node: d3Types.d3Node }, {}> {
 class Labels extends React.Component<{ nodes: d3Types.d3Node[] }, {}> {
   render() {
     const labels = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
-      return <Label key={index} node={node} />;
+      return <Label key={node.id} node={node} />;
     });
 
     return (
@@ -170,30 +190,27 @@ class Labels extends React.Component<{ nodes: d3Types.d3Node[] }, {}> {
 }
 
 
-interface Props {
-  width: number;
-  height: number;
+export interface GraphProps {
   graph: d3Types.d3Graph;
-  onClick: (SyntheticBaseEvent) => void;
+  // clickHandler?: (event:React.SyntheticEvent, svgCoords: {svgX: number, svgY: number}) => void;
+  mouseDownHandler: (e: React.SyntheticEvent, svgCoords: {x: number, y: number}, node?: d3Types.d3Node) => void;
+  mouseUpHandler: (e: React.SyntheticEvent, svgCoords: {x: number, y: number}, node?: d3Types.d3Node) => void;
 }
 
-interface GraphViewState {
+interface GraphState {
   nodes: d3Types.d3Node[],
   links: d3Types.d3Link[],
 }
 
-export class GraphView extends React.Component<Props, {}> {
+export class Graph extends React.Component<GraphProps, {}> {
   simulation: any;
   refSVG: React.RefObject<SVGSVGElement>;
-  state: GraphViewState;
+  state: GraphState;
 
   link: any;
   node: any;
   label: any;
 
-  nextId: number = 0;
-
-
   constructor(props) {
     super(props);
     this.state = {
@@ -212,14 +229,39 @@ export class GraphView extends React.Component<Props, {}> {
     // this.simulation.force("link").links(this.props.graph.links);
   }
 
-  addNode = (id, x,y) => {
-    this.setState((prevState: GraphViewState) => ({
-      nodes: [...prevState.nodes, { id, group: 8, x, y }],
+  createNode = (id, svgX,svgY) => {
+    this.setState((prevState: GraphState) => ({
+      nodes: [...prevState.nodes, { id, group: 8, x: svgX, y: svgY }],
     }));
   }
 
-  handleClick = event => {
-    if (event.detail === 2) { // Double-click
+  deleteNode = (id) => {
+    this.setState((prevState: GraphState) => {
+      console.log("DELETING NODE", id)
+      console.log("PREVSTATE:",prevState);
+      const newLinks = prevState.links.filter(l => l.source.id !== id && l.target.id !== id);
+      console.log("NEWLINKS:",newLinks)
+      return ({
+      nodes: prevState.nodes.filter(n => n.id !== id),
+      links: newLinks,
+    })})
+  }
+
+  createLink = (source, target) => {
+    this.setState((prevState: GraphState) => ({
+      links: [...prevState.links, {source, target, value: 1}],
+    }));
+  }
+
+  render() {
+    console.log("Graph.render")
+    const { graph } = this.props;
+
+    const width = 600;
+    const height = 600;
+
+    const clientToSvgCoords = (event, callback) => {
+      // console.log(event);
       // Translate mouse event to SVG coordinates:
       if (this.refSVG.current !== null) {
         const ctm = this.refSVG.current.getScreenCTM();
@@ -227,30 +269,34 @@ export class GraphView extends React.Component<Props, {}> {
           const pt = this.refSVG.current.createSVGPoint();
           pt.x = event.clientX;
           pt.y = event.clientY;
-          const {x, y} = pt.matrixTransform(ctm.inverse());
-          this.addNode(this.nextId++, x, y);
+          callback(event, pt.matrixTransform(ctm.inverse()));
         }
-      }      
+      }
     }
-  }
-
-  render() {
-    const { width, height, graph } = this.props;
 
     return (
       <svg className="container"
         ref={this.refSVG}
-        width={"100%"} height={"100%"}
+        style={{width: "100%", height}}
         viewBox={`${-width/2} ${-height/2} ${width} ${height}`}
-        onClick={this.handleClick}>
+        onMouseDown={e => clientToSvgCoords(e, this.props.mouseDownHandler)}
+        onMouseUp={e => clientToSvgCoords(e, this.props.mouseUpHandler)}
+        onContextMenu={e => e.preventDefault()}
+      >
+
         <defs>
           <marker id="arrow2" markerWidth="10" markerHeight="10" refX="14" refY="3" orient="auto" markerUnits="strokeWidth">
             <path d="M0,0 L0,6 L9,3 z" fill="#fff" />
           </marker>
         </defs>
+
         <Links links={this.state.links} />
         <Labels nodes={this.state.nodes} />
-        <Nodes nodes={this.state.nodes} simulation={this.simulation} />
+        <Nodes nodes={this.state.nodes} simulation={this.simulation}
+          mouseDownHandler={(e, node) => clientToSvgCoords(e, (e,coords) => this.props.mouseDownHandler(e,coords,node))}
+          mouseUpHandler={(e, node) => clientToSvgCoords(e, (e,coords) => this.props.mouseUpHandler(e,coords,node))}
+        />
+
       </svg>
     );
   }
@@ -301,6 +347,7 @@ export class GraphView extends React.Component<Props, {}> {
   }
 
   componentDidUpdate() {
+    console.log("UDPATED STATE:",this.state)
     this.update();
     this.simulation.alpha(1).restart().tick();
     this.ticked();

+ 63 - 0
src/frontend/interactive_graph.tsx

@@ -0,0 +1,63 @@
+import * as React from 'react';
+import {d3Types, Graph, GraphProps} from "./graph";
+
+interface InteractiveGraphProps {
+  graph: d3Types.d3Graph;
+}
+
+export class InteractiveGraph extends React.Component<InteractiveGraphProps, {}> {
+
+  graphRef: React.RefObject<Graph>;
+  mouseDownNode: d3Types.d3Node | null;
+
+  nextId: number = 0;
+
+  constructor(props) {
+    super(props);
+    this.graphRef = React.createRef<Graph>();
+    this.mouseDownNode = null;
+  }
+
+  mouseDownHandler = (event, {x,y}, node) => {
+    event.stopPropagation();
+    console.log("DOWN:", node, event);
+    if (node) {
+      this.mouseDownNode = node;
+    }
+  }
+
+  mouseUpHandler = (event, {x,y}, node) => {
+    event.stopPropagation();
+    console.log("UP:", node, event);
+    if (this.graphRef.current !== null) {
+      if (event.button === 2)  { // right mouse button
+        if (node && this.mouseDownNode) {
+          this.graphRef.current.createLink(this.mouseDownNode, node);
+        }
+        else  { // right mouse button
+          this.graphRef.current.createNode(`ID-${this.nextId++}`, x, y);
+        }
+      }
+      else if (event.button === 1) { // middle mouse button
+        if (node) {
+          console.log("INVOKE ON CHILD")
+          this.graphRef.current.deleteNode(node.id);
+        }
+      }
+    }
+    this.mouseDownNode = null;
+  }
+
+  render() {
+    return (
+      <Graph
+        ref={this.graphRef}
+        graph={this.props.graph}
+        mouseDownHandler={this.mouseDownHandler}
+        mouseUpHandler={this.mouseUpHandler}
+      />
+    );
+  }
+
+
+}