React + Relationship Chart => Startup

Displays the complex structures in the graph! 

D3 (or D3.js) is a JavaScript library for visualizing data using SVG, Canvas, and HTML. 

D3 combines powerful visualization and interaction techniques with a data-driven approach to DOM manipulation, giving you the full capabilities of modern browsers and the freedom to design the completed visual interface for your data.

ENV

    # Create React Project 
    npx create-react-app <app-name> --template typescript 

    # Install React
    cd <app-name>
    npm i react
    npm i ts-node typescript @types/node sass

D3

Install D3

npm i d3 @types/d3 d3-force @types/d3-force recoil @types/recoil

SimpleForceGraph

Update App.tsx
// import './App.css'
import RelationGraph from './components/rg'
export default function App() {
  return (
    <main>
      <p>Hi</p>
      <div><RelationGraph /></div>
    </main>
  )
}
Create rg.tsx
import NW from "./__core__/NetworksWidget"
import { RecoilRoot } from "recoil";
import { Types } from './__core__/types';
import InputBox from './inputbox';
import React from 'react';

export const demoData: Types.Map = {
  nodes: [
    {
      name: "#1",
      radiusSize: 20,
      fillColor: "#777",
      group: 1
    },
    {
      name: "#2",
      radiusSize: 20,
      fillColor: "#777",
      group: 2
    },
    {
      name: "#3",
      radiusSize: 10,
      fillColor: "#777",
      group: 2
    },
    {
      name: "#4",
      radiusSize: 20,
      fillColor: "#777",
      group: 3
    },
    {
      name: "#5",
      radiusSize: 25,
      fillColor: "#777",
      group: 3
    },

  ],
  links: [
    {
      "source": "#1",
      "target": "#2",
      "value": 3
    },
    {
      "source": "#2",
      "target": "#3",
      "value": 8
    },
    {
      "source": "#4",
      "target": "#5",
      "value": 1
    },
  ]

}


class RelationGraph extends React.Component<any, { wordsData: string, wordsDataMap: Types.Map }> {
  callbacks = {
    getWordColor: (word: any) => (word.value > 50 ? "orange" : "purple"),
    getWordTooltip: (word: any) =>
      `The word "${word.text}" appears ${word.value} times.`
  };
  constructor(props: any) {
    super(props)
    const data: string = props.wordsData ? props.wordsData : JSON.stringify(demoData)
    this.state = {
      wordsData: data,
      wordsDataMap: this.parsedWordsString(data)
    }
  }

  callbakOnChange = ((event: any): void => {
    this.setState({
      wordsData: event.target.value,
      wordsDataMap: this.parsedWordsString(event.target.value)
    })
  })
  parsedWordsString = (s: string): Types.Map => {
    try {
      return JSON.parse(s) as Types.Map || demoData
    }
    catch (e) {
      return demoData
    }

  }


  render() {

    return (
      <div>
        <InputBox id="mainInput" initData={JSON.stringify(demoData)} onChange={this.callbakOnChange} showLabel={true} />
        <div style={{ height: 600, width: 600 }}>
          <RecoilRoot>
            <NW data={this.state.wordsDataMap} />
          </RecoilRoot>
        </div>
      </div>
    )
  }
}

export default RelationGraph;
Create types.tsx
export namespace Types{
    export type Node = {
        name: string
        group: number
        radiusSize: number
        fillColor: string
    }
    export type Link = {
        source: string
        target: string
        value: number
    }
    export type Map ={
        nodes: Node[]
        links: Link[]
    }
    export type Point = {
        x: number
        y: number
    }

    export type Datum = {
        x: number
        y: number
        fx: number | null
        fy: number | null
    }
}
Create Circle.tsx
import * as React from 'react'
import * as d3 from 'd3'
import { Types } from './types'

export default class Circle extends React.PureComponent<{ node: Types.Node }> {
  ref: SVGCircleElement | undefined

  componentDidMount() {
    if (this.ref) d3.select(this.ref).data([this.props.node])
  }

  render() {
    return (
      // eslint-disable-next-line no-return-assign
      <circle className="node" r={this.props.node.radiusSize} fill={this.props.node.fillColor as string} ref={(ref: SVGCircleElement) => (this.ref = ref)}>
        <title>{this.props.node.name}</title>
      </circle>
    )
  }
}
Create Circles.tsx
import * as React from 'react'
import * as d3 from 'd3'
import { D3DragEvent } from 'd3'
import Circle from './Circle'
import { Types } from './types'


