Newer
Older
import * as Interactions from "../interactions";
import { Graph } from "../structures/graph/graph";
import { loadGraphJson } from "../../../datasets";
import { NodeDetails } from "./nodedetails";
import { SpaceSelect } from "./spaceselect";
import ReactForceGraph2d from "react-force-graph-2d";
import { Node } from "../structures/graph/node";
import { HistoryNavigator } from "./historynavigator";

Maximilian Giller
committed
import { GraphElement } from "../structures/graph/graphelement";
import { Link } from "../structures/graph/link";
type propTypes = any;
type stateTypes = {
graph: Graph;

Maximilian Giller
committed
visibleLabels: boolean;
selectedNode: Node;
keys: { [name: string]: boolean };
type clickPosition = {
graph: { x: number; y: number };
window: { x: number; y: number };
};
type positionTranslate = {
x: number;
y: number;
z: number;
};
export class Editor extends React.PureComponent<propTypes, stateTypes> {
private maxDistanceToConnect = 15;

Maximilian Giller
committed
private defaultWarmupTicks = 100;
private warmupTicks = 100;

Maximilian Giller
committed
this.loadGraph = this.loadGraph.bind(this);
this.loadSpace = this.loadSpace.bind(this);
this.extractPositions = this.extractPositions.bind(this);
this.handleNodeClick = this.handleNodeClick.bind(this);

Maximilian Giller
committed
this.onHistoryChange = this.onHistoryChange.bind(this);

Maximilian Giller
committed
this.handleEngineStop = this.handleEngineStop.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this);
this.forceUpdate = this.forceUpdate.bind(this);

Maximilian Giller
committed
this.isHighlighted = this.isHighlighted.bind(this);
this.handleNodeCanvasObject = this.handleNodeCanvasObject.bind(this);
this.handleLinkCanvasObject = this.handleLinkCanvasObject.bind(this);
this.handleBackgroundClick = this.handleBackgroundClick.bind(this);
this.handleNodeDrag = this.handleNodeDrag.bind(this);
this.handleNodeDragEnd = this.handleNodeDragEnd.bind(this);
this.handleLinkClick = this.handleLinkClick.bind(this);
this.renderer = React.createRef();
// Set as new state
this.state = {
graph: undefined,

Maximilian Giller
committed
visibleLabels: true,
selectedNode: undefined,
keys: {},
Interactions.initInteractions();
// Load initial space
this.loadSpace("space");
}
/**
* Loads a space from the database to the editor.
* @param spaceId Id of space to load.
* @returns Promise with boolean value that is true, if successful.
*/
public loadSpace(spaceId: string): any {
return loadGraphJson(spaceId).then(this.loadGraph);
}
/**
* Loads another graph based on the data supplied. Note: Naming currently suggests that this only loads a GRAPH, not a SPACE. Needs further work and implementation to see if that makes sense or not.
* @param data Serialized graph data.
* @returns True, if successful.
*/
public loadGraph(data: any): boolean {
console.log("Starting to load new graph ...");

Maximilian Giller
committed
this.warmupTicks = this.defaultWarmupTicks; // Should only run for each newly initialized graph, but then never again
// Is valid and parsed successfully?
if (newGraph === undefined) {
return false;
}
// .onNodeDragEnd((node: any, translate: any) =>
// Editor.globalState.onNodeDragEnd(node, translate)
// )
// .linkWidth((link: any) => Editor.globalState.linkWidth(link))
// .linkDirectionalParticles(
// Editor.globalState.linkDirectionalParticles()
// )
// .linkDirectionalParticleWidth((link: any) =>
// Editor.globalState.linkDirectionalParticleWidth(link)
// )
// .onBackgroundClick((event: any) =>
// Editor.globalState.onBackgroundClick(
// event,
// this.extractPositions(event)
// )
// )
// .onLinkClick((link: any) => Editor.globalState.onLinkClick(link));
console.log(newGraph);

Maximilian Giller
committed
this.state.graph.onChangeCallbacks.push(this.onHistoryChange);
// Subscribe to global key-press events
document.onkeydown = this.handleKeyDown;
document.onkeyup = this.handleKeyUp;
return true;
}
private handleKeyDown(event: KeyboardEvent) {
const key: string = event.key;

Maximilian Giller
committed
const keys = this.state.keys;
keys[key] = true;
this.setState({
keys: keys,
});
}
private handleKeyUp(event: KeyboardEvent) {
const key: string = event.key;

Maximilian Giller
committed
const keys = this.state.keys;
keys[key] = false;
this.setState({
keys: keys,
});
/**
* Handler for background click event on force graph. Adds new node by default.
* @param event Click event.
*/
private handleBackgroundClick(event: any, position: clickPosition) {
const newNode = new Node();
newNode.label = "Unnamed";
(newNode as any).fx = position.graph.x;
(newNode as any).fy = position.graph.y;
(newNode as any).x = position.graph.x;
(newNode as any).y = position.graph.y;
(newNode as any).vx = 0;
(newNode as any).vy = 0;
newNode.add(this.state.graph);
}

Maximilian Giller
committed
/**
* Propagates the changed state of the graph.
*/
private onHistoryChange() {

Maximilian Giller
committed
this.setState({
selectedNode: undefined,
});

Maximilian Giller
committed
this.forceUpdate();
}

Maximilian Giller
committed
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
/**
* Should a given element be highlighted in rendering or not.
* @param element Element that should, or should not be highlighted.
* @returns True, if element should be highlighted.
*/
private isHighlighted(element: GraphElement): boolean {
if (this.state.selectedNode == undefined) {
// Default to false if nothing selected.
return false;
}
if (element.node) {
// Is node
return element.equals(this.state.selectedNode);
} else if (element.link) {
// Is link
// Is it one of the adjacent links?
const found = this.state.selectedNode.links.find(
this.state.selectedNode.equals
);
return found !== undefined;
} else {
return false;
}
}
/**
* Calculates the corresponding coordinates for a click event for easier further processing.
* @param event The corresponding click event.
* @returns Coordinates in graph and coordinates in browser window.
*/
private extractPositions(event: any): clickPosition {
graph: this.renderer.current.screen2GraphCoords(
event.layerX,
event.layerY
),
window: { x: event.clientX, y: event.clientY },
};
}
private handleNodeClick(node: Node) {

Maximilian Giller
committed
if (this.state.keys["Control"]) {
node.delete();
} else {

Maximilian Giller
committed
this.setState({
selectedNode: node,
});

Maximilian Giller
committed
private handleNodeCanvasObject(node: any, ctx: any, globalScale: any) {
console.log("Custom node rendering");

Maximilian Giller
committed
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
// add ring just for highlighted nodes
if (this.isHighlighted(node)) {
ctx.beginPath();
ctx.arc(node.x, node.y, 4 * 0.6, 0, 2 * Math.PI, false);
ctx.fillStyle = "red";
ctx.fill();
}
// Draw image
const imageSize = 12;
if (node.icon !== undefined) {
const img = new Image();
img.src = node.icon.link;
ctx.drawImage(
img,
node.x - imageSize / 2,
node.y - imageSize / 2,
imageSize,
imageSize
);
}
// Draw label
if (this.state.visibleLabels) {
const label = node.label;
const fontSize = 11 / globalScale;
ctx.font = `${fontSize}px Sans-Serif`;
const textWidth = ctx.measureText(label).width;
const bckgDimensions = [textWidth, fontSize].map(
(n) => n + fontSize * 0.2
); // some padding
const nodeHeightOffset = imageSize / 3 + bckgDimensions[1];
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(
node.x - bckgDimensions[0] / 2,
node.y - bckgDimensions[1] / 2 + nodeHeightOffset,
...bckgDimensions
);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "white";
ctx.fillText(label, node.x, node.y + nodeHeightOffset);
}
// TODO: Render label as always visible
}
private handleLinkCanvasObject(link: any, ctx: any): any {
// Links already initialized?
if (link.source.x === undefined) {
return undefined;
}
// Draw gradient link
const gradient = ctx.createLinearGradient(
link.source.x,
link.source.y,
link.target.x,
link.target.y
);
// Have reversed colors
// Color at source node referencing the target node and vice versa
gradient.addColorStop("0", link.target.type.color);
gradient.addColorStop("1", link.source.type.color);
ctx.beginPath();
ctx.moveTo(link.source.x, link.source.y);
ctx.lineTo(link.target.x, link.target.y);
ctx.strokeStyle = gradient;
ctx.stroke();
// Only render strokes on last link
// var lastLink = graph.data[Graph.GRAPH_LINKS][graph.data[Graph.GRAPH_LINKS].length - 1];
// if (link === lastLink) {
// ctx.stroke();
// }
return undefined;
}
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
private handleNodeDrag(node: Node, translate: positionTranslate) {
this.setState({
selectedNode: node,
});
const closest = this.state.graph.getClosestOtherNode(node);
// Is close enough for new link?
if (closest.distance > this.maxDistanceToConnect) {
return;
}
// Does link already exist?
if (node.neighbors.includes(closest.node)) {
return;
}
// Add link
node.connect(closest.node);
this.forceUpdate();
}
private handleNodeDragEnd(node: Node, translate: positionTranslate) {
return;
}
private handleLinkClick(link: Link) {
link.delete();
this.forceUpdate();
}

Maximilian Giller
committed
private handleEngineStop() {
// Only do something on first stop for each graph
if (this.warmupTicks <= 0) {
return;
}
this.warmupTicks = 0; // Only warm up once, so stop warming up after the first freeze
this.state.graph.storeCurrentData("Initial state", false);
this.forceUpdate();
}
render(): React.ReactNode {
// The id "ks-editor" indicates, that the javascript associated with this should automatically be executed
return (
<div id="ks-editor">
<h1>Interface</h1>
<SpaceSelect onLoadSpace={this.loadSpace} />
<div id="content">
<div id="sidepanel">
<HistoryNavigator
spaceId="space"
historyObject={this.state.graph}

Maximilian Giller
committed
onChange={this.onHistoryChange}

Maximilian Giller
committed
selectedNode={this.state.selectedNode}
allTypes={
this.state.graph ? this.state.graph.types : []
}
onChange={this.forceUpdate}
graphData={this.state.graph.data}
onNodeClick={this.handleNodeClick}
autoPauseRedraw={false}

Maximilian Giller
committed
cooldownTicks={0}
warmupTicks={this.warmupTicks}
onEngineStop={this.handleEngineStop}

Maximilian Giller
committed
nodeCanvasObject={this.handleNodeCanvasObject}
nodeCanvasObjectMode={"after"}
linkCanvasObject={this.handleLinkCanvasObject}
linkCanvasObjectMode={"replace"}
onNodeDrag={this.handleNodeDrag}
onNodeDragEnd={this.handleNodeDragEnd}
onBackgroundClick={(event) =>
this.handleBackgroundClick(
event,
this.extractPositions(event)
)
}