import * as Config from "../config"; import * as Helpers from "./helpers"; import { loadGraphJson } from "../datasets"; import * as THREE from "three"; import { ForceGraph3D } from "react-force-graph"; import screenfull from "screenfull"; import { CSS3DRenderer, CSS3DSprite, } from "three/examples/jsm/renderers/CSS3DRenderer.js"; 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 { Coordinate, Graph, GraphLink, GraphNode, LinkData, NodeData, } from "./graph"; import { graph } from "../editor/js/editor"; export class GraphRenderer extends React.Component< 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... edgeColors: Map<string, string>; static propTypes = { graph: PropTypes.instanceOf(Graph), spaceId: PropTypes.string.isRequired, loadingFinishedCallback: PropTypes.func, onNodeClicked: PropTypes.func, isFullscreen: PropTypes, }; static stateTypes = { highlightedNodes: PropTypes.array, highlightedLinks: PropTypes.array, hoverNode: PropTypes.object, }; constructor(props: InferType<typeof GraphRenderer.propTypes>) { super(props); this.state = { highlightedNodes: [], highlightedLinks: [], hoverNode: null, }; this.forceGraph = React.createRef(); // TODO: NodeVisibility, linkVisibility, graphLoading has to be moved to parent component } // componentDidMount() { // loadGraphJson(this.props.spaceId).then((json) => // this.updateGraph(json) // ); // } // updateGraph(json: { nodes: NodeData[]; links: LinkData[] }) { // const graph = new Graph(json.nodes, json.links); // this.setState({ graph: graph }); // // this.edgeVisibility = new Map<string, boolean>( // graph.getLinkClasses().map((cls) => [cls, true]) // ); // this.nodeVisibility = new Map<string, boolean>( // graph.getNodeClasses().map((cls) => [cls, true]) // ); // } drawNode(node: GraphNode): Object3D { const sprite = new SpriteText(node.id); sprite.color = node.color; sprite.textHeight = 8; return sprite; } onNodeHover(node: GraphNode) { // no state change if ( (!node && !this.state.highlightNodes.size) || (node && this.state.hoverNode === node) ) return; const highlightNodes: Set<NodeData> = new Set<NodeData>(); const highlightLinks: Set<LinkData> = new Set<LinkData>(); if (node) { highlightNodes.add(node); node.neighbors.forEach((neighbor) => highlightNodes.add(neighbor)); node.links.forEach((link) => highlightLinks.add(link)); } this.setState({ highlightedNodes: highlightNodes, highlightedLinks: highlightLinks, hoverNode: node || null, }); } onLinkHover(link: GraphLink, previousLink: GraphLink) { const highlightNodes: Set<NodeData> = new Set<NodeData>(); const highlightLinks: Set<LinkData> = new Set<LinkData>(); if (previousLink && previousLink.material) { // A bit hacky, but the alternative would require additional data structures previousLink.material.linewidth = Config.LINK_WIDTH; } if (link && link.material) { link.material.linewidth = Config.HOVER_LINK_WIDTH; highlightLinks.add(link); highlightNodes.add(link.source); highlightNodes.add(link.target); } this.setState({ highlightedNodes: highlightNodes, highlightedLinks: highlightLinks, }); } onNodeClick(node: GraphNode) { this.focusOnNode(node); if (MODE === "default") { this.props.onNodeClicked(node); } } onNodeDragEnd(node: GraphNode, translate: Coordinate) { // NodeDrag is handled like NodeClick if distance is very short if ( Math.sqrt( Math.pow(translate.x, 2) + Math.pow(translate.y, 2) + Math.pow(translate.z, 2) ) < DRAG_THRESHOLD_3D ) { this.onNodeClick(node); } } focusOnNode(node: GraphNode) { // Aim at node from outside it const distance = 250; const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z); this.forceGraph.current.cameraPosition( { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio, }, // new position node, // lookAt ({ x, y, z }) 1000 // ms transition duration ); } getLinkColor(link: LinkData) { if ("type" in link) { return this.edgeColors.get(link.type); } return "rgb(255, 255, 255)"; } getLinkWidth(link) { return this.highlightLinks.has(link) ? 2 : 0.8; } /** * Maps the colors of the color palette to the different edge types */ mapLinkColors() { const linkClasses = this.props.graph.getLinkClasses(); for (let i = 0; i < linkClasses.length; i++) { this.edgeColors.set( linkClasses[i], Config.COLOR_PALETTE[i % Config.COLOR_PALETTE.length] ); } } resize() { if (screenfull.isFullscreen) { this.graph.height(screen.height); this.graph.width(screen.width); } else { this.graph.height(window.innerHeight - 200); this.graph.width(Helpers.getWidth()); } } render() { this.mapLinkColors(); return ( <ForceGraph3D ref={this.forceGraph} width={scree} graphData={this.state.graph} rendererConfig={{ antialias: true }} nodeThreeObject={(node: GraphNode) => this.drawNode(node)} onNodeClick={(node: GraphNode) => this.onNodeClick(node)} onNodeHover={(node: GraphNode) => this.onNodeHover(node)} onLinkHover={(link: GraphLink, previousLink: GraphLink) => this.onLinkHover(link, previousLink) } onNodeDragEnd={(node: GraphNode, translate: Coordinate) => this.onNodeDragEnd(node, translate) } /> ); } } /** * The main ForceGraph. Displays the graph and handles all connected events. */ export default class Graph2 { /** * Constructs a new Graph object. * @param {string} spaceId Name of the knowledge space that should be loaded * @param {function} loadingFinishedCallback Callback that is called when the graph is fully loaded. */ constructor(spaceId, loadingFinishedCallback = Function()) { this.graph = null; this.gData = null; this.highlightNodes = new Set(); this.highlightLinks = new Set(); this.hoverNode = null; this.edgeColors = {}; this.nodeColors = {}; this.idToNode = {}; this.firstTick = true; this.engineFrozen = false; this.allowRedraw = false; this.infoOverlay = null; this.edgeTypeVisibility = {}; this.nodeTypeVisibility = {}; this.loadingFinishedCallback = loadingFinishedCallback; this.loadGraph(spaceId); } /** * Loads the graph by constructing a new ForceGraph3D object. * Also fetches the JSON data from the given space. * @param {string} spaceId ID to a JSON object defining the graph structure. * @returns {Promise<void>} */ async loadGraph(spaceId) { this.gData = await loadGraphJson(spaceId); this.graph = ForceGraph3D({ extraRenderers: [new CSS3DRenderer()], rendererConfig: { antialias: true }, })(document.getElementById("3d-graph")) .graphData(this.gData) .nodeLabel("hidden") // Just a value that is not present as node attribute. //.nodeAutoColorBy("group") //.nodeColor((node) => this.getNodeColor(node)) //.linkWidth((link) => this.getLinkWidth(link)) .onNodeClick((node) => this.onNodeClick(node)) .onNodeHover((node) => { this.onNodeHover(node); this.updateHighlight(); }) .onLinkHover((link, previousLink) => this.onLinkHover(link, previousLink) ) .onNodeDrag(() => { this.allowRedraw = true; }) .onNodeDragEnd((node, translate) => this.onNodeDragEnd(node, translate) ) .onEngineStop(() => this.simulationStop()) //.linkColor((link) => this.getLinkColor(link)) .linkPositionUpdate((line, { start, end }) => this.updateLinkPosition(line, start, end) ) //.linkOpacity(0.8) .nodeThreeObjectExtend(false) .nodeThreeObject((node) => this.drawNode(node)) //.linkThreeObject((link) => this.drawLink(link)) .onEngineTick(() => this.initializeModel()) .width(Helpers.getWidth()) .height(Helpers.getHeight()); setTimeout(() => this.simulationStop(), 3000); } /** * Initializes all component which are dependent on the graph data after the graph has finished loading * (after it has computed its first tick.) */ initializeModel() { if (this.firstTick) { // Initialize data structures this.mapLinkColors(); this.mapNodeColors(); this.updateNodeData(); this.updateNodeMap(); this.addBackground(); // Can only be called after link colors have been mapped. this.graph.linkThreeObject((link) => this.drawLink(link)); // Catch resize events document.addEventListener("fullscreenchange", () => this.resize()); window.addEventListener("resize", () => this.resize()); // Initialize visibility states this.getLinkClasses().forEach( (item) => (this.edgeTypeVisibility[item] = true) ); this.getNodeClasses().forEach( (item) => (this.nodeTypeVisibility[item] = true) ); this.firstTick = false; this.loadingFinishedCallback(); } } // TODO: Move this up to the class which is handling the graph // updateVisibility() { // this.updateGraphData(); // this.removeFloatingLinks(); // this.updateNodeData(); // this.removeFloatingNodes(); // } // // removeFloatingNodes() { // const gData = this.graph.graphData(); // const nodes = gData.nodes.filter((node) => node.neighbors.length > 0); // const data = { // nodes: nodes, // links: gData.links, // }; // this.graph.graphData(data); // } // // removeFloatingLinks() { // const gData = this.graph.graphData(); // const links = gData.links.filter( // (link) => // this.nodeTypeVisibility[link.target.type] & // this.nodeTypeVisibility[link.source.type] // ); // const data = { // nodes: gData.nodes, // links: links, // }; // this.graph.graphData(data); // } // /** // * Resets additional node values. // * @see updateNodeData // */ updateHighlight() { // trigger update of highlighted objects in scene // this.graph // .nodeColor(this.graph.nodeColor()) // .linkWidth(this.graph.linkWidth()) // .linkDirectionalParticles(this.graph.linkDirectionalParticles()); } addBackground() { const sphereGeometry = new THREE.SphereGeometry(20000, 32, 32); //const planeGeometry = new THREE.PlaneGeometry(1000, 1000, 1, 1); const loader = new THREE.TextureLoader(); //const planeMaterial = new THREE.MeshLambertMaterial({color: 0xFF0000, side: THREE.DoubleSide}); //THREE.BackSide 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.graph.scene().add(mesh); } /** * Maps the colors of the color palette to the different edge types */ mapLinkColors() { const linkClasses = this.getLinkClasses(); for (let i = 0; i < linkClasses.length; i++) { this.edgeColors[linkClasses[i]] = Config.COLOR_PALETTE[i % Config.COLOR_PALETTE.length]; } } mapNodeColors() { const nodeClasses = this.getNodeClasses(); for (let i = 0; i < nodeClasses.length; i++) { this.nodeColors[nodeClasses[i]] = Config.COLOR_PALETTE[i % Config.COLOR_PALETTE.length]; } } drawLink(link) { const colors = new Float32Array( [].concat( ...[link.target, link.source] .map((node) => this.nodeColors[node.type]) .map((color) => color.replace(/[^\d,]/g, "").split(",")) // Extract rgb() color components .map((rgb) => rgb.map((v) => 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; } simulationStop() { this.engineFrozen = true; this.stopPhysics(); } stopPhysics() { const data = this.graph.graphData(); data["nodes"].forEach((n) => { n.fx = n.x; n.fy = n.y; n.fz = n.z; }); this.graph.graphData(data); } updateLinkPosition(line, start, end) { if (!this.allowRedraw) { if (this.engineFrozen) { return true; } } if (!(line instanceof Line2)) { return false; } const startR = 4; const endR = 4; const lineLen = Math.sqrt( ["x", "y", "z"] .map((dim) => Math.pow((end[dim] || 0) - (start[dim] || 0), 2)) .reduce((acc, v) => acc + v, 0) ); const positions = [startR / lineLen, 1 - endR / lineLen] .map((t) => ["x", "y", "z"].map( (dim) => start[dim] + (end[dim] - start[dim]) * t ) ) .flat(); line.geometry.setPositions(positions); // line.geometry.getAttribute("position").needsUpdate = true; // line.computeLineDistances(); return true; } drawNode(node) { // Draw node as label + image const nodeDiv = Helpers.createDiv( "node-container", document.getElementById("3d-graph") ); const group = new THREE.Group(); const labelDiv = Helpers.createDiv("node-label", nodeDiv, { textContent: node.name, }); labelDiv.classList.add("no-select"); labelDiv.style.color = node.color; const cssobj = new CSS3DSprite(nodeDiv); cssobj.scale.set(0.25, 0.25, 0.25); cssobj.position.set(0, -6, 0); cssobj.element.style.pointerEvents = "none"; group.add(cssobj); // Draw node circle image const textureLoader = new THREE.TextureLoader(); textureLoader.setCrossOrigin("anonymous"); const imageAlpha = textureLoader.load( Config.PLUGIN_PATH + "datasets/images/alpha.png" ); let imageTexture = null; if ("image" in node) { if (node.image.startsWith("http")) { imageTexture = textureLoader.load(node.image); } else { imageTexture = textureLoader.load( Config.PLUGIN_PATH + "datasets/images/" + node.image ); } } else { imageTexture = textureLoader.load( Config.PLUGIN_PATH + "datasets/images/default.jpg" ); } const material = new THREE.SpriteMaterial({ map: imageTexture, 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. if ("image" in node) { sprite.scale.set(20, 20); } else { sprite.scale.set(5, 5); } group.add(sprite); return group; } }