Compare commits

...

4 Commits

  1. 2
      .gitignore
  2. 23
      README.md
  3. 29
      frontend/assets/css/src/style.scss
  4. 2
      frontend/assets/css/style.min.css
  5. 199
      frontend/assets/js/canvas.js
  6. 12
      frontend/index.html
  7. 30
      install.sh
  8. 72
      main.py

2
.gitignore vendored

@ -0,0 +1,2 @@
venv/
graph.json

@ -0,0 +1,23 @@
# Assignment
## Installationsanleitung
Voraussetzung ist Python in einer Version >=3.3. Normalerweise reicht es einfach folgenden Befehl auszuführen:
```$ ./install.sh```
Sollte es Probleme mit der Python-Instanz geben kann diese manuell in der 4. Zeile von `install.sh` geändert werden.
Gestartet wird der Server per:
```$ venv/bin/python main.py```
Das startet ein Webserver der auf Port 5000 lauscht.
## Kommentar
Da mir gesagt wurde overachieving ist u.U. auch nicht gern gesehen (getreu dem Motto: "Zu früh kommen ist auch unpünktlich.") habe ich wirklich nur die Anforderungen implementiert.
Um dennoch zu demonstrieren, dass ich mir Gedanken um die simple Erweiterung von Funktionalitäten gemacht habe, habe ich eine weitere CRUD Funktion auskommentiert hinzugefügt. Diese ist im letzten Commit zu begutachten.
Ach ja, und da zu der layout Methode nicht viel gesagt wurde hab ich mich mit force-directed-graphs etwas ausgetobt. Zu sehen per Klick auf Automatic Layout rechts unten.

@ -5,16 +5,14 @@ $main-color: rgb(240, 141, 25);
margin:0;
padding: 0;
box-sizing: border-box;
font-family: "Roboto-Regular";
}
body{
font-family: Roboto;
}
main{
position: relative;
height: 100vh;
#infobar{
#errorbar{
position: absolute;
top:0;
width: 100%;
@ -49,7 +47,9 @@ main{
position: absolute;
border: none;
padding: 0.6em 1em;
width: 50px;
width: 200px;
font-size: 1.2em;
text-align: center;
transform: translateX(-50%) translateY(-50%);
}
}
@ -106,5 +106,24 @@ main{
left: auto;
bottom: 1em;
right: 1em;
left: 1em;
display: flex;
justify-content: flex-end;
#infobar{
position: absolute;
bottom:0;
left: 0;
width: 50%;
padding: 0.4em;
color: rgba(0, 0, 0, 0.8);
font-weight: bold;
font-size: 1.8em;
z-index: 1;
opacity: 0;
transition: opacity 1s;
&.active{
opacity: 1;
}
}
}
}