interface ICirclesProps {
  nodes: Types.Node[]
  restartDrag: () => void
  stopDrag: () => void
}
let nodeCounter = 0;
export default class Circles extends React.PureComponent<ICirclesProps, {}> {
  componentDidMount() {
    this.setMouseEventsListeners()
  }

  componentDidUpdate(prevProps: ICirclesProps) {
    this.setMouseEventsListeners()
  }

  setMouseEventsListeners = () => {
    const { props } = this
    d3.selectAll('.node')
      // @ts-ignore
      .call(d3.drag<SVGCircleElement, Types.datum>().on('start', onDragStart).on('drag', onDrag).on('end', onDragEnd))

    // @ts-ignore
    function onDragStart(event: D3DragEvent<SVGCircleElement>, d: Types.Datum) {
      if (!event.active) {
        props.restartDrag()
      }
      // eslint-disable-next-line no-param-reassign
      d.fx = d.x
      // eslint-disable-next-line no-param-reassign
      d.fy = d.y
    }

    function onDrag(event: D3DragEvent<SVGCircleElement, never, never>, d: Types.Datum) {
      // eslint-disable-next-line no-param-reassign
      d.fx = event.x
      // eslint-disable-next-line no-param-reassign
      d.fy = event.y
    }

    function onDragEnd(event: D3DragEvent<SVGCircleElement, never, never>, d: Types.Datum) {
      if (!event.active) {
        props.stopDrag()
      }
      // eslint-disable-next-line no-param-reassign
      d.fx = null
      // eslint-disable-next-line no-param-reassign
      d.fy = null
    }
  }

  render() {
    const nodes = this.props.nodes.map((node: Types.Node) => {
      nodeCounter += 1; 
      return <Circle key={`node-${nodeCounter}`} node={node} />
    })
    return <g className="nodes">{nodes}</g>
  }
}

Create Label.tsx
import * as React from 'react'
import * as d3 from 'd3'
import { Dispatch, SetStateAction } from 'react'
import { Types } from './types'

export default class Label extends React.PureComponent<ILabelProps> {
  ref: SVGTextElement | undefined

  componentDidMount() {
    if (this.ref) d3.select(this.ref).data([this.props.node])
  }

  render() {
    return (
      <text
        style={{ cursor: 'pointer' }}
        className="label"
        // eslint-disable-next-line no-return-assign
        ref={(ref: SVGTextElement) => (this.ref = ref)}
        onClick={() => {
          this.props.onNodeSelected(((this.props.node as unknown) as { index: number }).index - 1)
        }}
      >
        {this.props.node.name}
      </text>
    )
  }
}

interface ILabelProps {
  node: Types.Node
  onNodeSelected: Dispatch<SetStateAction<number>>
}
Create Labels.tsx
import * as React from 'react'
import { Dispatch, SetStateAction } from 'react'
import Label from './Label'
import { Types } from './types'

let labelCounter = 0;


export default class Labels extends React.PureComponent<ILabelsProps> {
  render() {

    const labels = this.props.nodes.map((node: Types.Node) => {
      labelCounter+=1
      return <Label key={`label-${labelCounter}`} node={node} onNodeSelected={this.props.onNodeSelected} />
    })
    return <g className="labels">{labels}</g>
  }
}

interface ILabelsProps {
  nodes: Types.Node[]
  onNodeSelected: Dispatch<SetStateAction<number>>
}
Create Link.tsx
import * as React from 'react'
import * as d3 from 'd3'
import { Types } from './types'

export default class Link extends React.PureComponent<ILinkProps> {
  ref: SVGElement | undefined

  componentDidMount() {
    if (this.ref) d3.select(this.ref).data([this.props.link])
  }
  componentDidUpdate(){
    if (this.ref) d3.select(this.ref).data([this.props.link])
  }

  // eslint-disable-next-line class-methods-use-this
  onMouseOverHandler(event: React.MouseEvent<SVGLineElement, MouseEvent>, link: ILinkProps) {
    d3.select('.linkGroup')
      .append('text')
      .attr('class', 'linkTextValue')
      .attr('stroke-width', link.link.value)
      .attr('x', event.nativeEvent.offsetX)
      .attr('y', event.nativeEvent.offsetY)
  }

  // eslint-disable-next-line class-methods-use-this
  onMouseOutHandler() {
    d3.select('.linkTextValue').remove()
  }

