import * as Config from "../config"; import * as THREE from "three"; import { ForceGraph3D } from "react-force-graph"; import { MODE, DRAG_THRESHOLD_3D } from "../config"; import background from "./background.jpg"; import { Line2, LineGeometry, LineMaterial } from "three-fatline"; import React from "react"; import PropTypes, { InferType } from "prop-types"; import SpriteText from "three-spritetext"; import { Object3D } from "three"; import Graph, { Coordinate, LinkData, NodeData } from "./graph"; export interface GraphNode extends NodeData { x: number; y: number; z: number; vx: number; vy: number; vz: number; fx: number; fy: number; fz: number; color: string; __threeObj: THREE.Group; } export interface GraphLink extends LinkData { __lineObj?: Line2; } // It is important to extend from React.PureComponent here to remove unnecessary re-renders! // see https://www.robinwieruch.de/react-prevent-rerender-component/ /** * Renders contents of a Graph object as 3d-graph representation. */ export class GraphRenderer extends React.PureComponent< InferType<typeof GraphRenderer.propTypes>, InferType<typeof GraphRenderer.stateTypes> > { props: InferType<typeof GraphRenderer.propTypes>; state: InferType<typeof GraphRenderer.stateTypes>; forceGraph: React.RefObject<any>; // using typeof ForceGraph3d produces an error here... highlightedNodes: Set<GraphNode>; highlightedLinks: Set<GraphLink>; hoverNode: GraphNode; static propTypes = { graph: PropTypes.instanceOf(Graph).isRequired, width: PropTypes.number.isRequired, height: PropTypes.number.isRequired, onNodeClicked: PropTypes.func, isFullscreen: PropTypes.bool, }; static stateTypes = {}; constructor(props: InferType<typeof GraphRenderer.propTypes>) { super(props); this.highlightedNodes = new Set(); this.highlightedLinks = new Set(); this.hoverNode = null; this.forceGraph = React.createRef(); } componentDidMount() { this.addBackground(); } addBackground() { const sphereGeometry = new THREE.SphereGeometry(20000, 32, 32); const loader = new THREE.TextureLoader(); const planeMaterial = new THREE.MeshBasicMaterial({ map: loader.load(background), side: THREE.DoubleSide, }); //THREE.BackSide const mesh = new THREE.Mesh(sphereGeometry, planeMaterial); mesh.position.set(0, 0, 0); //mesh.rotation.set(0.5 * Math.PI, 0, 0); this.forceGraph.current.scene().add(mesh); } drawNode(node: GraphNode): Object3D { const group = new THREE.Group(); const text = new SpriteText(node.name); text.color = "white"; text.backgroundColor = "black"; text.textHeight = 5; // text.padding = 2; text.borderRadius = 5; text.borderWidth = 3; text.borderColor = "black"; text.translateY(12); text.material.opacity = 0.85; text.renderOrder = 999; group.add(text); // Draw node circle image const textureLoader = new THREE.TextureLoader(); textureLoader.setCrossOrigin("anonymous"); const imageAlpha = textureLoader.load( Config.PLUGIN_PATH + "datasets/images/alpha.png" ); const material = new THREE.SpriteMaterial({ //map: imageTexture, color: this.props.graph.nodeColors.get(node.type), alphaMap: imageAlpha, transparent: true, alphaTest: 0.2, depthWrite: false, depthTest: false, }); const sprite = new THREE.Sprite(material); //sprite.renderOrder = 999; // This may not be optimal. But it allows us to render the sprite on top of everything else. sprite.scale.set(...Config.NODE_SIZE); group.add(sprite); return group; } drawLink(link: LinkData) { const colors = new Float32Array( [].concat( ...[link.target, link.source] .map((node) => this.props.graph.nodeColors.get(node.type)) .map((color) => color.replace(/[^\d,]/g, "").split(",")) // Extract rgb() color components .map((rgb) => rgb.map((v: string) => parseInt(v) / 255)) ) ); const geometry = new LineGeometry(); geometry.setPositions([0, 0, 0, 1, 1, 1]); geometry.setColors(colors); const material = new LineMaterial({ color: 0xffffff, linewidth: Config.LINK_WIDTH, // in world units with size attenuation, pixels otherwise vertexColors: true, resolution: new THREE.Vector2( window.screen.width, window.screen.height ), // Set the resolution to the maximum width and height of the screen. dashed: false, alphaToCoverage: true, }); const line = new Line2(geometry, material); line.computeLineDistances(); line.scale.set(1, 1, 1); return line; } onNodeHover(node: GraphNode) { // no state change if ( (!node && !this.highlightedNodes.size) || (node && this.hoverNode === node) ) return; const highlightedNodes = new Set<GraphNode>(); const highlightedLinks = new Set<GraphLink>(); if (node) { highlightedNodes.add(node); node.neighbors.forEach((neighbor) => highlightedNodes.add(neighbor as GraphNode) ); node.links.forEach((link) => highlightedLinks.add(link as GraphLink) ); } this.hoverNode = node || null; this.updateHighlight(highlightedNodes, highlightedLinks); } onLinkHover(link: GraphLink) { const highlightedNodes = new Set<GraphNode>(); const highlightedLinks = new Set<GraphLink>(); if (link && link.__lineObj) { highlightedLinks.add(link); highlightedNodes.add(link.source as GraphNode); highlightedNodes.add(link.target as GraphNode); } this.updateHighlight(highlightedNodes, highlightedLinks); } updateHighlight( highlightedNodes: Set<GraphNode>, highlightedLinks: Set<GraphLink> ) { // Update Links this.highlightedLinks.forEach( (link) => (link.__lineObj.material.linewidth = Config.LINK_WIDTH) ); this.highlightedLinks = highlightedLinks; this.highlightedLinks.forEach( (link) => (link.__lineObj.material.linewidth = Config.HOVER_LINK_WIDTH) ); // Update Nodes this.highlightedNodes.forEach((node) => { node.__threeObj.children[1].scale.set(...Config.NODE_SIZE); }); this.highlightedNodes = highlightedNodes; this.highlightedNodes.forEach((node) => { node.__threeObj.children[1].scale.set( ...Config.HIGHLIGHTED_NODE_SIZE ); }); } onNodeClick(node: GraphNode) { this.focusOnNode(node); if (MODE === "default" && this.props.onNodeClicked) { this.props.onNodeClicked(node); } } onNodeDragEnd(node: GraphNode, translate: Coordinate) { // NodeDrag is handled like NodeClick if distance is very short if ( Math.hypot(translate.x, translate.y, translate.z) < DRAG_THRESHOLD_3D ) { this.onNodeClick(node); } } focusOnNode(node: GraphNode) { const distance = 400; // Aim at node from outside it const speed = 0.5; // Camera travel speed through space const minTime = 1000; // Minimum transition time const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z); const currentPos = this.forceGraph.current.cameraPosition(); const newPos = { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio, }; const travelDist = Math.hypot( newPos.x - currentPos.x, newPos.y - currentPos.y, newPos.z - currentPos.z ); this.forceGraph.current.cameraPosition( newPos, node, // lookAt ({ x, y, z }) Math.max(travelDist / speed, minTime) // ms transition duration ); } updateLinkPosition(line: Line2, start: Coordinate, end: Coordinate) { if (!(line instanceof Line2)) { return false; } // console.log("Updating links"); const startR = 4; const endR = 4; const lineLen = Math.hypot( end.x - start.x, end.y - start.y, end.z - start.z ); const positions = [startR / lineLen, 1 - endR / lineLen] .map((t) => ["x", "y", "z"].map( (dim) => start[dim as keyof typeof start] + (end[dim as keyof typeof end] - start[dim as keyof typeof start]) * t ) ) .flat(); line.geometry.setPositions(positions); return true; } render() { return ( <ForceGraph3D ref={this.forceGraph} width={this.props.width} height={this.props.height} graphData={this.props.graph} rendererConfig={{ antialias: true }} // nodeLabel={"hidden"} nodeThreeObject={(node: GraphNode) => this.drawNode(node)} linkThreeObject={(link: LinkData) => this.drawLink(link)} onNodeClick={(node: GraphNode) => this.onNodeClick(node)} //d3AlphaDecay={0.1} warmupTicks={150} cooldownTime={1000} // TODO: Do we want the simulation to unfreeze on node drag? enableNodeDrag={false} onNodeHover={(node: GraphNode) => this.onNodeHover(node)} onLinkHover={(link: GraphLink) => this.onLinkHover(link)} linkPositionUpdate={( line: Line2, coords: { start: Coordinate; end: Coordinate } ) => this.updateLinkPosition(line, coords.start, coords.end)} onNodeDragEnd={(node: GraphNode, translate: Coordinate) => this.onNodeDragEnd(node, translate) } /> ); } }