@ -1 +1 @@
*{margin:0;padding:0;box-sizing:border-box}body{font-family:Roboto}main{position:relative;height:100vh}main #infobar{position:absolute;top:0;width:100%;padding:0.4em;background-color:rgba(232,9,9,0.4);border:4px solid rgba(232,9,9,0.8);text-align:center;font-weight:bold;font-size:1.8em;z-index:1;transform:translateY(-100%);transition:transform 1s}main #infobar.active{transform:translateY(0%)}main .canvas{background-color:lightgrey;position:relative;height:100%}main .canvas .background{position:absolute;top:0;left:0;background-color:rgba(0,0,0,0.5);width:100%;height:100%}main .canvas input{position:absolute;border:none;padding:0.6em 1em;width:50px;transform:translateX(-50%) translateY(-50%)}main nav,main footer{display:flex;position:absolute;top:1em;left:1em;opacity:0.9}main nav button,main footer button{display:block;background-color:#939292;border:none;border-radius:1em;padding:1em 1em;min-width:8em;margin:0.4em;color:black;border:solid 2px #131313;box-shadow:2px 2px 8px rgba(0,0,0,0.5)}main nav button:focus,main footer button:focus{outline:none}main nav button::-moz-focus-inner,main footer button::-moz-focus-inner{border:0}main nav button:hover,main footer button:hover{background-color:#acacac;border-color:#000;cursor:pointer}main nav button.active,main footer button.active{background-color:#f08d19}main nav button h1,main footer button h1{text-align:center;font-size:1.4em;font-weight:normal;white-space:nowrap}main nav button img,main footer button img{display:block;margin:0 auto 0.5em auto;width:2em}main footer{top:auto;left:auto;bottom:1em;right:1em}
*{margin:0;padding:0;box-sizing:border-box;font-family:"Roboto-Regular"}main{position:relative;height:100vh}main #errorbar{position:absolute;top:0;width:100%;padding:0.4em;background-color:rgba(232,9,9,0.4);border:4px solid rgba(232,9,9,0.8);text-align:center;font-weight:bold;font-size:1.8em;z-index:1;transform:translateY(-100%);transition:transform 1s}main #errorbar.active{transform:translateY(0%)}main .canvas{background-color:lightgrey;position:relative;height:100%}main .canvas .background{position:absolute;top:0;left:0;background-color:rgba(0,0,0,0.5);width:100%;height:100%}main .canvas input{position:absolute;border:none;padding:0.6em 1em;width:200px;font-size:1.2em;text-align:center;transform:translateX(-50%) translateY(-50%)}main nav,main footer{display:flex;position:absolute;top:1em;left:1em;opacity:0.9}main nav button,main footer button{display:block;background-color:#939292;border:none;border-radius:1em;padding:1em 1em;min-width:8em;margin:0.4em;color:black;border:solid 2px #131313;box-shadow:2px 2px 8px rgba(0,0,0,0.5)}main nav button:focus,main footer button:focus{outline:none}main nav button::-moz-focus-inner,main footer button::-moz-focus-inner{border:0}main nav button:hover,main footer button:hover{background-color:#acacac;border-color:#000;cursor:pointer}main nav button.active,main footer button.active{background-color:#f08d19}main nav button h1,main footer button h1{text-align:center;font-size:1.4em;font-weight:normal;white-space:nowrap}main nav button img,main footer button img{display:block;margin:0 auto 0.5em auto;width:2em}main footer{top:auto;left:auto;bottom:1em;right:1em;left:1em;display:flex;justify-content:flex-end}main footer #infobar{position:absolute;bottom:0;left:0;width:50%;padding:0.4em;color:rgba(0,0,0,0.8);font-weight:bold;font-size:1.8em;z-index:1;opacity:0;transition:opacity 1s}main footer #infobar.active{opacity:1}

@ -28,32 +28,32 @@ const node_layout = {
shadowOpacity: 0.3,
};
const text_layout = {
fontSize: 22,
fontFamily: 'Roboto, Arial, sans-serif',
fill: 'black',
verticalAlign: 'middle',
align: 'center'
};
const node_text_layout = {
...text_layout,
x: 1 - node_size,
y: 2 - node_size,
text: name,
fontSize: 22,
width: 2 * node_size - 1,
height: 2 * node_size - 1,
fontFamily: 'Roboto',
fill: 'black',
verticalAlign: 'middle',
align: 'center'
};
const edge_layout = {
stroke: 'black',
strokeWidth: 3,
}
};
const edge_text_layout = {
fontSize: 22,
...text_layout,
width: 2 * node_size,
height: 2 * node_size,
fontFamily: 'Roboto',
fill: 'black',
verticalAlign: 'middle',
align: 'center'
};
@ -103,10 +103,10 @@ function initialization() {
for (var i = 0; i < 1000; i++) {
force_directed_graph();
}
edge_layer.show();
node_layer.draw();
}
initialization();
//-----------------------------------------------------------------------------
@ -118,8 +118,8 @@ function sort(array) {
})
}
function show_error(msg) {
let infobar = document.querySelector("#infobar");
function show_info(msg, elem="#errorbar") {
let infobar = document.querySelector(elem);
infobar.innerHTML = msg;
infobar.classList.add("active");
window.setTimeout(function() {
@ -127,7 +127,6 @@ function show_error(msg) {
}, 4000);
}
//-----------------------------------------------------------------------------
//---------------------------draw methods--------------------------------------
//-----------------------------------------------------------------------------
@ -135,6 +134,7 @@ function draw_new_node(name, x, y) {
let group = new Konva.Group({
x: x,
y: y,
dragDistance: 5,
draggable: true,
dragBoundFunc: function(pos) {
return {
@ -152,6 +152,7 @@ function draw_new_node(name, x, y) {
group.add(box);
group.add(simpleText);
group.on("click", on_node_click);
group.on('tap', on_node_click);
group.on('dragstart', function() {
group.attrs.dragging = true;
});
@ -171,7 +172,6 @@ function draw_new_node(name, x, y) {
node_layer.draw();
}
function draw_new_edge(edge) {
let start_node = stage.findOne('#' + edge["start"]);
let end_node = stage.findOne('#' + edge["end"]);
@ -185,7 +185,7 @@ function draw_new_edge(edge) {
let line = new Konva.Line(edge_layout);
let simpleText = new Konva.Text({
...edge_text_layout,
text: weight
text: ""+weight
});
graph_copy[edge["start"]].push(edge["end"]);
@ -236,7 +236,7 @@ function draw_selected_nodes() {
reset_node_style();
reset_edge_style();
for (let id in selected) {
selected[id].children[0].strokeWidth(node_layout.strokeWidth + 1);
selected[id].children[0].strokeWidth(node_layout.strokeWidth+1);
selected[id].children[0].stroke("rgb(240, 141, 25)");
}
node_layer.draw();
@ -262,6 +262,8 @@ function draw_path(path) {
path_elements[id].children[0].strokeWidth(node_layout.strokeWidth + 1);
path_elements[id].children[0].stroke("rgb(240, 141, 25)");
}
node_layer.draw();
edge_layer.draw();
}
function on_node_click(e) {
@ -279,34 +281,73 @@ function on_node_click(e) {
draw_selected_nodes();
};
function create_input(x, y) {
return new Promise(function(resolve, reject) {
var container = document.createElement("div");
container.classList.add("background");
var input = document.createElement("input");
var clear = function() {
container.remove();
stage.listening(true);
focus = true;
}
input.setAttribute("type", "text");
input.style.left = x + "px";
input.style.top = y + "px";
canvas.appendChild(container);
container.appendChild(input);
input.focus();
stage.listening(false);
focus = false;
window.addEventListener("keyup", function(event) {
if (event.key === "Escape") {
reject();
clear();
}
});
input.addEventListener("keyup", function(event) {
if (event.key === "Enter") {
resolve(input.value);
clear();
}
});
});
}
//-----------------------------------------------------------------------------
//---------------------------REST methods--------------------------------------
//-----------------------------------------------------------------------------
function post(endpoint, data) {
return fetch(url + endpoint, {
method: 'POST',
body: JSON.stringify(data),
function rest_req(method, endpoint, data) {
let body_data = {}
if(!!data){
body_data = {body: JSON.stringify(data)};
}
return fetch(url + "api/" + endpoint, {
method: method,
...body_data,
headers: {
'Content-Type': 'application/json'
}
}).then(res => res.json())
}
//-----------------------------------------------------------------------------
function create_node() {
if (state != "create_node") {
state = "create_node";
// let mousePos = stage.getPointerPosition();
let pos = {
x: width / 2,
y: height / 2
x: (Math.random()-0.5)*node_size*4+width / 2,
y: (Math.random()-0.5)*node_size*4+height / 2
};
create_input(pos.x, pos.y).then(function(text) {
post("nodes", {
rest_req('POST', "nodes", {
name: text
})
.then(function(res) {
if (res["error"]) {
show_error(res["error"])
show_info(res["error"])
} else {
draw_new_node(text, pos.x, pos.y);
}
@ -319,26 +360,22 @@ function create_node() {
function create_edge() {
if (selected.length < 2) {
show_error("Select at least 2 nodes.");
show_info("Select 2 nodes.");
return;
}
let x = (selected[0].getX() + selected[1].getX()) / 2;
let y = (selected[0].getY() + selected[1].getY()) / 2;
create_input(x, y).then(function(text) {
post("edges", {
rest_req('POST',"edges", {
start: selected[0].id(),
end: selected[1].id(),
weight: text
})
.then(function(res) {
if (res["error"]) {
show_error(res["error"])
show_info(res["error"])
} else {
draw_new_edge({
"start": selected[0].children[1].text(),
"end": selected[1].children[1].text(),
"weight": text
});
draw_new_edge(res);
selected = [];
draw_selected_nodes();
}
@ -348,57 +385,51 @@ function create_edge() {
}, function() {});
}
function create_input(x, y) {
return new Promise(function(resolve, reject) {
var container = document.createElement("div");
container.classList.add("background");
var input = document.createElement("input");
var clear = function() {
container.remove();
stage.listening(true);
focus = true;
}
input.setAttribute("type", "text");
input.style.left = x + "px";
input.style.top = y + "px";
canvas.appendChild(container);
container.appendChild(input);
input.focus();
stage.listening(false);
focus = false;
window.addEventListener("keyup", function(event) {
if (event.key === "Escape") {
reject();
clear();
}
});
input.addEventListener("keyup", function(event) {
if (event.key === "Enter") {
resolve(input.value);
clear();
}
});
});
}
function find_path(data) {
if (data.length < 2) {
show_error("Select at least 2 nodes.");
show_info("Select 2 nodes.");
return;
}
console.log(url + "paths/" + data[0].attrs.id + "/" + data[1].attrs.id);
fetch(url + "paths/" + data[0].attrs.id + "/" + data[1].attrs.id).then(res => res.json())
rest_req("GET", "paths/" + data[0].attrs.id + "/" + data[1].attrs.id, null)
.then(function(res) {
if (res["error"]) {
show_error(res["error"]);
show_info(res["error"]);
} else {
selected = [];
draw_path(res["path"]);
show_info(res["path"], "#infobar");
}
})
.catch(error => console.error('Error:', error));
}
//-----------------------------------------------------------------------------
function save_graph() {
fetch(url+"save").then(res => res.json())
.then(function(res) {
if (res["error"]) {
show_info(res["error"]);
} else {
console.log(res);
show_info(res["info"], "#infobar");
}
})
.catch(error => console.error('Error:', error));
}
function clear_graph() {
fetch(url+"clear").then(res => res.json())
.then(function(res) {
if (res["error"]) {
show_info(res["error"]);
} else {
location.reload();
}
})
.catch(error => console.error('Error:', error));
}
//-----------------------------------------------------------------------------
//------------------------background methods-----------------------------------
@ -433,6 +464,9 @@ function toggle_auto_layout() {
function normalize(vec) {
let l = Math.sqrt(vec[0] ** 2 + vec[1] ** 2);
if (l == 0){
return [vec[0], vec[1]];
}
return [vec[0] / l, vec[1] / l];
}
@ -462,15 +496,20 @@ function force_directed_graph(spring_length = 300, step = 0.004) {
let x2 = konva_nodes[jdx].getX();
let y2 = konva_nodes[jdx].getY();
let dir = normalize([x2 - x1, y2 - y1]);
let dist = ((x1 - x2) ** 2 + (y1 - y2) ** 2);
let dist = Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);
if (dist == 0){
continue;
}
if (in_edges(konva_nodes[idx], konva_nodes[jdx])) {
let f = (dist / spring_length);
let f = (dist**2 / spring_length);
attr[0] += f * dir[0];
attr[1] += f * dir[1];
}
let f = (spring_length ** 2 / Math.sqrt(dist));
rep[0] -= f * dir[0];
rep[1] -= f * dir[1];
if (dist < width*0.4){
let f = (spring_length ** 2 / dist);
rep[0] -= f * dir[0];
rep[1] -= f * dir[1];
}
}
let new_x = x1 + step * (rep[0] + attr[0]);
let new_y = y1 + step * (rep[1] + attr[1]);
@ -492,11 +531,15 @@ function force_directed_graph(spring_length = 300, step = 0.004) {
var anim = new Konva.Animation(function(frame) {
if (auto_layout && focus) {
const step = 0.00025 * frame.timeDiff;
force_directed_graph(300, step)
let spring_factor = Math.min((konva_edges.length+konva_nodes.length-3)/20, 1);
force_directed_graph(150+(1-spring_factor)*180, step);
}
}, node_layer);
anim.start();
// bug: this avoids glitches
window.onfocus = function() {focus = true;};
window.onblur = function() {focus = false;};
initialization();

@ -5,11 +5,12 @@
<title>Assignment</title>
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/roboto.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.min.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<script src="https://unpkg.com/konva@4.0.7/konva.min.js"></script>
</head>
<body>
<main>
<div id="infobar">You have to select at least 2 nodes</div>
<div id="errorbar">You have to select at least 2 nodes</div>
<div id="canvas" class="canvas"></div>
<nav>
<button type="button" onclick="create_node()">
@ -26,6 +27,13 @@
</button>
</nav>
<footer>
<div id="infobar"></div>
<button type="button" onclick="save_graph()">
<h1>Save Graph</h1>
</button>
<button type="button" onclick="clear_graph()">
<h1>Clear Graph</h1>
</button>
<button type="button" data-activeon="auto_layout" onclick="toggle_auto_layout()">
<h1>Automatic Layout</h1>
</button>
@ -34,7 +42,7 @@
<script>
var nodes = {{ nodes|safe }};
var edges = {{ edges|safe }};
const url = "http://{{ request.host|safe }}/api/";
const url = "http://{{ request.host|safe }}/";
</script>
<script src="{{ url_for('static', filename='js/canvas.js')}}"></script>
</body>

@ -0,0 +1,30 @@
#!/bin/bash
# Please edit the python path:
PYTHON3_PATH=python3
cd "$(dirname "$0")"
version=$($PYTHON3_PATH -V 2>&1 | grep -Po '(?<=Python )(.+)')
if [[ -z "$version" ]]
then
echo "Given PYTHON3_PATH is no Python3! Please edit paths.config"
exit 1
fi
parsedVersion=$(echo "${version//./}")
if [[ "$parsedVersion" -lt "330" ]]
then
echo "Python version must be >=3.3"
exit 1
fi
echo "Creating virtual enviroment"
python3 -m venv venv
echo "Installing created virtual enviroment"
echo "Installing dependencies"
venv/bin/pip install flask
echo "Successfully installed webserver"
echo "Start webserver with \"venv/bin/python main.py\""

@ -1,24 +1,27 @@
from flask import Flask, jsonify, request, render_template
import numpy as np
import json, os
from flask import Flask, jsonify, request, render_template, escape
app = Flask(__name__)
app.template_folder = "frontend/"
app.static_folder = "frontend/assets/"
PATH = "graph.json"
graph = {}
graph = {
"A": {"B":10},
"B": {"E":10, "D":10, "C":10, "A":10},
"C": {"B":10, "D":10},
"D": {"B":10, "C":10},
"E": {"B":10}
}
def bellmann_ford(graph, node_a):
"""
Calculate distance from node_a to all other nodes assuming no negative
cycles.
def bellmann_ford(graph, node_a, node_b):
"""Calculate distance from node_a to node_b assuming no negative cycles."""
Kommentar:
Auch wenn Dijkstra der effizientere Algorithmus in diesem Falle wäre,
habe mich dazu entschieden Bellmann Ford zu implementieren.
Dijkstra musste ich schon mindestens 4 mal in meiner Ausbildung
implementieren. Ich hatte mal wieder Lust auf Dynamische Programmierung.
"""
# weights
from_a_to = {node: np.inf for node in graph}
from_a_to = {node: float("inf") for node in graph}
from_a_to[node_a] = 0
# save path so we dont have to backtrace later
path_from_a_to = {node: [] for node in graph}
@ -27,9 +30,9 @@ def bellmann_ford(graph, node_a, node_b):
for i in range(len(graph.keys())-1):
for s,e in [(s,e) for s in graph for e in graph[s]]:
if from_a_to[s] + graph[s][e] < from_a_to[e]:
path_from_a_to[e] = path_from_a_to[s] + [e]
from_a_to[e] = from_a_to[s] + graph[s][e]
return path_from_a_to[node_b]
path_from_a_to[e] = path_from_a_to[s] + [e]
from_a_to[e] = from_a_to[s] + graph[s][e]
return path_from_a_to
def error(message):
@ -47,6 +50,9 @@ def edges_to_json(graph):
return return_list
# -----------------------------------------------------------------------------
# ------------------------------API--------------------------------------------
# -----------------------------------------------------------------------------
@app.route('/api/paths/<start>/<end>', methods=['GET'])
def get_path(start, end):
if start == end:
@ -56,23 +62,13 @@ def get_path(start, end):
if end not in graph:
return error("End node does not exist."), 400
path = bellmann_ford(graph, start, end)
path = bellmann_ford(graph, start)[end]
if len(path) == 0:
return error("There exists no path."), 400
return jsonify({'path': path})
@app.route('/api/nodes', methods=['GET'])
def get_nodes():
return jsonify({'nodes': graph.keys()})
@app.route('/api/edges', methods=['GET'])
def get_edges():
return jsonify({'edges': edges_to_json(graph)})
@app.route('/api/nodes', methods=['POST'])
def create_node():
if not request.json:
@ -84,7 +80,7 @@ def create_node():
if name == "":
return error("Name cannot be empty."), 400
if name in graph:
return error(f'Node with name "{name}" already exist.'), 400
return error(f'Node with name "{escape(name)}" already exist.'), 400
graph[name] = {}
return jsonify(name), 201
@ -126,10 +122,30 @@ def create_edge():
return jsonify(edge), 201
@app.route("/save")
def save():
try:
with open(PATH, 'w') as f:
json.dump(graph, f)
return jsonify({"info":"Saved graph",**graph}), 200
except Exception:
return error("Internal Server Error"), 500
@app.route("/clear")
def clear():
global graph
graph = {}
return "", 204
@app.route("/")
def index():
return render_template("index.html", nodes=list(graph.keys()), edges=edges_to_json(graph))
if __name__ == '__main__':
app.run(host="127.0.0.1", debug=True, port=5000)
if os.path.isfile(PATH) and os.access(PATH, os.R_OK):
with open(PATH, 'r') as f:
graph = json.load(f)
app.run(host="127.0.0.1", debug= True, port=5000)

Loading…
Cancel
Save