Newer
Older
import * as Config from "../config";
import * as Helpers from "./helpers";
import { loadGraphJson } from "../datasets";

Matthias Konitzny
committed
import * as THREE from "three";
import ForceGraph3D from "3d-force-graph";
import screenfull from "screenfull";

Matthias Konitzny
committed
import {
CSS3DRenderer,
CSS3DSprite,
} from "three/examples/jsm/renderers/CSS3DRenderer.js";
import { MODE, DRAG_THRESHOLD_3D } from "../config";

Matthias Konitzny
committed
import background from "./background.jpg";
import { Line2, LineGeometry, LineMaterial } from "three-fatline";
/**
* The main ForceGraph. Displays the graph and handles all connected events.
*/
* @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;

Matthias Konitzny
committed
this.nodeColors = {};
this.firstTick = true;

Matthias Konitzny
committed
this.engineFrozen = false;
this.allowRedraw = false;

Matthias Konitzny
committed
this.infoOverlay = null;
this.edgeTypeVisibility = {};

Matthias Konitzny
committed
this.nodeTypeVisibility = {};
this.loadingFinishedCallback = loadingFinishedCallback;
/**
* 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.
async loadGraph(spaceId) {
this.gData = await loadGraphJson(spaceId);
this.graph = ForceGraph3D({

Matthias Konitzny
committed
extraRenderers: [new CSS3DRenderer()],

Matthias Konitzny
committed
rendererConfig: { antialias: true },
})(document.getElementById("3d-graph"))
.graphData(this.gData)

Matthias Konitzny
committed
.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)
)

Matthias Konitzny
committed
.onNodeDrag(() => {
this.allowRedraw = true;
})
.onNodeDragEnd((node, translate) =>
this.onNodeDragEnd(node, translate)
)

Matthias Konitzny
committed
.onEngineStop(() => this.simulationStop())

Matthias Konitzny
committed
//.linkColor((link) => this.getLinkColor(link))
.linkPositionUpdate((line, { start, end }) =>
this.updateLinkPosition(line, start, end)
)
.nodeThreeObjectExtend(false)
.nodeThreeObject((node) => this.drawNode(node))
.onEngineTick(() => this.initializeModel())
.width(Helpers.getWidth())
.height(Helpers.getHeight());

Matthias Konitzny
committed
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.)
*/

Matthias Konitzny
committed
// Initialize data structures
this.mapLinkColors();
this.mapNodeColors();
this.updateNodeData();

Matthias Konitzny
committed
// Can only be called after link colors have been mapped.
this.graph.linkThreeObject((link) => this.drawLink(link));
document.addEventListener("fullscreenchange", () => this.resize());
window.addEventListener("resize", () => this.resize());

Matthias Konitzny
committed
// Initialize visibility states
this.getLinkClasses().forEach(
(item) => (this.edgeTypeVisibility[item] = true)
);

Matthias Konitzny
committed
this.getNodeClasses().forEach(
(item) => (this.nodeTypeVisibility[item] = true)
);

Matthias Konitzny
committed
this.loadingFinishedCallback();
/**
* Returns the color of the given node as a string in the HTML rgb() format.
* @param node
* @returns {string} HTML rgb() string.
*/
getNodeColor(node) {
return this.highlightNodes.has(node)

Matthias Konitzny
committed
? node === this.hoverNode
? "rgb(255,0,0,1)"
: "rgba(255,160,0,0.8)"
: "rgba(0,255,255,0.6)";
}
getLinkColor(link) {
if ("type" in link) {
return this.edgeColors[link.type];
return "rgb(255, 255, 255)";
}
getLinkWidth(link) {
return this.highlightLinks.has(link) ? 2 : 0.8;
}
/**
* Returns an array containing the different edge types of the graph.
* @returns {*[]}
*/
this.graph
.graphData()
.links.forEach((link) => linkClasses.push(link.type));

Matthias Konitzny
committed
return [...new Set(linkClasses)].map((c) => String(c));
}
getNodeClasses() {
const nodeClasses = [];
this.graph
.graphData()
.nodes.forEach((node) => nodeClasses.push(node.type));

Matthias Konitzny
committed
return [...new Set(nodeClasses)].map((c) => String(c));
onNodeHover(node) {
// no state change
if (
(!node && !this.highlightNodes.size) ||
(node && this.hoverNode === node)
)
return;
this.highlightNodes.clear();
this.highlightLinks.clear();
if (node) {
this.highlightNodes.add(node);
node.neighbors.forEach((neighbor) =>
this.highlightNodes.add(neighbor)
);
node.links.forEach((link) => this.highlightLinks.add(link));
}
this.hoverNode = node || null;
}
this.highlightNodes.clear();
this.highlightLinks.clear();
if (previousLink) {
// A bit hacky, but the alternative would require additional data structures
previousLink.__lineObj.material.linewidth = Config.LINK_WIDTH;
}
if (link) {
link.__lineObj.material.linewidth = Config.HOVER_LINK_WIDTH;
this.highlightLinks.add(link);
this.highlightNodes.add(link.source);
this.highlightNodes.add(link.target);
}
this.updateHighlight();
}
onNodeClick(node) {
this.focusOnNode(node);
if (MODE === "default") {
this.infoOverlay.updateInfoOverlay(node);
}
}
onNodeDragEnd(node, translate) {
// 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
) {

Matthias Konitzny
committed
this.allowRedraw = false;
if (typeof node == "string") {
node = this.idToNode[node];
}
// Aim at node from outside it
const distance = 250;
const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
{
x: node.x * distRatio,
y: node.y * distRatio,
z: node.z * distRatio,
}, // new position
1000 // ms transition duration
toggleLinkVisibility(type) {
if (this.edgeTypeVisibility[type]) {
this.hideLinkType(type);
} else {
this.showLinkType(type);
}
}

Matthias Konitzny
committed
toggleNodeVisibility(type) {
if (this.nodeTypeVisibility[type]) {
this.hideNodeType(type);
} else {
this.showNodeType(type);
}
}
updateVisibility() {
this.updateGraphData();

Matthias Konitzny
committed
this.removeFloatingLinks();
this.updateNodeData();
this.removeFloatingNodes();
}

Matthias Konitzny
committed
hideLinkType(type) {
this.edgeTypeVisibility[type] = false;
this.updateVisibility();
}
showLinkType(type) {
this.edgeTypeVisibility[type] = true;

Matthias Konitzny
committed
this.updateVisibility();
}
hideNodeType(type) {
this.nodeTypeVisibility[type] = false;
this.updateVisibility();
}
showNodeType(type) {
this.nodeTypeVisibility[type] = true;
this.updateVisibility();
}
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);
}

Matthias Konitzny
committed
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);
}
updateGraphData() {
const data = {

Matthias Konitzny
committed
nodes: this.gData.nodes.filter(
(l) => this.nodeTypeVisibility[l.type]
),
links: this.gData.links.filter(
(l) => this.edgeTypeVisibility[l.type]
),
};
this.graph.graphData(data);
}
/**
* Resets additional node values.
* @see updateNodeData
*/
resetNodeData() {
const gData = this.graph.graphData();
for (const node of gData.nodes) {
node.neighbors = [];
node.links = [];
}
}
/**
* Updates the graph data structure to contain additional values.
* Creates a 'neighbors' and 'links' array for each node object.
*/
updateNodeData() {
const gData = this.graph.graphData();
// cross-link node objects
this.resetNodeData();
gData.links.forEach((link) => {
const a = link.source;
const b = link.target;
a.neighbors.push(b);
b.neighbors.push(a);
a.links.push(link);
b.links.push(link);
});
this.graph.graphData(gData);
}
updateHighlight() {
// trigger update of highlighted objects in scene
// this.graph
// .nodeColor(this.graph.nodeColor())
// .linkWidth(this.graph.linkWidth())
// .linkDirectionalParticles(this.graph.linkDirectionalParticles());
updateNodeMap() {
const gData = this.graph.graphData();
gData.nodes.forEach((node) => {
});
this.graph.height(screen.height);
this.graph.width(screen.width);
} else {
this.graph.height(window.innerHeight - 200);
this.graph.width(Helpers.getWidth());
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({

Matthias Konitzny
committed
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
*/

Matthias Konitzny
committed
mapLinkColors() {
const linkClasses = this.getLinkClasses();
this.edgeColors[linkClasses[i]] =
Config.COLOR_PALETTE[i % Config.COLOR_PALETTE.length];

Matthias Konitzny
committed
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,

Matthias Konitzny
committed
});
const line = new Line2(geometry, material);
line.computeLineDistances();
line.scale.set(1, 1, 1);
return line;

Matthias Konitzny
committed
}

Matthias Konitzny
committed
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);
}

Matthias Konitzny
committed
updateLinkPosition(line, start, end) {

Matthias Konitzny
committed
if (!this.allowRedraw) {
if (this.engineFrozen) {
return true;
}
}
if (!(line instanceof Line2)) {
return false;
}

Matthias Konitzny
committed
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

Matthias Konitzny
committed
)
)
.flat();

Matthias Konitzny
committed
// line.geometry.getAttribute("position").needsUpdate = true;
// line.computeLineDistances();

Matthias Konitzny
committed
return true;
}
drawNode(node) {
// Draw node as label + image

Matthias Konitzny
committed
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);

Matthias Konitzny
committed
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;
}
}