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;
}