From 2500cabd19d34b9ee1179e76d6918ec65d8b9a37 Mon Sep 17 00:00:00 2001
From: Max <m.giller@tu-bs.de>
Date: Wed, 20 Apr 2022 23:31:57 +0200
Subject: [PATCH] Implemented properly typed graph object.

---
 src/editor/js/graph.js                        | 337 +-----------------
 src/editor/js/manageddata.js                  | 124 -------
 src/editor/js/structures/graph/graph.ts       | 148 ++++++++
 .../js/structures/graph/graphelement.ts       |  41 +++
 src/editor/js/structures/graph/link.ts        |  46 +++
 src/editor/js/structures/graph/node.ts        |  89 +++++
 src/editor/js/structures/graph/nodetype.ts    |  12 +
 .../js/structures/helper/serializableitem.ts  |  64 ++++
 .../js/structures/helper/serializedurl.ts     |  13 +
 src/editor/js/structures/manageddata.ts       | 195 ++++++++++
 src/editor/js/structures/space.ts             |  15 +
 11 files changed, 630 insertions(+), 454 deletions(-)
 delete mode 100644 src/editor/js/manageddata.js
 create mode 100644 src/editor/js/structures/graph/graph.ts
 create mode 100644 src/editor/js/structures/graph/graphelement.ts
 create mode 100644 src/editor/js/structures/graph/link.ts
 create mode 100644 src/editor/js/structures/graph/node.ts
 create mode 100644 src/editor/js/structures/graph/nodetype.ts
 create mode 100644 src/editor/js/structures/helper/serializableitem.ts
 create mode 100644 src/editor/js/structures/helper/serializedurl.ts
 create mode 100644 src/editor/js/structures/manageddata.ts
 create mode 100644 src/editor/js/structures/space.ts