  render() {
    return (
      <g className="linkGroup">
        <line
          // eslint-disable-next-line no-return-assign
          ref={(ref: SVGLineElement) => (this.ref = ref)}
          className="link"
          strokeWidth={this.props.link.value}
          onMouseOver={(event) => {
            this.onMouseOverHandler(event, this.props)
          }}
          onMouseOut={(event) => {
            this.onMouseOutHandler()
          }}
        />
      </g>
    )
  }
}

interface ILinkProps {
  link: Types.Link
}
Create Links.tsx
import * as React from 'react'
import Link from './Link'
import { Types } from './types'

let linkCounter = 1;

export default class Links extends React.PureComponent<{ links: Types.Link[] }> {
  render() {
    const links = this.props.links.map((link: Types.Link) => {
        linkCounter +=1 ;
        return <Link key={`links-${linkCounter}`} link={link} />
    })
    return <g className="links">{links}</g>
  }
}
Create NetworksWidget.tsx
import React, { useState } from 'react'
import './NetworksWidget.scss'
import { useRecoilValue, selector, RecoilRoot } from 'recoil'
import SimpleForceGraph from './SimpleForceGraph'
import { Types } from './types'



const NW = (props: any) => {
  const forceData: Types.Map = props.data
  // const forceData: Types.Map = useRecoilValue(getPowerChartData()) as Types.Map

  const [selectedIndex, setSelectedIndex] = useState(0)

  if(forceData){
    return(
      <RecoilRoot>
        <div className="selectedText">Selected Index: {selectedIndex}</div>
        <SimpleForceGraph
          forceData={forceData}
          onNodeSelected={setSelectedIndex}
        />
      </RecoilRoot>
    )
  }else{
    return(<>Loading</>)
  }
}
export default NW
Create SimpleForceGraph.tsx
import * as React from 'react'
import * as d3 from 'd3'
import './SimpleForceGraph.scss'
import { Simulation, SimulationNodeDatum } from 'd3-force'
import { Dispatch, SetStateAction } from 'react'
import Links from './Links'
import Circles from './Circles'
import Labels from './Labels'
import { Types } from './types'

type execState = {
  hasError: boolean
  errorInfo?: Error
  forceData: Types.Map
  onNodeSelected: any
}
type inputPorps = {
  forceData: Types.Map,
  onNodeSelected: any
}
class SimpleForceGraph  extends React.Component<inputPorps, execState> {
  constructor(props: inputPorps) {
    super(props)
    this.state = {
      hasError: false,
      forceData: this.props.forceData,
      onNodeSelected: this.props.onNodeSelected
    }
  }
  componentDidCatch(error: Error, errorInfo: {componentStack: string}){
    this.setState({
      hasError: true,
      errorInfo: error
    });
  }
  componentDidUpdate(prevProps: inputPorps, prevState: execState){
    if(this.props.forceData !== prevProps.forceData){
      this.setState({
        hasError: false,
        errorInfo: undefined,
        forceData: this.props.forceData,
        onNodeSelected: this.props.onNodeSelected
      });

    }
  }
  render(){
    if(this.state.hasError){
      return <p>{String(this.state.errorInfo)}</p>
    }
    
    return (
      <div className="wrapperDiv">
        <SimpleForceGraphCore
            width={800}
            height={800}
            data={this.state.forceData}
            onNodeSelected={this.state.onNodeSelected}
            linkDistance={80}
            linkStrength={1}
            chargeStrength={-20}
            centerWidth={350}
            centerHeight={170}
          />
      </div>
    )

    
  }
}


class SimpleForceGraphCore extends React.PureComponent<ITopContentPowerChartProps, {} > {
  private simulation: Simulation<SimulationNodeDatum, any> = d3.forceSimulation()

  constructor(props: ITopContentPowerChartProps) {
    super(props)
    this.initD3Map()
  }

  componentDidMount() {
    
    // 
    this.resetD3Map()
    //
    this.simulatePositions(this.props)
    this.drawTicks(this.props.data)
    this.addZoomCapabilities()
  }


  componentDidUpdate(prevProps: ITopContentPowerChartProps, prevState: {}) {

    console.log("init:",this.props);
    this.simulatePositions(this.props)
    this.drawTicks(this.props.data)
  }
  
  simulatePositions = (simProps: ITopContentPowerChartProps) => {
    this.simulation
      .nodes(simProps.data?.nodes as SimulationNodeDatum[])
      .force(
        'link',
        d3
          .forceLink()
          .id((d) => {
            return (d as Types.Node).name
          })
          .distance(simProps.linkDistance)
          .strength(simProps.linkStrength)
      )
      .force('charge', d3.forceManyBody().strength(simProps.chargeStrength))
      .force('center', d3.forceCenter(simProps.centerWidth, simProps.centerHeight))

      
      try {
        // @ts-ignore
        this.simulation.force('link').links(simProps.data?.links)
      } catch (error) {
        this.resetD3Map()
        console.log("error 2", error)
        throw Error(`${error}`)
      }
      
    
  }
  initD3Map = () =>{
    this.simulation
    .nodes([])
    .force('link',d3.forceLink())
  }
  resetD3Map = () =>{
    // @ts-ignore
    this.simulation.force('link').links([])
    this.simulation.nodes([]);
  }
  drawTicks = (mapData: Types.Map) => {
    const onTickHandler = ()=> {
      d3.selectAll('.link')
        .attr('x1', (d) => {
          return (d as { source: Types.Point }).source.x
        })
        .attr('y1', (d) => {
          return (d as { source: Types.Point }).source.y
        })
        .attr('x2', (d) => {
          return (d as { target: Types.Point }).target.x
        })
        .attr('y2', (d) => {
          return (d as { target: Types.Point }).target.y
        })
      d3.selectAll('.node')
        .attr('cx', (d) => {
          return (d as Types.Point).x
        })
        .attr('cy', (d) => {
          return (d as Types.Point).y
        })
      d3.selectAll('.label')
        .attr('x', (d) => {
          return (d as Types.Point).x + 5
        })
        .attr('y', (d) => {
          return (d as Types.Point).y + 5
        })
    }
    this.simulation.nodes(mapData?.nodes as SimulationNodeDatum[]).on('tick', onTickHandler)
    
  }

  addZoomCapabilities = () => {
    const container = d3.select('.container')
    const zoom = d3
      .zoom()
      .scaleExtent([1, 8])
      .translateExtent([
        [100, 100],
        [300, 300],
      ])
      .extent([
        [100, 100],
        [200, 200],
      ])
      .on('zoom', (event) => {
        let { x, y, k } = event.transform
        x = 0
        y = 0
        k *= 1
        container.attr('transform', `translate(${x}, ${y})scale(${k})`).attr('width', this.props.width).attr('height', this.props.height)
      })

    // @ts-ignore
    container.call(zoom)
  }

  restartDrag = () => {
    if (this.simulation) this.simulation.alphaTarget(0.2).restart()
  }

  stopDrag = () => {
    if (this.simulation) this.simulation.alphaTarget(0)
  }

  render() {
    const initialScale = 1
    const initialTranslate = [0, 0]
    const { width, height } = this.props
    return (
      <svg className="container" x={0} y={0} width={width} height={height} transform={`translate(${initialTranslate[0]}, ${initialTranslate[1]})scale(${initialScale})`}>
        <g>
          <Links links={this.props.data?.links as Types.Link[]} />
          <Circles nodes={this.props.data?.nodes as Types.Node[]} restartDrag={this.restartDrag} stopDrag={this.stopDrag} />
          <Labels nodes={this.props.data?.nodes as Types.Node[]} onNodeSelected={this.props.onNodeSelected} />
        </g>
      </svg>
    )
  }
}

interface ITopContentPowerChartProps {
  width: number
  height: number
  data: Types.Map
  onNodeSelected: Dispatch<SetStateAction<number>>
  linkDistance: number
  linkStrength: number
  chargeStrength: number
  centerWidth: number
  centerHeight: number
}



export default SimpleForceGraph

CSS

Create SimpleForceGraph.scss
.container {
    background-color: #ffffff;
}

.node {
    stroke: #e0b7f6;
    stroke-width: 1px;
}

.link {
    stroke: #cfcccc;
    stroke-opacity: 1;
    // stroke-width: 1px;
}

.label {
    font-size: 13px;
    fill: #999898;
}

.linkTextValue {
    display: block;
    font-size: 6px;
    fill: rgba(253, 165, 41, 1);
}
Create NetworksWidget.scss
.wrapperDiv {
    width: 800px;
    height: 350px;
    clip-path: inset(10px 20px 30px 40px);
  }

.selectedText {
    font-size: 13px;
    color: #373636;
}

Add a Comment

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *