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

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

Matthias Konitzny
committed
import {
CSS3DRenderer,
CSS3DSprite,
} from "three/examples/jsm/renderers/CSS3DRenderer.js";
import { MODE } from "../config";
/**
* 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.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.focusOnNode(node);
if (MODE === "default") {
this.infoOverlay.updateInfoOverlay(node);
}
.onNodeHover((node) => {
this.onNodeHover(node);
this.updateHighlight();
})
.onLinkHover((link) => this.onLinkHover(link))

Matthias Konitzny
committed
//.linkColor((link) => this.getLinkColor(link))
.linkPositionUpdate((line, { start, end }) =>
this.updateLinkPosition(line, start, end)
)
.linkOpacity(0.8)
.nodeThreeObjectExtend(false)
.nodeThreeObject((node) => this.drawNode(node))
.onEngineTick(() => this.initializeModel())
.width(Helpers.getWidth())
.height(Helpers.getHeight());
/**
* 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;
}
onLinkHover(link) {
this.highlightNodes.clear();
this.highlightLinks.clear();
if (link) {
this.highlightLinks.add(link);
this.highlightNodes.add(link.source);
this.highlightNodes.add(link.target);
}
this.updateHighlight();
}
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) => {
});
if (document.fullscreenElement == Helpers.getCanvasDivNode()) {
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({
map: loader.load(
Config.PLUGIN_PATH + "backgrounds/background_4.jpg"
),
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
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
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 material = new THREE.LineBasicMaterial({
vertexColors: THREE.VertexColors,
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(2 * 3), 3)
);
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
return new THREE.Line(geometry, material);
}
updateLinkPosition(line, start, end) {
const startR = 4;
const endR = 4;
// const startR = Graph.nodeRelSize();
// const endR = Graph.nodeRelSize();
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 linePos = line.geometry.getAttribute("position");
// calculate coordinate on the node's surface instead of center
linePos.set(
[startR / lineLen, 1 - endR / lineLen]
.map((t) =>
["x", "y", "z"].map(
(dim) => start[dim] + (end[dim] - start[dim]) * t
)
)
.flat()
);
linePos.needsUpdate = true;
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;
}
}