diff --git a/src/editor/js/graph.js b/src/editor/js/graph.js
index a925719..68e52e8 100644
--- a/src/editor/js/graph.js
+++ b/src/editor/js/graph.js
@@ -1,21 +1,12 @@
-import ManagedData from "./manageddata";
+/**
+import ManagedData from "./structures/manageddata";
 import { PLUGIN_PATH, COLOR_PALETTE } from "../../config";
 
 const LINK_NAME_CONNECTOR = " → ";
 
-export const NODE_LABEL = "name";
-export const NODE_ID = "id";
-export const NODE_TYPE = "type";
-export const NODE_DESCRIPTION = "description";
-export const NODE_IMAGE = "image";
-export const NODE_REFERENCES = "infoLinks";
-export const NODE_VIDEO = "video";
-export const NODE_DETAIL_IMAGE = "infoImage";
 
-export const LINK_SOURCE = "source";
-export const LINK_TARGET = "target";
-export const LINK_TYPE = "type";
-export const LINK_PARTICLE_COUNT = 4;
+
+
 
 export const GRAPH_NODES = "nodes";
 export const GRAPH_LINKS = "links";
@@ -23,30 +14,16 @@ export const GRAPH_LINKS = "links";
 export const IMAGE_SIZE = 12;
 export const IMAGE_SRC = PLUGIN_PATH + "datasets/images/";
 
-export const LINK_PARAMS = [];
-export const NODE_PARAMS = [
-    NODE_ID,
-    NODE_LABEL,
-    NODE_IMAGE,
-    NODE_DESCRIPTION,
-    NODE_REFERENCES,
-    NODE_VIDEO,
-    NODE_TYPE,
-    NODE_DETAIL_IMAGE,
-];
-export const LINK_SIM_PARAMS = ["index"];
-export const NODE_SIM_PARAMS = ["index", "x", "y", "vx", "vy", "fx", "fy"]; // Based on https://github.com/d3/d3-force#simulation_nodes
 
 export const JSON_CONFIG = PLUGIN_PATH + "datasets/aud1v2.json";
 
 export const STOP_PHYSICS_DELAY = 5000; // ms
 
-export class Graph extends ManagedData {
+export class PREVIOUSGraph extends ManagedData {
     constructor(data) {
         super(Graph.addIdentifiers(data));
 
         this.calculateNodeTypes();
-        this.onChangeCallbacks = [];
         this.physicsDelay = STOP_PHYSICS_DELAY;
         this.physicsStopTimeoutId = undefined;
     }
@@ -67,86 +44,6 @@ export class Graph extends ManagedData {
         }, this.physicsDelay);
     }
 
-    triggerOnChange() {
-        this.onChangeCallbacks.forEach((fn) => fn(this.data));
-    }
-
-    onRedo() {
-        this.triggerOnChange();
-    }
-
-    onUndo() {
-        this.triggerOnChange();
-    }
-
-    storableData(data) {
-        return this.getCleanData(data, true);
-    }
-
-    /**
-     * Based on the function from the 3d-graph code from @JoschaRode
-     */
-    calculateNodeTypes() {
-        const nodeClasses = [];
-
-        this.data[GRAPH_NODES].forEach((node) =>
-            nodeClasses.push(node[NODE_TYPE])
-        );
-
-        this.nodeTypes = [...new Set(nodeClasses)];
-    }
-
-    getNodeColor(node) {
-        return this.getTypeColor(node[NODE_TYPE]);
-    }
-
-    getTypeColor(typeClass) {
-        var classIndex = this.nodeTypes.indexOf(typeClass);
-
-        if (classIndex <= -1) {
-            return "black";
-        }
-
-        return COLOR_PALETTE[classIndex % COLOR_PALETTE.length];
-    }
-
-    deleteNode(nodeId) {
-        // Delete node from nodes
-        this.data[GRAPH_NODES] = this.data[GRAPH_NODES].filter(
-            (n) => n[NODE_ID] !== nodeId
-        );
-
-        // Delete links with node
-        this.data[GRAPH_LINKS] = this.data[GRAPH_LINKS].filter(
-            (l) =>
-                l[LINK_SOURCE][NODE_ID] !== nodeId &&
-                l[LINK_TARGET][NODE_ID] !== nodeId
-        );
-
-        this.storeCurrentData("Deleted node with id [" + nodeId + "]");
-    }
-
-    deleteNodes(nodeIds) {
-        if (nodeIds === undefined || nodeIds.length <= 0) {
-            return;
-        }
-
-        try {
-            this.disableStoring();
-
-            nodeIds.forEach((id) => {
-                this.deleteNode(id);
-            });
-        } finally {
-            // Gotta make sure that storing is turned back on again
-            this.enableStoring();
-        }
-
-        this.storeCurrentData(
-            "Deleted nodes with ids [" + nodeIds.join(",") + "]"
-        );
-    }
-
     stopPhysics() {
         this.data[GRAPH_NODES].forEach((n) => {
             n.fx = n.x;
@@ -154,54 +51,13 @@ export class Graph extends ManagedData {
         });
     }
 
-    static addIdentifiers(data) {
-        data[GRAPH_NODES].forEach((n) => {
-            n.node = true;
-            n.link = false;
-        });
-        data[GRAPH_LINKS].forEach((l) => {
-            l.node = false;
-            l.link = true;
-        });
-
-        return data;
-    }
-
-    deleteLink(sourceId, targetId) {
-        // Only keep links, of one of the nodes is different
-        this.data[GRAPH_LINKS] = this.data[GRAPH_LINKS].filter(
-            (l) =>
-                l[LINK_SOURCE][NODE_ID] !== sourceId ||
-                l[LINK_TARGET][NODE_ID] !== targetId
-        );
-
-        this.storeCurrentData(
-            "Deleted link connecting [" + sourceId + "] with [" + targetId + "]"
-        );
-    }
-
-    isLinkOnNode(link, node) {
-        if (link === undefined || node === undefined) {
-            return false;
-        }
-
-        if (link.link !== true || node.node !== true) {
-            return false;
-        }
-
-        return (
-            link[LINK_SOURCE][NODE_ID] === node[NODE_ID] ||
-            link[LINK_TARGET][NODE_ID] === node[NODE_ID]
-        );
-    }
-
     existsLink(sourceId, targetId) {
         const links = this.data[GRAPH_LINKS];
 
         for (var i = 0; i < links.length; i++) {
             var link = links[i];
             if (
-                link[LINK_SOURCE][NODE_ID] === sourceId &&
+                linkdeleteLink[LINK_SOURCE][NODE_ID] === sourceId &&
                 link[LINK_TARGET][NODE_ID] === targetId
             ) {
                 return true;
@@ -211,108 +67,6 @@ export class Graph extends ManagedData {
         return false;
     }
 
-    changeDetails(selectionDetails) {
-        if (selectionDetails.node === true) {
-            this.changeNodeDetails(selectionDetails[NODE_ID], selectionDetails);
-        } else if (selectionDetails.link === true) {
-            this.changeLinkDetails(
-                selectionDetails[LINK_SOURCE][NODE_ID],
-                selectionDetails[LINK_TARGET][NODE_ID],
-                selectionDetails
-            );
-        }
-    }
-
-    changeNodeDetails(nodeId, newDetails) {
-        var nodes = this.data[GRAPH_NODES];
-        for (var i = 0; i < nodes.length; i++) {
-            // Is relevant node?
-            if (nodes[i][NODE_ID] !== nodeId) {
-                continue; // No
-            }
-
-            // Change details
-            nodes[i] = Object.assign(nodes[i], newDetails);
-
-            // All done
-            this.storeCurrentData("Changed node details");
-            return;
-        }
-    }
-
-    changeLinkDetails(sourceId, targetId, newDetails) {
-        var links = this.data[GRAPH_LINKS];
-        for (var i = 0; i < links.length; i++) {
-            // Is relevant link?
-            if (
-                links[i][LINK_SOURCE][NODE_ID] !== sourceId ||
-                links[i][LINK_TARGET][NODE_ID] !== targetId
-            ) {
-                continue; // No
-            }
-
-            // Change details
-            links[i] = Object.assign(links[i], newDetails);
-
-            // All done
-            this.storeCurrentData("Changed link details");
-            return;
-        }
-    }
-
-    connectNodes(sourceId, targetIds) {
-        targetIds.forEach((targetId) => {
-            if (
-                this.existsLink(sourceId, targetId) ||
-                this.existsLink(targetId, sourceId)
-            ) {
-                return;
-            }
-
-            this.addLink(sourceId, targetId);
-        });
-    }
-
-    getCleanData(data = undefined, simulationParameters = false) {
-        if (data === undefined) {
-            data = this.data;
-        }
-
-        var cleanData = {};
-        cleanData[GRAPH_LINKS] = [];
-        cleanData[GRAPH_NODES] = [];
-
-        data[GRAPH_LINKS].forEach((link) =>
-            cleanData[GRAPH_LINKS].push(
-                this.getCleanLink(link, simulationParameters)
-            )
-        );
-
-        data[GRAPH_NODES].forEach((node) =>
-            cleanData[GRAPH_NODES].push(
-                this.getCleanNode(node, simulationParameters)
-            )
-        );
-
-        return cleanData;
-    }
-
-    getCleanNode(node, simulationParameters) {
-        var cleanNode = {};
-
-        NODE_PARAMS.forEach((param) => {
-            cleanNode[param] = node[param];
-        });
-
-        if (simulationParameters) {
-            NODE_SIM_PARAMS.forEach((param) => {
-                cleanNode[param] = node[param];
-            });
-        }
-
-        return cleanNode;
-    }
-
     getCleanLink(link, simulationParameters) {
         var cleanLink = {};
 
@@ -329,17 +83,6 @@ export class Graph extends ManagedData {
             cleanLink[LINK_TARGET] = link[LINK_TARGET];
         }
 
-        // Other parameters
-        LINK_PARAMS.forEach((param) => {
-            cleanLink[param] = link[param];
-        });
-
-        if (simulationParameters) {
-            LINK_SIM_PARAMS.forEach((param) => {
-                cleanLink[param] = link[param];
-            });
-        }
-
         return cleanLink;
     }
 
@@ -377,73 +120,6 @@ export class Graph extends ManagedData {
         return result;
     }
 
-    addLink(sourceId, targetId, linkDetails = {}) {
-        // Copy params
-        var newLink = linkDetails;
-
-        // Make sure the IDs exist
-        if (
-            sourceId === undefined ||
-            targetId === undefined ||
-            this.existsNodeId(sourceId) === false ||
-            this.existsNodeId(targetId) === false
-        ) {
-            return;
-        }
-
-        // Make sure the link is unique
-        if (this.existsLink(sourceId, targetId)) {
-            return;
-        }
-
-        newLink[LINK_SOURCE] = sourceId;
-        newLink[LINK_TARGET] = targetId;
-
-        // Basic node properties
-        newLink.link = true;
-        newLink.node = false;
-
-        // Add node
-        this.data[GRAPH_LINKS].push(newLink);
-        this.triggerOnChange();
-
-        this.storeCurrentData(
-            "Added custom link connecting [" +
-                sourceId +
-                "] with [" +
-                targetId +
-                "]"
-        );
-
-        return newLink;
-    }
-
-    addNode(nodeDetails) {
-        // Copy params
-        var newNode = nodeDetails;
-
-        // Make sure the ID is set and unique
-        if (newNode[NODE_ID] === undefined) {
-            newNode[NODE_ID] = this.getUnusedNodeId();
-        } else if (this.existsNodeId(newNode[NODE_ID])) {
-            return;
-        }
-
-        // Basic node properties
-        newNode.node = true;
-        newNode.link = false;
-
-        // Add node
-        this.data[GRAPH_NODES].push(newNode);
-        this.triggerOnChange();
-
-        this.storeCurrentData(
-            "Added custom node with id [" + newNode[NODE_ID] + "]"
-        );
-
-        return newNode;
-    }
-
     static toStr(item) {
         if (item === undefined) {
             return "UNDEFINED";
@@ -462,3 +138,4 @@ export class Graph extends ManagedData {
         }
     }
 }
+*/
\ No newline at end of file
diff --git a/src/editor/js/manageddata.js b/src/editor/js/manageddata.js
deleted file mode 100644
index d5c01d4..0000000
--- a/src/editor/js/manageddata.js
+++ /dev/null
@@ -1,124 +0,0 @@
-import jQuery from "jquery";
-
-const SAVE_BUTTON_ID = "div#ks-editor #toolbar-save";
-
-export default class ManagedData {
-    constructor(data) {
-        this.data = data;
-        this.history = []; // Newest state is always at 0
-        this.historyPosition = 0;
-        this.savedHistoryId = 0;
-        this.storingEnabled = true;
-
-        this.storeCurrentData("Initial state", false);
-    }
-
-    updateUnsavedChangesHandler() {
-        if (this.hasUnsavedChanges()) {
-            jQuery(SAVE_BUTTON_ID).removeClass("hidden");
-            window.addEventListener("beforeunload", this.handleBeforeUnload);
-        } else {
-            jQuery(SAVE_BUTTON_ID).addClass("hidden");
-            window.removeEventListener("beforeunload", this.handleBeforeUnload);
-        }
-    }
-
-    handleBeforeUnload(e) {
-        var confirmationMessage =
-            "If you leave before saving, unsaved changes will be lost.";
-
-        (e || window.event).returnValue = confirmationMessage; //Gecko + IE
-        return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc.
-    }
-
-    hasUnsavedChanges() {
-        return this.history[this.historyPosition].id !== this.savedHistoryId;
-    }
-
-    saveChanges() {
-        this.savedHistoryId = this.history[this.historyPosition].id;
-        this.updateUnsavedChangesHandler();
-    }
-
-    disableStoring() {
-        this.storingEnabled = false;
-    }
-
-    enableStoring() {
-        this.storingEnabled = true;
-    }
-
-    onUndo() {}
-    onRedo() {}
-
-    undo() {
-        if (this.step(1)) {
-            this.updateUnsavedChangesHandler();
-            this.onUndo();
-            return true;
-        } else {
-            return false;
-        }
-    }
-
-    redo() {
-        if (this.step(-1)) {
-            this.updateUnsavedChangesHandler();
-            this.onRedo();
-            return true;
-        } else {
-            return false;
-        }
-    }
-
-    step(direction = 1) {
-        var newHistoryPosition = this.historyPosition + Math.sign(direction);
-
-        if (
-            newHistoryPosition >= this.history.length ||
-            newHistoryPosition < 0
-        ) {
-            return false;
-        }
-
-        this.historyPosition = newHistoryPosition;
-        this.data = JSON.parse(this.history[this.historyPosition].data);
-
-        return true;
-    }
-
-    storableData(data) {
-        return data;
-    }
-
-    storeCurrentData(description, relevantChanges = true) {
-        if (this.storingEnabled === false) {
-            return;
-        }
-
-        var formattedData = this.storableData(this.data);
-
-        var nextId = 0;
-        if (this.history.length > 0) {
-            nextId = this.history[0].id;
-
-            // Keep same as previous id, if nothing relevant changed
-            // Otherwise, increase by one
-            if (relevantChanges) {
-                nextId++;
-            }
-        }
-
-        // Forget about the currently stored potential future
-        this.history.splice(0, this.historyPosition);
-        this.historyPosition = 0;
-
-        this.history.unshift({
-            description: description,
-            data: JSON.stringify(formattedData), // Creating a deep copy
-            id: nextId,
-        });
-
-        this.updateUnsavedChangesHandler();
-    }
-}
diff --git a/src/editor/js/structures/graph/graph.ts b/src/editor/js/structures/graph/graph.ts
new file mode 100644
index 0000000..2b7fc38
--- /dev/null
+++ b/src/editor/js/structures/graph/graph.ts
@@ -0,0 +1,148 @@
+import ManagedData from "../manageddata";
+import {Link} from "./link";
+import {NodeType} from "./nodetype";
+import {Node} from "./node";
+import {GLOBAL_PARAMS} from "../helper/serializableitem";
+
+const GRAPH_PARAMS = ["nodes", "links", "types", ...GLOBAL_PARAMS];
+
+export class Graph extends ManagedData {
+    public nodes: Node[];
+    public links: Link[];
+    public types: NodeType[];
+
+    // Callbacks
+    public onChangeCallbacks: { (data: any): void; } [];
+
+    constructor(data: any) {
+        super(data);
+        this.onChangeCallbacks = [];
+
+        // TODO: Parse all node types
+    }
+
+    /**
+     * Calls all registered callbacks for the onChange event.
+     * @private
+     */
+    private triggerOnChange() {
+        this.onChangeCallbacks.forEach((fn) => fn(this.data));
+    }
+
+    /**
+     * Triggers change event on data-redo.
+     */
+    protected onRedo() {
+        // TODO: Actually read new data state
+        this.triggerOnChange();
+    }
+
+    /**
+     * Triggers change event on data-undo.
+     */
+    protected onUndo() {
+        // TODO: Actually read new data state
+        this.triggerOnChange();
+    }
+
+    protected storableData(data: any): any {
+        // TODO: Ideally use data parameter
+        return {
+            ...this.serialize()
+        };
+    }
+
+    serialize(): any {
+        return this.serializeProperties(GRAPH_PARAMS);
+    }
+
+    /**
+     * Adds a pre-created node to the graph.
+     * @param node New node object.
+     * @returns True, if successful.
+     */
+    public addNode(node: Node) {
+        if (this.nodes.includes(node)) {
+            return true;   // Already exists in graph.
+        }
+
+        // TODO: Maybe set node id
+        this.nodes.push(node);
+
+        this.triggerOnChange();
+        // TODO: Use toString implementation of node
+        this.storeCurrentData("Added node [" + node + "]");
+
+        return true;
+    }
+
+    /**
+     * Deletes a node from the graph.
+     * @param node Node object to remove.
+     * @returns True, if successful.
+     */
+    public deleteNode(node: Node): boolean {
+        if (!this.nodes.includes(node)) {
+            return true;   // Doesn't even exist in graph to begin with.
+        }
+
+        this.nodes.filter((n: Node) => n !== node);
+
+        try {
+            // No save points should be created when deleting the links
+            this.disableStoring();
+
+            // Delete all the links that contain this node
+            node.links().forEach((l) => {
+                l.delete();
+            });
+        } finally {
+            this.enableStoring();
+        }
+
+        this.triggerOnChange();
+        // TODO: Use toString implementation of node
+        this.storeCurrentData("Deleted node [" + node + "] and all connected links");
+
+        return true;
+    }
+
+    /**
+     * Adds a pre-created link to the graph.
+     * @param link New link object.
+     * @returns True, if successful.
+     */
+    public addLink(link: Link): boolean {
+        if (this.links.includes(link)) {
+            return true;   // Already exists in graph.
+        }
+
+        // TODO: Maybe set link id
+        this.links.push(link);
+
+        this.triggerOnChange();
+        // TODO: Use toString implementation of link
+        this.storeCurrentData("Added link [" + link + "]");
+
+        return true;
+    }
+
+    /**
+     * Deletes a link from the graph.
+     * @param link Link object to remove.
+     * @returns True, if successful.
+     */
+    public deleteLink(link: Link): boolean {
+        if (!this.links.includes(link)) {
+            return true;   // Doesn't even exist in graph to begin with.
+        }
+
+        this.links.filter((l: Link) => l !== link);
+
+        this.triggerOnChange();
+        // TODO: Use toString implementation of link
+        this.storeCurrentData("Deleted link [" + link + "]");
+
+        return true;
+    }
+}
\ No newline at end of file
diff --git a/src/editor/js/structures/graph/graphelement.ts b/src/editor/js/structures/graph/graphelement.ts
new file mode 100644
index 0000000..4072b75
--- /dev/null
+++ b/src/editor/js/structures/graph/graphelement.ts
@@ -0,0 +1,41 @@
+import {Graph} from "./graph";
+import {SerializableItem} from "../helper/serializableitem";
+
+export class GraphElement extends SerializableItem {
+    protected isNode: boolean;
+    protected isLink: boolean;
+
+    protected graph: Graph;
+
+    constructor(graph: Graph) {
+        super();
+        this.graph = graph;
+        this.isNode = false;
+        this.isLink = false;
+    }
+
+    /**
+     * Removes element from its parent graph.
+     * @returns True, if successful.
+     */
+    public delete(): boolean {
+        throw new Error("Function \"delete()\" has not been implemented.");
+    }
+
+    /**
+     * Adds the element to the given graph.
+     * @param graph Graph to add element to.
+     * @returns True, if successful.
+     */
+    public add(graph: Graph = this.graph): boolean {
+        throw new Error("Function \"add(graph)\" has not been implemented.");
+    }
+
+    /**
+     * Needs to be implemented to create a filtered version for storing in the data history.
+     * @returns Filtered object.
+     */
+    public getCleanInstance(): any {
+        throw new Error("Function \"getCleanInstance()\" has not been implemented.");
+    }
+}
\ No newline at end of file
diff --git a/src/editor/js/structures/graph/link.ts b/src/editor/js/structures/graph/link.ts
new file mode 100644
index 0000000..bdfd33e
--- /dev/null
+++ b/src/editor/js/structures/graph/link.ts
@@ -0,0 +1,46 @@
+import {GraphElement} from "./graphelement";
+import {Graph} from "./graph";
+import {Node} from "./node";
+import {GLOBAL_PARAMS} from "../helper/serializableitem";
+
+const LINK_PARAMS = ["source", "target", ...GLOBAL_PARAMS];
+const LINK_SIM_PARAMS = ["index"];
+
+export class Link extends GraphElement {
+    public source: Node;
+    public target: Node;
+
+    constructor(graph: Graph) {
+        super(graph);
+        this.isLink = true;
+    }
+
+    public delete() {
+        return this.graph.deleteLink(this);
+    }
+
+    public add(graph: Graph = this.graph) {
+        this.graph = graph;
+        return this.graph.addLink(this);
+    }
+
+    /**
+     * Determines if the given node is part of the link structure.
+     * @param node Node to check for.
+     * @returns True, if node is either source or target node of link.
+     */
+    public contains(node: Node): boolean {
+        return this.source === node || this.target === node;
+    }
+
+    public serialize(): any {
+        return this.serializeProperties(LINK_PARAMS);
+    }
+
+    public getCleanInstance(): any {
+        return {
+            ...this.serialize(),
+            ...this.serializeProperties(LINK_SIM_PARAMS)
+        };
+    }
+}
\ No newline at end of file
diff --git a/src/editor/js/structures/graph/node.ts b/src/editor/js/structures/graph/node.ts
new file mode 100644
index 0000000..5976137
--- /dev/null
+++ b/src/editor/js/structures/graph/node.ts
@@ -0,0 +1,89 @@
+import {Graph} from "./graph";
+import {GraphElement} from "./graphelement"
+import {NodeType} from "./nodetype";
+import {SerializedURL} from "../helper/serializedurl";
+import {Link} from "./link";
+import {GLOBAL_PARAMS} from "../helper/serializableitem";
+
+const NODE_PARAMS = [
+    "label",
+    "icon",
+    "description",
+    "references",
+    "video",
+    "type",
+    "banner",
+    ...GLOBAL_PARAMS
+];
+const NODE_SIM_PARAMS = ["index", "x", "y", "vx", "vy", "fx", "fy"]; // Based on https://github.com/d3/d3-force#simulation_nodes
+
+export class Node extends GraphElement {
+    public label: string;
+    public description: string;
+    public type: NodeType;
+    public icon: SerializedURL;
+    public banner: SerializedURL;
+    public video: SerializedURL;
+    public references: SerializedURL[];
+
+    constructor(graph: Graph) {
+        super(graph);
+        this.isNode = true;
+    }
+
+    public delete() {
+        return this.graph.deleteNode(this);
+    }
+
+    public add(graph: Graph = this.graph) {
+        this.graph = graph;
+        return this.graph.addNode(this);
+    }
+
+    /**
+     * Calculates a list of all connected links to the current node.
+     * @returns Array containing all connected links.
+     */
+    public links(): Link[] {
+        const links: Link[] = [];
+
+        this.graph.links.forEach((link) => {
+            if (link.contains(this)) {
+                links.push(link);
+            }
+        });
+
+        return links;
+    }
+
+    /**
+     * Connects a given node to itself. Only works if they are in the same graph.
+     * @param node Other node to connect.
+     * @returns The created link, if successful, otherwise undefined.
+     */
+    public connect(node: Node): Link {
+        if (this.graph !== node.graph) {
+            return undefined;
+        }
+
+        const link = new Link(this.graph);
+
+        link.source = this;
+        link.target = node;
+
+        if (link.add()) {
+            return link;
+        }
+    }
+
+    public serialize(): any {
+        return this.serializeProperties(NODE_PARAMS);
+    }
+
+    public getCleanInstance(): any {
+        return {
+            ...this.serialize(),
+            ...this.serializeProperties(NODE_SIM_PARAMS)
+        };
+    }
+}
\ No newline at end of file
diff --git a/src/editor/js/structures/graph/nodetype.ts b/src/editor/js/structures/graph/nodetype.ts
new file mode 100644
index 0000000..c7a1069
--- /dev/null
+++ b/src/editor/js/structures/graph/nodetype.ts
@@ -0,0 +1,12 @@
+import {GLOBAL_PARAMS, SerializableItem} from "../helper/serializableitem";
+
+const NODE_TYPE_PARAMS = ["name", "color", ...GLOBAL_PARAMS];
+
+export class NodeType extends SerializableItem {
+    public name: string;
+    public color: string;
+
+    serialize(): any {
+        return this.serializeProperties(NODE_TYPE_PARAMS);
+    }
+}
\ No newline at end of file
diff --git a/src/editor/js/structures/helper/serializableitem.ts b/src/editor/js/structures/helper/serializableitem.ts
new file mode 100644
index 0000000..672c164
--- /dev/null
+++ b/src/editor/js/structures/helper/serializableitem.ts
@@ -0,0 +1,64 @@
+/**
+ * Provides the basic interface for unique, serializable objects.
+ */
+import {array} from "prop-types";
+
+export const GLOBAL_PARAMS = ["id"];
+
+export class SerializableItem {
+    public id: string;  // Serialized objects need to be unique.
+
+    /**
+     * Returns the current object in its serialized form.
+     * @returns The serialized object.
+     */
+    public serialize(): any {
+        throw new Error("Method 'serialize()' must be implemented.");
+    }
+
+    /**
+     * Creates the current object based on raw, serialized data.
+     * @param raw The serialized data.
+     */
+    public parse(raw: any) {
+        throw new Error("Method 'parse()' must be implemented.");
+    }
+
+    /**
+     * A generic way to create a new object with all the desired parameters of the current one.
+     * @param params List of parameters to include in the new object.
+     * @protected
+     * @returns New object containing all the desired properties.
+     */
+    protected serializeProperties(params: string[]): any {
+        const serialized: any = {};
+
+        params.forEach((param) => {
+            serialized[param] = this.serializeItem((this as any)[param]);
+        });
+
+        return serialized;
+    }
+
+    /**
+     * Recursively serializes an object. Handles serializable items and lists properly.
+     * @param value Object to be serialized.
+     * @private
+     * @returns Serialized item.
+     */
+    private serializeItem(value: any): any {
+        if (value instanceof SerializableItem) {
+            // If is also serializable, use the serialized form
+            return value.serialize();
+        } else if (value instanceof array) {
+            // If is some kind of list, convert the objects in the list recursively
+            const serializedList: any = [];
+            (value as []).forEach((item) => {
+                serializedList.push(this.serializeItem(item));
+            })
+            return serializedList;
+        } else {
+            return value;
+        }
+    }
+}
diff --git a/src/editor/js/structures/helper/serializedurl.ts b/src/editor/js/structures/helper/serializedurl.ts
new file mode 100644
index 0000000..7a56676
--- /dev/null
+++ b/src/editor/js/structures/helper/serializedurl.ts
@@ -0,0 +1,13 @@
+import {GLOBAL_PARAMS, SerializableItem} from "./serializableitem";
+
+const URL_PARAMS = ["link", ...GLOBAL_PARAMS];
+
+export class SerializedURL extends SerializableItem {
+    public link: string;    // The full url
+
+    // TODO: URL validator
+
+    serialize(): any {
+        return this.serializeProperties(URL_PARAMS);
+    }
+}
\ No newline at end of file
diff --git a/src/editor/js/structures/manageddata.ts b/src/editor/js/structures/manageddata.ts
new file mode 100644
index 0000000..61d4c18
--- /dev/null
+++ b/src/editor/js/structures/manageddata.ts
@@ -0,0 +1,195 @@
+import {SerializableItem} from "./helper/serializableitem";
+import jQuery from "jquery";
+
+const SAVE_BUTTON_ID = "div#ks-editor #toolbar-save";
+
+/**
+ * Allows objects to have undo/redo functionality in their data and custom save points.
+ */
+export default class ManagedData extends SerializableItem {
+    public data: any;   // The data to be stored in a history.
+    public history: any[];  // All save points of the data.
+    public historyPosition: number; // Currently selected save point in history. Latest always at index 0.
+    private savedHistoryId: number;  // Id of save point that is considered saved.
+    private storingEnabled: boolean; // To internally disable saving of objects on save call.
+
+    /**
+     * Sets initial states.
+     * @param data Initial state of data to be stored.
+     */
+    constructor(data: any) {
+        super();
+        this.data = data;
+        this.history = []; // Newest state is always at 0
+        this.historyPosition = 0;
+        this.savedHistoryId = 0;
+        this.storingEnabled = true;
+
+        this.storeCurrentData("Initial state", false);
+    }
+
+    /**
+     * If the data has unsaved changes, this will subscribe to the tab-closing event to warn about losing unsaved changes before closing.
+     * @private
+     */
+    private updateUnsavedChangesHandler() {
+        if (this.hasUnsavedChanges()) {
+            jQuery(SAVE_BUTTON_ID).removeClass("hidden");
+            window.addEventListener("beforeunload", this.handleBeforeUnload);
+        } else {
+            jQuery(SAVE_BUTTON_ID).addClass("hidden");
+            window.removeEventListener("beforeunload", this.handleBeforeUnload);
+        }
+    }
+
+    /**
+     * Called on the tab-closing event to trigger a warning, to avoid losing unsaved changes.
+     * @param e Event.
+     * @private
+     */
+    private handleBeforeUnload(e: any) {
+        const confirmationMessage =
+            "If you leave before saving, unsaved changes will be lost.";
+
+        (e || window.event).returnValue = confirmationMessage; //Gecko + IE
+        return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc.
+    }
+
+    /**
+     * Returns true, if data has unsaved changes.
+     */
+    public hasUnsavedChanges(): boolean {
+        return this.history[this.historyPosition].id !== this.savedHistoryId;
+    }
+
+    /**
+     * Internally marks the current save point as saved.
+     */
+    public markChangesAsSaved() {
+        this.savedHistoryId = this.history[this.historyPosition].id;
+        this.updateUnsavedChangesHandler();
+    }
+
+    /**
+     * Setter to disable storing save points.
+     */
+    public disableStoring() {
+        this.storingEnabled = false;
+    }
+
+    /**
+     * Setter to enable storing save points.
+     */
+    public enableStoring() {
+        this.storingEnabled = true;
+    }
+
+    /**
+     * Event triggered after undo.
+     */
+    protected onUndo() {
+        // No base implementation.
+    }
+
+    /**
+     * Event triggered after redo.
+     */
+    protected onRedo() {
+        // No base implementation.
+    }
+
+    /**
+     * Go to one step back in the stored history, if available.
+     * @returns True, if successful.
+     */
+    public undo(): boolean {
+        if (this.step(1)) {
+            this.updateUnsavedChangesHandler();
+            this.onUndo();
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Go one step forward in the stored history, if available.
+     * @returns True, if successful.
+     */
+    public redo(): boolean {
+        if (this.step(-1)) {
+            this.updateUnsavedChangesHandler();
+            this.onRedo();
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Moves the history pointer to the desired position and adjusts the data object.
+     * @param direction How many steps to take in the history. Positive for going back in time, negative for going forward.
+     * @returns True, if successful.
+     * @private
+     */
+    private step(direction = 1): boolean {
+        const newHistoryPosition = this.historyPosition + Math.sign(direction);
+
+        if (
+            newHistoryPosition >= this.history.length ||
+            newHistoryPosition < 0
+        ) {
+            return false;
+        }
+
+        this.historyPosition = newHistoryPosition;
+        this.data = JSON.parse(this.history[this.historyPosition].data);
+
+        return true;
+    }
+
+    /**
+     * Formats the data to the desired stored format.
+     * @param data The raw data.
+     * @returns The formatted, cleaned up data to be stored.
+     */
+    protected storableData(data: any): any {
+        return data;
+    }
+
+    /**
+     * Creates a save point.
+     * @param description Description of the current save point. Could describe the difference to the previous save point.
+     * @param relevantChanges Indicates major or minor changes. Major changes get a new id to indicate an actual changed state. Should usually be true.
+     */
+    public storeCurrentData(description: string, relevantChanges = true) {
+        if (this.storingEnabled === false) {
+            return;
+        }
+
+        const formattedData = this.storableData(this.data);
+
+        let nextId = 0;
+        if (this.history.length > 0) {
+            nextId = this.history[0].id;
+
+            // Keep same as previous id, if nothing relevant changed
+            // Otherwise, increase by one
+            if (relevantChanges) {
+                nextId++;
+            }
+        }
+
+        // Forget about the currently stored potential future
+        this.history.splice(0, this.historyPosition);
+        this.historyPosition = 0;
+
+        this.history.unshift({
+            description: description,
+            data: JSON.stringify(formattedData), // Creating a deep copy
+            id: nextId,
+        });
+
+        this.updateUnsavedChangesHandler();
+    }
+}
diff --git a/src/editor/js/structures/space.ts b/src/editor/js/structures/space.ts
new file mode 100644
index 0000000..470e13d
--- /dev/null
+++ b/src/editor/js/structures/space.ts
@@ -0,0 +1,15 @@
+import {GLOBAL_PARAMS, SerializableItem} from "./helper/serializableitem";
+import {Graph} from "./graph/graph";
+
+const SPACE_PARAMS = ["name", "description", "graph", ...GLOBAL_PARAMS];
+
+export class Space extends SerializableItem {
+    public name: string;
+    public description: string;
+
+    public graph: Graph;
+
+    serialize(): any {
+        return this.serializeProperties(SPACE_PARAMS);
+    }
+}
\ No newline at end of file
-- 
GitLab