Skip to content

Instantly share code, notes, and snippets.

@corvuscrypto
Last active October 17, 2017 13:48
Show Gist options
  • Save corvuscrypto/41fc962f54c99164783807f2f4698fd1 to your computer and use it in GitHub Desktop.
Save corvuscrypto/41fc962f54c99164783807f2f4698fd1 to your computer and use it in GitHub Desktop.
file for the graph visualisation included in one of my blog posts
/**
I'll let you use my code for manipulation and reposting on the condition that you listen to my tale.
FADE IN:
EXT. UNNAMED CITY - DAY
The hustle and bustle is a symphony of progress.
We pan past windows, each of which contain a different story,
to find JACEY LAKIMS, 28... hot, but doesn't know it.
Jacey stops when her high heel gets caught in the grating of a sewer.
Suddenly, a man steps into frame and points a gun at her.
This is not her day.
FADE TO BLACK
TITLE: Three weeks earlier
...
INT. UNNAMED BAR - NIGHT
(pause for laugh)
...
BLANE
Maybe I don't need a new friend.
JACEY
Maybe you're the only friend I need.
BLANE
Need, or want?
JACEY
I've never been much for wanting.
BLANE
Spoken like someone with needs.
Jacey reaches out and touches his face. It's clear he needs what she wants.
She's a woman. He's a man. The city burns in the background as he takes her in his arms.
FADE OUT
TITLE: The end?
*/
// simple color map
var COLORS = {
unvisited: "#FFFFFF",
active: "#FF0000",
queued: "#0000FF",
done: "#000000",
path: "#00FF00",
}
// naive reverse color map for contrasting text colors
var TEXT_COLORS = {
"#FFFFFF": "#000000",
"#FF0000": "#000000",
"#0000FF": "#FFFFFF",
"#000000": "#FFFFFF",
"#00FF00": "#000000",
}
// More convenient infinity definition than the float Infinity
const INF = 1<<30;
// Node types to use as artificial weights (Currently unused)
const TYPE_SLOW = 0;
const TYPE_MEDIUM = 1;
const TYPE_FAST = 3;
// Some other needed constants
const GRAPH_SIZE = 7; // Size is in number of nodes across
const PAUSE_TIME = 1000; // Time to wait (in ms) between each step
const NODE_RADIUS = 12;
/**
* Queue represents a normal FIFO queue without any shuffling
*/
function Queue() {
this.q = [];
}
Queue.prototype.enqueue = function(node) {
this.q.push(node);
}
Queue.prototype.dequeue = function() {
return this.q.shift();
}
/**
* NaivePriorityQueue is a naive implementation of a queue with priority shuffling.
* Speed isn't important here, but if you're into it, just use a min-priority heap instead
*/
function NaivePriorityQueue() {
this.q = [];
}
NaivePriorityQueue.prototype.sort = function() {
this.q.sort(function(a, b){return a[1]-b[1]});
}
NaivePriorityQueue.prototype.enqueue = function(node, priority) {
this.q.push([node, priority]);
}
NaivePriorityQueue.prototype.adjust = function(node, priority) {
this.q.forEach(function(a){
if (a[0] === node) {
a[1] = priority
}
})
}
NaivePriorityQueue.prototype.contains = function(node) {
for (var i in this.q) {
if (this.q[i][0] === node) {
return true;
}
}
return false;
}
NaivePriorityQueue.prototype.dequeue = function() {
this.sort();
return (this.q.shift()||[null])[0];
}
/**
* Node is the representation of graph connection points.
*
* Args:
* x (int): x coordinate on the graph
* y (int): y coordinate on the graph
*/
function Node(x, y){
this.x = x;
this.y = y;
this.children = [];
this.visited = false;
this.distance = INF;
this.type = TYPE_SLOW;
this.color = COLORS.unvisited;
this.predecessor = null;
}
/**
* Convenience function to calculate euclidian distance to a nother node.
* Truncates result to precision of 1e-1.
*
* Args:
* n (Node): another Node object.
*
* Returns float64
*/
Node.prototype.calculateDistanceTo = function(n) {
return Math.floor(Math.sqrt(Math.pow(this.x - n.x, 2) + Math.pow(this.y - n.y, 2)) * 10) / 10;
}
/**
* Interface to satisfy requirements for visualization of graph search algorithms.
*
* Args:
* setupFunction ( ()=>null ): setup function that performs any preparations before initiating the search.
* iterativeFunction ( ()=>boolean ): iterative function for performing the iterative steps of the algorithm.
* finishFunction ( ()=>boolean ): the path reconstruction function.
*/
function GenericSearcher(setupFunction, iterativeFunction, finishFunction){
this.timer = null;
this.startNode = null;
this.finishNode = null;
this.nodes = null;
this.path = null;
this.setup = setupFunction.bind(this);
this.iterate = iterativeFunction.bind(this);
this.finish = finishFunction.bind(this);
}
/**
* This is the wrapper that handles running graph search algorithms.
*
* Args:
* nodeList (Array<Node>): list of nodes.
* start (Node): starting node.
* finish (Node): node we are trying to find.
*/
GenericSearcher.prototype.search = function(nodeList, start, finish) {
var self = this;
clearInterval(this.timer);
this.startNode = start;
this.finishNode = finish;
this.nodes = nodeList;
this.setup();
this.timer = setInterval(function(){
if (!self.iterate()) {
clearInterval(self.timer);
self.timer = setInterval(function(){
if (!self.finish()) {
clearInterval(self.timer);
}
}, 1000);
}
}, 1000);
}
var breadthFirstSearch = new GenericSearcher(
function (){
this.nodes.forEach(function(n){
n.visited = false;
n.color = COLORS.unvisited;
n.distance = INF;
})
this.startNode.visited = true;
this.startNode.distance = 0;
var queue = new Queue();
queue.enqueue(this.startNode);
this.queue = queue;
this.predecessor = null;
this.path = null;
},
function (){
if (this.predecessor) this.predecessor.color = COLORS.done;
var node = this.queue.dequeue();
var self = this;
if (node === this.finishNode) {
this.finishNode.color = COLORS.path;
return false;
}
node.visited = true;
node.color = COLORS.active;
node.children.forEach(function(n){
if (n.visited || self.queue.q.indexOf(n) !== -1) return;
n.distance = node.distance + 1;
n.color = COLORS.queued;
self.queue.enqueue(n);
})
this.predecessor = node;
return true;
},
function (){
if (!this.path){
this.path = [];
this.path.unshift(this.finishNode);
}
var node = this.path[0];
var leastDist = INF;
var leastDistNode = null
node.children.forEach(function(n){
if (n.distance < leastDist) {
leastDist = n.distance;
leastDistNode = n;
}
})
leastDistNode.color = COLORS.path
this.path.unshift(leastDistNode);
return leastDistNode !== this.startNode;
}
);
var dijkstraSearch = new GenericSearcher(
function (){
var self = this;
this.nodes.forEach(function(n){
n.visited = false;
n.color = COLORS.unvisited;
n.distance = INF;
})
this.startNode.visited = true;
this.startNode.distance = 0;
var queue = new NaivePriorityQueue();
queue.enqueue(this.startNode, 0);
this.queue = queue;
this.predecessor = null;
this.path = null;
},
function (){
var node = this.queue.dequeue();
if (!node) return false;
if (this.predecessor) this.predecessor.color = COLORS.done;
node.visited = true;
node.color = COLORS.active;
if (node === this.finishNode) {
return false;
}
var self = this;
node.children.forEach(function(n){
var distance = node.distance + node.calculateDistanceTo(n);
distance = Math.round(distance * 10) / 10;
if (distance < n.distance){
n.distance = distance;
n.predecessor = node
if (self.queue.contains(n)){
self.queue.adjust(n, n.distance)
} else {
n.color = COLORS.queued;
self.queue.enqueue(n, n.distance)
}
}
})
this.predecessor = node;
return true;
},
function (){
if (!this.path){
this.path = [];
this.path.unshift(this.finishNode);
}
var node = this.path[0];
node.color = COLORS.path
this.path.unshift(node.predecessor);
return node !== this.startNode;
}
);
var astarSearch = new GenericSearcher(
function (){
var self = this;
this.nodes.forEach(function(n){
n.visited = false;
n.color = COLORS.unvisited;
n.distance = INF;
})
this.startNode.visited = true;
this.startNode.distance = 0;
var queue = new NaivePriorityQueue();
queue.enqueue(this.startNode, 0);
this.queue = queue;
this.predecessor = null;
this.path = null;
},
function (){
var node = this.queue.dequeue();
if (!node) return false;
if (this.predecessor) this.predecessor.color = COLORS.done;
node.visited = true;
node.color = COLORS.active;
if (node === this.finishNode) {
return false;
}
var self = this;
node.children.forEach(function(n){
var distance = node.distance + node.calculateDistanceTo(n);
distance = Math.round(distance * 10) / 10; // truncating needed due to float addition
// inb4 "eval is bad"
var fscore = distance + function(node, n, finishNode){let result = 0; eval(document.getElementById("astarHeuristic").value); return result}(node, n, self.finishNode);
fscore = Math.round(fscore * 10) / 10;
if (distance < n.distance){
n.distance = distance;
n.predecessor = node
if (self.queue.contains(n)){
self.queue.adjust(n, fscore)
} else {
n.color = COLORS.queued;
self.queue.enqueue(n, fscore)
}
}
})
this.predecessor = node;
return true;
},
function (){
if (!this.path){
if (!this.finishNode.predecessor) return false;
this.path = [];
this.path.unshift(this.finishNode);
}
var node = this.path[0];
node.color = COLORS.path
this.path.unshift(node.predecessor);
return node !== this.startNode;
}
);
/**
* GraphCreator is the object representation of the user interface that allows
* creation of arbitrary graphs for searching. Pretty quickly hacked together. Probably best
* just to avoid the brain hurt and not worry about this.
*
* Args:
* element (HTMLCanvasElement): the canvas element which is to be turned into the graph creation interface.
*/
function GraphCreator(element){
var self = this;
this.canvas = element;
this.ctx = element.getContext('2d');
element.onmousemove = function(e){
self.mouseIn = true;
self.mouseX = e.layerX;
self.mouseY = e.layerY;
}
element.onmouseout = function(e){
self.mouseIn = false;
}
element.onclick = this.clickHandler.bind(this)
this.editState = GraphCreator.STATE_NO_EDIT;
document.getElementById("btnAddNodes").onclick = function(){
self.editState = GraphCreator.STATE_ADD_NODE
}
document.getElementById("btnAddConnections").onclick = function(){
self.editState = GraphCreator.STATE_ADD_CONNECTION
self.partialConnectionStart = null;
}
document.getElementById("btnReset").onclick = function(){
self.editState = 0
self.partialConnectionStart = null;
self.nodeMap = {}
self.recountNodes();
self.adjustSelects();
}
this.partialConnectionStart = null;
this.nodeSeparation = (this.canvas.width - (2 * NODE_RADIUS * GRAPH_SIZE)) / (GRAPH_SIZE - 2)
this.nodeMap = {}
this.startNode = 0;
this.finishNode = 0;
var startSearch = function(searcher){
var nodes = []
var startNode = null;
var finishNode = null;
for (var k in self.nodeMap) {
var node = self.nodeMap[k];
nodes.push(self.nodeMap[k])
if (node.label == self.startNode) startNode = node;
if (node.label == self.finishNode) finishNode = node;
}
searcher.search(nodes, startNode, finishNode)
}
document.getElementById("selectStartNode").onchange = function(){
self.startNode = this.value
}
document.getElementById("selectFinishNode").onchange = function(){
self.finishNode = this.value
}
document.getElementById("btnRunBFS").onclick = function(){
startSearch(breadthFirstSearch);
}
document.getElementById("btnRunDijkstra").onclick = function(){
startSearch(dijkstraSearch);
}
document.getElementById("btnRunAStar").onclick = function(){
startSearch(astarSearch);
}
self.showLabels = true;
self.showEdgeDist = true;
self.showNodeDist = true;
document.getElementById("checkShowLabels").onchange = function(){
self.showLabels = this.checked;
}
document.getElementById("checkShowEdgeDist").onchange = function(){
self.showEdgeDist = this.checked;
}
document.getElementById("checkShowNodeDist").onchange = function(){
self.showNodeDist = this.checked;
}
this.drawCreate();
}
// GraphCreator constants
GraphCreator.STATE_NO_EDIT = 0
GraphCreator.STATE_ADD_NODE = 1
GraphCreator.STATE_ADD_CONNECTION = 2
GraphCreator.STATE_PLACING_CONNECTION = 3
GraphCreator.prototype.clickHandler = function(){
switch (this.editState) {
case GraphCreator.STATE_ADD_NODE:
this.createNode();
break;
case GraphCreator.STATE_ADD_CONNECTION:
if (this.connectionCreate())
this.editState = GraphCreator.STATE_PLACING_CONNECTION;
break;
case GraphCreator.STATE_PLACING_CONNECTION:
this.connectionCreate()
this.editState = GraphCreator.STATE_ADD_CONNECTION;
this.partialConnectionStart = null;
break;
}
this.recountNodes();
this.adjustSelects();
}
GraphCreator.prototype.recountNodes = function(){
var i = 0;
for (var k in this.nodeMap){
this.nodeMap[k].label = i++;
}
}
GraphCreator.prototype.adjustSelects = function(){
var startSelect = document.getElementById("selectStartNode")
var finishSelect = document.getElementById("selectFinishNode")
startSelect.innerHTML = ""
finishSelect.innerHTML = ""
var i = 0;
for (var k in this.nodeMap){
var option = new Option();
option.value = i;
var option2 = option.cloneNode()
option.text = "Node " + i;
option2.text = "Node " + i;
startSelect.add(option);
finishSelect.add(option2);
i++
}
startSelect.value = this.startNode;
finishSelect.value = this.finishNode;
}
GraphCreator.prototype.drawCreate = function(){
var ctx = this.ctx
ctx.clearRect(0, 0, 500, 500)
var separation = this.nodeSeparation
ctx.lineWidth = 1;
for (var i = 0; i < GRAPH_SIZE; i++){
for (var j = 0; j < GRAPH_SIZE; j++){
ctx.beginPath();
ctx.arc((i+0.5) * separation, (j + 0.5) * separation, 3, 0, Math.PI * 2, true);
ctx.fillStyle = "#777"
ctx.fill();
ctx.closePath();
}
}
// draw Nodes
var edgesDrawn = {};
for (var k in this.nodeMap) {
var node = this.nodeMap[k];
// draw edges first
node.children.forEach(function(n){
if (edgesDrawn[n.label+"-"+node.label]) return
ctx.beginPath();
ctx.moveTo((node.x+0.5) * separation, (node.y + 0.5) * separation);
ctx.lineStyle = "#000000"
ctx.lineTo((n.x+0.5) * separation, (n.y + 0.5) * separation)
ctx.stroke();
ctx.closePath();
edgesDrawn[node.label+"-"+n.label] = true
})
ctx.beginPath();
ctx.arc((node.x+0.5) * separation, (node.y + 0.5) * separation, NODE_RADIUS, 0, Math.PI * 2, true);
ctx.fillStyle = node.color
ctx.fill();
if (node.distance !== INF && this.showNodeDist) {
ctx.fillStyle = TEXT_COLORS[node.color]
ctx.font = "8pt monospace"
ctx.textAlign = "center"
ctx.fillText(node.distance, (node.x+0.5) * separation, (node.y + 0.5) * separation + 5);
}
ctx.stroke();
ctx.closePath();
if (this.showLabels){
ctx.beginPath();
ctx.fillStyle = "#FFFFFF"
ctx.fillRect((node.x + 0.13) * separation, (node.y + 0.27) * separation, 15, 15);
ctx.strokeRect((node.x + 0.13) * separation, (node.y + 0.27) * separation, 15, 15);
ctx.font = "10pt monospace"
ctx.fillStyle = "#000000"
ctx.textAlign = "center"
ctx.fillText(node.label, (node.x + 0.24) * separation, (node.y + 0.45) * separation);
ctx.closePath();
}
}
// second pass on Nodes to draw the edge distance labels
if (this.showEdgeDist){
var edgesDrawn = {};
for (var k in this.nodeMap) {
var node = this.nodeMap[k];
// draw edges first
node.children.forEach(function(n){
// draw the edge distance
var distance = node.calculateDistanceTo(n);
var halfX = ((n.x - node.x) / 2) + node.x + 0.5;
var halfY = ((n.y - node.y) / 2) + node.y + 0.5;
ctx.beginPath();
ctx.fillStyle = "#CCCCCC"
ctx.lineStyle = "#000000"
ctx.fillRect(halfX * separation - 15, halfY * separation - 11, 20, 15);
ctx.strokeRect(halfX * separation - 15, halfY * separation - 11, 20, 15);
ctx.font = "8pt monospace"
ctx.fillStyle = "#000000"
ctx.textAlign = "center"
ctx.fillText(distance, halfX * separation - 5, halfY * separation);
ctx.closePath();
edgesDrawn[node.label+"-"+n.label] = true
})
}
}
if (this.mouseIn) {
switch (this.editState) {
case 1:
this.drawAddNode();
break;
case 2:
this.drawAddConnection();
break;
case 3:
this.drawPlacingConnection();
break;
}
}
requestAnimationFrame(this.drawCreate.bind(this))
}
GraphCreator.prototype.drawAddNode = function(){
var ctx = this.ctx;
var separation = this.nodeSeparation;
ctx.beginPath();
ctx.arc((Math.floor(this.mouseX / separation) + 0.5) * separation, (Math.floor(this.mouseY / separation) + 0.5) * separation, NODE_RADIUS, 0, Math.PI * 2, true);
ctx.lineStyle = "#000000"
ctx.lineWidth = 2;
ctx.fillStyle = "#FFFFFF"
ctx.globalAlpha = 0.5
ctx.stroke();
ctx.globalAlpha = 1
ctx.fill();
ctx.closePath();
ctx.beginPath();
ctx.arc(this.mouseX, this.mouseY, NODE_RADIUS / 2, 0, Math.PI * 2, true);
ctx.lineStyle = "#000000"
ctx.lineWidth = 2;
ctx.fillStyle = "#FFFFFF"
ctx.stroke();
ctx.fill();
ctx.closePath();
}
GraphCreator.prototype.drawAddConnection = function(){
var ctx = this.ctx;
var separation = this.nodeSeparation;
ctx.lineStyle = "#000000"
ctx.lineWidth = 2;
if (this.getNodeNearestMouse()) {
ctx.beginPath();
ctx.arc((Math.floor(this.mouseX / separation) + 0.5) * separation, (Math.floor(this.mouseY / separation) + 0.5) * separation, 3, 0, Math.PI * 2, true);
ctx.fillStyle = "#0000FF"
ctx.stroke();
ctx.fill();
ctx.closePath();
}
ctx.fillStyle = "#FFFFFF"
ctx.beginPath();
ctx.arc(this.mouseX-5, this.mouseY+5, 1, 0, Math.PI * 2, true);
ctx.stroke();
ctx.fill();
ctx.closePath();
ctx.beginPath();
ctx.arc(this.mouseX+5, this.mouseY-5, 1, 0, Math.PI * 2, true);
ctx.stroke();
ctx.fill();
ctx.closePath();
ctx.beginPath();
ctx.moveTo(this.mouseX-5, this.mouseY+5);
ctx.lineTo(this.mouseX+5, this.mouseY-5);
ctx.lineWidth = 1;
ctx.stroke();
ctx.closePath();
}
GraphCreator.prototype.drawPlacingConnection = function(){
var ctx = this.ctx;
var separation = this.nodeSeparation;
ctx.lineStyle = "#000000"
if (this.getNodeNearestMouse()) {
ctx.beginPath();
ctx.arc((Math.floor(this.mouseX / separation) + 0.5) * separation, (Math.floor(this.mouseY / separation) + 0.5) * separation, 3, 0, Math.PI * 2, true);
ctx.fillStyle = "#0000FF"
ctx.stroke();
ctx.fill();
ctx.closePath();
ctx.beginPath();
ctx.moveTo((this.partialConnectionStart.x + 0.5) * separation, (this.partialConnectionStart.y + 0.5) * separation)
ctx.lineTo((Math.floor(this.mouseX / separation) + 0.5) * separation, (Math.floor(this.mouseY / separation) + 0.5) * separation)
ctx.stroke();
ctx.closePath();
} else {
ctx.beginPath();
ctx.moveTo((this.partialConnectionStart.x + 0.5) * separation, (this.partialConnectionStart.y + 0.5) * separation)
ctx.lineTo(this.mouseX, this.mouseY)
ctx.stroke();
ctx.closePath();
}
ctx.beginPath();
ctx.arc((this.partialConnectionStart.x + 0.5) * separation, (this.partialConnectionStart.y + 0.5) * separation, 3, 0, Math.PI * 2, true);
ctx.fillStyle = "#0000FF"
ctx.fill();
ctx.stroke();
ctx.closePath();
ctx.lineStyle = "#000000"
ctx.lineWidth = 2;
ctx.fillStyle = "#FFFFFF"
ctx.beginPath();
ctx.arc(this.mouseX-5, this.mouseY+5, 1, 0, Math.PI * 2, true);
ctx.stroke();
ctx.fill();
ctx.closePath();
ctx.beginPath();
ctx.arc(this.mouseX+5, this.mouseY-5, 1, 0, Math.PI * 2, true);
ctx.stroke();
ctx.fill();
ctx.closePath();
ctx.beginPath();
ctx.moveTo(this.mouseX-5, this.mouseY+5);
ctx.lineTo(this.mouseX+5, this.mouseY-5);
ctx.lineWidth = 1;
ctx.stroke();
ctx.closePath();
}
GraphCreator.prototype.getNodeNearestMouse = function(){
var separation = this.nodeSeparation;
var coordX = Math.floor(this.mouseX / separation);
var coordY = Math.floor(this.mouseY / separation);
return this.nodeMap[coordX+","+coordY] || null
}
GraphCreator.prototype.createNode = function(){
var separation = this.nodeSeparation;
var coordX = Math.floor(this.mouseX / separation);
var coordY = Math.floor(this.mouseY / separation);
if (!this.nodeMap[coordX+","+coordY])
this.nodeMap[coordX+","+coordY] = new Node(coordX, coordY)
}
GraphCreator.prototype.connectionCreate = function(){
if (!this.partialConnectionStart){
var node = this.getNodeNearestMouse()
this.partialConnectionStart = node;
return node !== null
}
var connectNode = this.getNodeNearestMouse();
if (connectNode === null || connectNode === this.partialConnectionStart) return false
if (this.partialConnectionStart.children.indexOf(connectNode) === -1) {
this.partialConnectionStart.children.push(connectNode)
connectNode.children.push(this.partialConnectionStart)
}
return true
}
var cvs = new GraphCreator(document.getElementById("creator"));
// load in default
var defaultGraph = [
[0, 0, [1]],
[2, 1, [2]],
[1, 2, [3, 4, 5]],
[5, 1, [6]],
[1, 4, [7]],
[3, 4, []],
[4, 3, [7]],
[5, 5, []]
]
defaultGraph.forEach(function(n, i){
var node = new Node(n[0], n[1])
node.label = i;
cvs.nodeMap[n[0]+","+n[1]] = node
})
defaultGraph.forEach(function(n, i){
var node = cvs.nodeMap[n[0]+","+n[1]];
n[2].forEach(function(j){
var o = defaultGraph[j];
var onode = cvs.nodeMap[o[0]+","+o[1]];
onode.children.push(node);
node.children.push(onode);
})
})
cvs.finishNode = 7;
cvs.adjustSelects();
console.log("This console has super bird powers!");
function caw(){
console.log(
" ,::::.._ *~caw~*\n" +
" ,':::::::::. /\n" +
" _,_`:::,: /\\ ::`-,.._\n" +
" _._., ', `:::::::::;'-..__`.\n" +
" _.-'' ' ,' ,' ,\:::,'::-`'''\n" +
" _.-'' , ' , ,' ' ,' `::WW/\n" +
" _..-'' , ' , ' ,' , ,' ',' '/wwww\n" +
" _...:::'`-..'_, ' , ,' , ' ,'' , ,':,WWw\n" +
" _`.:::::,':::::,'::`-:..'_',_'_,'..-'::,:W,\n" +
" _..-:::'::,':::::::,':::,':,'::,':::,'::::::,':::;\n" +
" `':,'::::::,:,':::::::::::::::::':::,'::_:::,'/\n" +
" __..:'::,':::::::--''' `-:,':,':::'::-' ,':::/\n" +
" _.::::::,:::.-''-`-`..'_,'. ,', , ' , ,' ', `','\n" +
" ,::::::''''` \:. . ,' ' ,',' '_,'\n" +
" ``::._,'_'_,',.-'\n" +
" \\ \\\n" +
" \\_\\\n" +
" \\`-`.-'_\n" +
" .`-.\\__`. ``\n" +
" ``-.-._\n" +
" `"
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment