-
Matthias Konitzny authoredMatthias Konitzny authored
renderer.tsx 10.48 KiB
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)
}
/>
);
}
}