/*
This file is part of SeaSound.
SeaSound is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
SeaSound is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with SeaSound. If not, see <https://www.gnu.org/licenses/>.
*/
// TODO: Need to fix node deletion order to be like pianoroll
/**
* The graph diagram class is the main class for dealing with graph diagram canvases.
* The graph diagram class stores a graph using two lists one for the nodes of the graph and one for the edges of the graph.
* @class
* @public
*/
class GraphDiagramCanvas
{
/**
* The coords of the mouse.
*/
coord = {x:0, y:0};
/**
* The various input modes of this widget.
* Node mode is used to input nodes by mouse left click.
* Delete mode is used to delete nodes by mouse left click.
* Edge mode is used to connect an edge from one node to another via a sequence of mouseclicks.
*/
inputModes = ["NODE","DELETE","EDGE"];
/**
* The current input mode.
*/
inputMode = "NODE";
/**
* The list of nodes for this graph.
*/
nodeList = new Array();
/**
* The list of edges for this graph.
*/
edgeList = new Array();
/**
* The edge we are currently building.
*/
workingEdge = null;
/**
* The first node clicked while building an edge.
*/
workingStartNode = null;
/**
* The type of node that the edge starts from while building an edge (either a node input or a node output).
*/
startEdgeNodeType = null;
/**
* The number of inputs for the next node to be constructed.
*/
curInputs = 2;
/**
* The output format for the outputs of the next node to be constructed.
* This is a string in csv format.
* Each entry is the variable type prefix from csound of the corresponding output.
* The node outputs are specified in order in the list from left to right.
*/
curOutputs = "";
/**
* The textual name of the next node to be constructed.
*/
curName = "Default";
/**
* The output style of the next node.
* This should either be the string "FUNCTIONAL" or the string "MACRO".
* In functional output style code is emitted using csound opcode functional notation (without typing).
* In macro output style code is emitted as is but with some inputs substituted for text based macros of the form @
* where n is an integer.
*/
curOutputStyle = "FUNCTIONAL";
/**
* The name of this instrument.
*/
instrumentName = "";
/**
* Amount translate changes by.
*/
translateAmt = 10;
/**
* Amount scale changes by.
*/
scaleAmt = 1.15;
// For note area dimensions in local coords
//TODO: I believe these can be removed.
localWidth = 0;
localHeight = 0;
/**
* Construct a graph diagram canvas widget instance and draw it to the screen.
* @param {string} query - String containing html id of the canvas we are constructing for.
* @param {string} name - String containing the instrument name that this widget corresponds to.
* @param {number} size - Sizes of nodes.
*/
constructor(query,name,size)
{
// Set Up the canvas
this.canvas = document.getElementById(query);
this.ctx = this.canvas.getContext("2d");
// for some reason 2*tab-container height works but not using master-tab-container directly
let tabsHeight = 2*document.getElementById('tab-container').offsetHeight;
tabsHeight += document.getElementById("instrument-controls").offsetHeight;
this.canvas.height = window.innerHeight - tabsHeight;
this.canvas.width = window.innerWidth;
this.localWidth = 500;
this.localHeight = 500;
// TODO: I think this can be removed. I don't think it is actually used anywhere.
this.nodeRadius = size;
// Set up the instrument name
this.instrumentName = name;
var that = this;
this.canvas.addEventListener('mousedown', function(ev) { that.leftClickDown(); });
this.canvas.addEventListener('keydown', function(ev) { that.mButtonClick(ev); });
this.canvas.addEventListener('mousemove', function(ev) { that.updateMouseCoordinates(); });
this.draw();
}
// Configure the nodes that are created on left click in node mode
/**
* Configure the nodes that are created on left click in node mode.
* @param {string} name - String containing html id of the canvas we are constructing for.
* @param {number} inputs - The number of inputs to the node.
* @param {string} outputs - The csv formatted list of outputs.
* @param {string} style - The style of output for the node (should be "FUNCTIONAL" or "MACRO").
*/
configureNode(name,inputs,outputs,style)
{
this.curName = name;
this.curInputs = inputs;
this.curOutputs = outputs;
this.curOutputStyle = style;
this.draw();
}
/**
* Handles button clicks from the user.
* @param {event} ev - The event containing the button click we are handling.
*/
mButtonClick(ev)
{
let controlText = "";
// Maybe add explanation of mouse click controls too
controlText += "1: enter node mode\n"
controlText += "2: enter edge mode\n"
controlText += "3: enter delete mode\n"
controlText += "n: change rectangle name\n"
controlText += "h: display keybinds\n";
controlText += "wasd: scroll viewport\n";
controlText += "qe: scale viewport\n";
controlText += "rf: change amount to translate by\n";
controlText += "tg: change amount to zoom by\n";
if (ev.key == "1")
{
this.inputMode = "NODE";
this.workingEdge = null; // reset the working edge
this.draw();
}
else if (ev.key == "2")
{
this.inputMode = "EDGE";
this.workingEdge = null; // reset the working edge
this.draw();
}
else if (ev.key == "3")
{
this.inputMode = "DELETE";
this.workingEdge = null; // reset the working edge
this.draw();
}
else if (ev.key == "n")
{
let out = prompt("Enter rectangle name");
if (out != null) this.curName = out;
else this.curName = "empty";
}
else if (ev.key == "h") alert(controlText);
else if (ev.key == "q") this.ctx.scale(this.scaleAmt,this.scaleAmt);
else if (ev.key == "e") this.ctx.scale(1/this.scaleAmt,1/this.scaleAmt);
else if (ev.key == "a") this.ctx.translate(this.translateAmt,0);
else if (ev.key == "d") this.ctx.translate(-this.translateAmt,0);
else if (ev.key == "s") this.ctx.translate(0,-this.translateAmt);
else if (ev.key == "w") this.ctx.translate(0,this.translateAmt);
else if (ev.key == "r") this.translateAmt += 10;
else if (ev.key == "f") this.translateAmt -= 10;
else if (ev.key == "t") this.scaleAmt *= (1+1/(2**4));
else if (ev.key == "g") this.scaleAmt /= (1+1/(2**4));
this.draw();
}
/**
* Handle when mouse left click is pressed down.
*/
leftClickDown()
{
if (this.inputMode == "NODE")
{
// add node to nodeList
let val = this.screenToWorldCoords(this.coord);
this.nodeList.push(new Node(val,this.curName,this.curInputs,this.curOutputs,this.curOutputStyle,this.ctx));
}
else if (this.inputMode == "DELETE")
{
this.nodeDelete();
this.edgeDelete();
this.inputMode = "NODE";
}
else // otherwise we are in edge mode
{
let point = this.screenToWorldCoords(this.coord); // convert mouse input to world coords
// on first click if clicking node add start point to edge structure
// else do return.
if (this.workingEdge == null)
{
this.workingEdge = new Edge();
let intersectNode = false;
for (let i = 0; i < this.nodeList.length; i++)
{
let intersectPoint = this.nodeList[i].collision(point);
if (intersectPoint != null)
{
intersectNode = true;
this.workingEdge.setFrom(intersectPoint);
this.workingEdge.addSegment(intersectPoint);
this.workingStartNode = this.nodeList[i];
this.startEdgeNodeType = this.nodeList[i].collisionType(intersectPoint);
// no need to draw in this case
break;
}
}
if (!intersectNode)
{
// if no intersection, then reset and return to node mode
this.inputMode = "NODE";
this.workingEdge = null;
this.draw();
return;
}
else return;
}
// on later clicks add segments to edge structure
let intersectNode = false; //TODO: Is this still needed here?
for (let i = 0; i < this.nodeList.length; i++)
{
let intersectPoint = this.nodeList[i].collision(point);
let intersectType = intersectPoint!=null ? this.nodeList[i].collisionType(intersectPoint) : null;
// If the node types at either end of the edge match we should exit immediately
if (intersectPoint != null && intersectType == this.startEdgeNodeType) return;
else if (intersectPoint != null)
{
intersectNode = true;
this.workingEdge.setTo(intersectPoint);
this.workingEdge.addSegment(intersectPoint);
// edges need to flow from outputs to inputs
if (this.startEdgeNodeType != "OUTPUT") this.workingEdge.reverse();
// Register the inputs and outputs of the two nodes
if (this.startEdgeNodeType != "OUTPUT")
{
// Get the to/from parameter number where the collision occured
// In this case recall the working edge has been reversed
let fromParam = this.nodeList[i].collisionOutputParam(this.workingEdge.getFrom());
let toParam = this.workingStartNode.collisionInputParam(this.workingEdge.getTo());
// We can only have one input per input rectangle of any given node
// So the node and edge is set up correctly only if adding the input succeeds
if (this.workingStartNode.addInputNode(this.nodeList[i],fromParam,toParam))
{
this.nodeList[i].addOutputNode(this.workingStartNode,fromParam,toParam);
this.edgeList.push(this.workingEdge);
}
}
else
{
// Get the to/from parameter number where the collision occured
let toParam = this.nodeList[i].collisionInputParam(this.workingEdge.getTo());
let fromParam = this.workingStartNode.collisionOutputParam(this.workingEdge.getFrom());
// We can only have one input per input rectangle of any given node
// So the node and edge is set up correctly only if adding the input succeeds
if (this.nodeList[i].addInputNode(this.workingStartNode,fromParam,toParam))
{
this.workingStartNode.addOutputNode(this.nodeList[i],fromParam,toParam);
this.edgeList.push(this.workingEdge);
}
}
// The edge is complete so exit edge mode
this.workingStartNode = null;
this.workingEdge = null;
this.inputMode = "NODE";
this.startEdgeNodeType = null;
this.draw();
return;
}
}
// add the segment to the edges list also
this.workingEdge.addSegment(point);
}
//draw
this.draw();
}
/**
* Delete the currently selected node.
*/
nodeDelete()
{
let point = this.screenToWorldCoords(this.coord);
for (let i = 0; i < this.nodeList.length; i++)
if (this.nodeList[i].boundingCollision(point))
{
for (let j = 0; j < this.edgeList.length; j++) // delete any edges connected to this node
{
let fromCollision = this.nodeList[i].boundingCollision(this.edgeList[j].getFrom());
let toCollision = this.nodeList[i].boundingCollision(this.edgeList[j].getTo());
// if the from or to points of the edge collided with an input or output of the current node
if (fromCollision || toCollision)
{
// then reset the corresponding input or output parameters
let fromIndex = this.nodeList[i].collisionOutputParam(this.edgeList[j].getFrom());
if (fromIndex != -1)
{
let tup = this.nodeList[i].getOutputNode(fromIndex);
this.nodeList[i].resetOutput(fromIndex);
tup[0].resetInput(tup[2]);
}
let toIndex = this.nodeList[i].collisionInputParam(this.edgeList[j].getTo());
if (toIndex != -1)
{
let tup = this.nodeList[i].getInputNode(toIndex);
this.nodeList[i].resetInput(toIndex);
tup[0].resetOutput(tup[1]);
}
// and remove the edge
this.edgeList.splice(j,1);
j--;
}
}
this.nodeList.splice(i,1);
break;
}
}
/**
* Delete the currently selected edge.
*/
edgeDelete()
{
let point = this.screenToWorldCoords(this.coord);
for (let i = 0; i < this.edgeList.length; i++)
if (this.edgeList[i].collision(point)) // if point collides with edge delete the edge
{
// find and reset the inputs and outputs of the nodes on either side of the edge
let fromIndex = -1;
let toIndex = -1;
let fromNode = null;
let toNode = null;
for (let j = 0; j < this.nodeList.length; j++)
{
fromIndex = this.nodeList[j].collisionOutputParam(this.edgeList[i].getFrom());
// found the from side of the edge so reset the output of the from node to null
if (fromIndex != -1)
{
fromNode = this.nodeList[j];
fromNode.resetOutput(fromIndex);
}
toIndex = this.nodeList[j].collisionInputParam(this.edgeList[i].getTo());
// found the to side of the edge so reset the input of the to node to null
if (toIndex != -1)
{
toNode = this.nodeList[j];
toNode.resetInput(toIndex);
}
}
// remove the edge
this.edgeList.splice(i,1);
break;
}
}
/**
* Update the current coordinates of the mouse.
*/
updateMouseCoordinates()
{
this.coord.x = event.clientX - this.canvas.offsetLeft;
this.coord.y = event.clientY - this.canvas.offsetTop;
if (this.inputMode != "NODE" && this.workingEdge != null) // draw the work in progress edge if needed
{
this.draw();
// drawing working edge
let index = this.workingEdge.polyLineList.length - 1; // this is not very good encapsulation
let from = this.workingEdge.polyLineList[index]; // this is not very good encapsulation
let to = {x:this.coord.x, y:this.coord.y}; // the to point in screen coords
to = this.screenToWorldCoords(to); // convert the to point to world coords
this.ctx.lineWidth = 6;
this.ctx.strokeStyle = 'black';
this.ctx.beginPath();
this.ctx.moveTo(from.x,from.y);
this.ctx.lineTo(to.x,to.y);
this.ctx.stroke();
}
}
/**
* Draw a circle around the input coord.
* @param {object} c - The center point to draw the circle around.
*/
circleCoord(c)
{
let radius = this.nodeRadius;
this.ctx.beginPath();
this.ctx.arc(c.x, c.y, radius, 0, 2 * Math.PI, false);
this.ctx.lineWidth = 5;
this.ctx.strokeStyle = 'black';
this.ctx.fillStyle= 'green';
this.ctx.fill();
this.ctx.stroke();
}
/**
* Draw the current state of the widget to the screen.
*/
draw()
{
// First we need to clear the old background
// Store the current transformation matrix
this.ctx.save();
// Use the identity matrix while clearing the canvas
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Restore the transform
this.ctx.restore();
// Now we can actually start drawing
// draw all the nodes
for (let i = 0; i < this.nodeList.length; i++) this.nodeList[i].draw(this.ctx);
// draw the working edge if it exists
if (this.workingEdge != null) this.workingEdge.draw(this.ctx);
// draw all the edges
for (let i = 0; i < this.edgeList.length; i++) this.edgeList[i].draw(this.ctx);
// Draw the outlines for the canvas too
//this.drawRectangleOutline({x:0,y:0},{x:this.localWidth,y:this.localHeight});
// Now we want to draw the outlines for the helper text on top of the canvas
// Store the current transformation matrix
this.ctx.save();
// Use the identity matrix while clearing the canvas
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
// Draw outline and helper text to fixed positions in viewport
this.helperText();
this.drawRectangleOutline({x:0,y:0},{x:this.canvas.width,y:this.canvas.height});
// Restore the transform
this.ctx.restore();
}
/**
* Prints helper text to the top right corner of the widget.
*/
helperText()
{
// Draw text showing the mode
let text = "";
if (this.inputMode == "NODE") text = "Node mode. Press h for keybinds ";
else if (this.inputMode == "EDGE") text = "Edge mode. Press h for keybinds ";
else if (this.inputMode == "DELETE") text = "Delete mode. Press h for keybinds ";
this.ctx.font = "bold 25px Arial";
this.ctx.fillStyle = 'black';
let textHeight = this.ctx.measureText('M').width; // The width of capital M approximates height
let textWidth = this.ctx.measureText(text).width;
this.ctx.fillText(text,this.canvas.width-textWidth,textHeight);
text = "inputs: " + this.curInputs + ", outputs: " + this.curOutputs + ", name: "+this.curName+" ";
textWidth = this.ctx.measureText(text).width;
this.ctx.fillText(text,this.canvas.width-textWidth,2*textHeight);
text = "translate amount: " +this.translateAmt +", zoom amount: " + this.scaleAmt.toFixed(2);
textWidth = this.ctx.measureText(text).width;
this.ctx.fillText(text,this.canvas.width-textWidth,3*textHeight);
text = "output style: " +this.curOutputStyle;
textWidth = this.ctx.measureText(text).width;
this.ctx.fillText(text,this.canvas.width-textWidth,4*textHeight);
}
/**
* Converts the coordinates of the input point in screen coordinates to local/world coordinates.
* @param {object} p - Point to convert.
* @returns A new point with transformed x and y coords.
*/
screenToWorldCoords(p)
{
// get and invert the canvas xform coords, then apply them to the input point
return this.ctx.getTransform().invertSelf().transformPoint(p);
}
/**
* Draw a rectangle outline with the given points.
* @param {object} c1 - Object denoting top left coord of rectangle.
* @param {object} c2 - Object denoting bottom right coord of rectangle.
*/
drawRectangleOutline(c1,c2)
{
this.ctx.beginPath();
this.ctx.moveTo(c1.x,c1.y);
this.ctx.lineTo(c1.x,c2.y);
this.ctx.lineTo(c2.x,c2.y);
this.ctx.lineTo(c2.x,c1.y);
this.ctx.lineTo(c1.x,c1.y);
this.ctx.lineWidth = 6;
this.ctx.strokeStyle = 'black';
this.ctx.stroke();
}
/**
* Render the graph described by the graph into properly formed csound instrument code.
* @returns The string containing the above mentioned code.
*/
renderToText()
{
// Get all of the nodes with no outputs first
let startNodes = Array();
for (let i = 0; i < this.nodeList.length; i++)
if (this.nodeList[i].outputNodeCount() == 0)
{
startNodes.push(this.nodeList[i]);
}
// If there are no nodes with outputs then the instrument is not in a printable state
if (startNodes.length == 0)
{
console.log("Error: Instrument has no well defined outputs");
return;
}
// The string we will output
let outString = "";
// Print the instrument name using csound instrument name syntax
outString = "instr "+this.instrumentName+"\n";
// Print the instrument nodes
for (let i = 0; i < startNodes.length; i++)
if (!startNodes[i].getPrintFlag())
outString += startNodes[i].renderToText();
// Print endin to signify end of instrument block
outString += "endin\n";
// Reset all the printflags for later prints
for (let i = 0; i < this.nodeList.length; i++) this.nodeList[i].setPrintFlag(false);
// Return the string containing our instrument code
return outString;
}
/**
* Get the name of the instrument associated with this parameter widget.
* @returns The above mentioned name.
*/
getName()
{
return this.instrumentName;
}
/**
* Output a data format representing the current graph. The format is organized into sections delimited by lines of #s.
* The first section contains graph structure data with nodes replaced by their indices in the nodeList.
* The next sections contain node data with output/input adjacency list nodes replaced by indices.
* the last two sections are the input/output adjacency lists in terms of the above indices.
* This allows us to recreate the various adjacency lists from scratch when we decide to read this file.
* The individual lines for data are stored using more toText() methods or stringify (though perhaps later)
* we will do this differently.
* @returns the above mentioned format (as a string).
*/
toText()
{
let out = "#".repeat(64) + "\n"; // delimiter
out += "GraphDiagramCanvas\n"; // we need this to distinguish between text and graph widgets
let nodeIndexDict = {}; //dictionary used to assign each node an index
// build list of indices for our nodes as key value pairs
for (let i = 0; i < this.nodeList.length; i++)
nodeIndexDict[this.nodeList[i].getId()] = i;
// output the state of the graph class, skip the nodeList key to avoid circularity
out += JSON.stringify(this, (key,value) => {
if(key=="nodeList")
{
return new Array();
}
else return value;
});
out += "\n";
// output the number of edges/nodes to facilitate reading them back in from the file later
out += this.edgeList.length + "\n";
out += this.nodeList.length + "\n";
// output the edge list
for (let i = 0; i < this.edgeList.length; i++)
{
out += "#".repeat(64) + "\n"; // delimiter
out += this.edgeList[i].toText();
}
// output the individual nodes
for (let i = 0; i < this.nodeList.length; i++)
{
out += "#".repeat(64) + "\n"; // delimiter
out += this.nodeList[i].toText(nodeIndexDict);
}
return out;
}
/**
* Set up the state of the widget based on the input file.
* Takes in a 2d array file[i][j] where i indexes across the # delimited sections specified
* in toText() and j indexes across the individual lines per section.
* @param {object} file - The file as a double array of strings to load the graph from.
*/
reconfigure(file)
{
// get the node and edge list lengths for later reading
let edgeListLength = Number(file[0][file[0].length - 2]);
let nodeListLength = Number(file[0][file[0].length - 1]);
// build the node and edge lists
this.nodeList = new Array();
for (let i = 0; i < nodeListLength; i++) this.nodeList.push(null);
this.edgeList = new Array();
for (let i = 0; i < edgeListLength; i++) this.edgeList.push(null);
// Load the basic variables for this widget
let temp = JSON.parse(file[0][1]);
this.coord = temp.coord;
this.instrumentName = temp.instrumentName;
this.translateAmt = temp.translateAmt;
this.scaleAmt = temp.scaleAmt;
this.nodeRadius = temp.nodeRadius;
// Load the edge list
for (let i = 0; i < edgeListLength; i++)
{
// load edge i from file[i+1];
this.edgeList[i] = new Edge();
this.edgeList[i].reconfigure(file[i+1]);
}
// load the nodes
for (let i = 0; i < nodeListLength; i++)
{
// load node i from file[i+1+edgeListLength]
this.nodeList[i] = new Node({x:0,y:0},"NO NAME",0,[],"EMPTY-STYLE",this.ctx);
this.nodeList[i].reconfigure(file[i+1+edgeListLength]);
}
// set up the node input adjacency lists
for (let i = 0; i < this.nodeList.length; i++)
for (let j = 0; j < this.nodeList[i].inputNodes.length; j++)
if (typeof this.nodeList[this.nodeList[i].inputNodes[j][0]] != "undefined")
this.nodeList[i].inputNodes[j][0] = this.nodeList[this.nodeList[i].inputNodes[j][0]];
else this.nodeList[i].inputNodes[j][0] = null;
// set up the node output adjacency lists
for (let i = 0; i < this.nodeList.length; i++)
for (let j = 0; j < this.nodeList[i].outputNodes.length; j++)
if (typeof this.nodeList[this.nodeList[i].outputNodes[j][0]] != "undefined")
this.nodeList[i].outputNodes[j][0] = this.nodeList[this.nodeList[i].outputNodes[j][0]];
else this.nodeList[i].outputNodes[j][0] = null;
// redraw the screen
this.draw();
}
}
/**
* The edge class defines edges used by our graph diagram class.
* Note that this is mostly geometric as our node class itself recursively stores the nodes connected as inputs/outputs.
* The edge class is used mainly for actual drawing of edges and occasionally collisions info.
* @class
* @public
*/
class Edge
{
/**
* The coord this edge starts from.
*/
from = null;
/**
* The coord this edge ends to.
*/
to = null;
/**
* Polyline list of segments connecting the from/to coords of this edge.
*/
polyLineList = null;
/**
* Used for determining the radius at which collisions can occr.
*/
collisionRadius = 10.0;
/**
* Construct an edge object.
*/
constructor()
{
this.polyLineList = new Array();
}
/**
* Return true if this edge has an empty polyline.
* This occurs only if no points have been added to the current edge for connection.
* @returns true if empty polyline list else false.
*/
empty()
{
return (this.polyLineList.length == 0);
}
/**
* Get the from coord of this edge.
* @retuns the from coord of this edge.
*/
getFrom() { return this.from; }
/**
* Get the to coord of this edge.
* @returns The to coord of this edge.
*/
getTo() { return this.to; }
/**
* Set the from coord of this edge.
* @param {object} n - The coord point to set from with.
*/
setFrom(n)
{
this.from = n;
}
/**
* Set the to coord of this edge.
* @param {object} n - The coord point to set to with.
*/
setTo(n)
{
this.to = n;
}
/**
* Add a segment to the end of our polyline.
* In other words extend the edge by adding a segment to it.
* @param {object} n - The coord point to add to the edge.
*/
addSegment(n)
{
let val = {x: n.x, y:n.y};
this.polyLineList.push(val);
}
/**
* Draw this edge to the supplied canvas context.
* @param {object} ctx - The canvas context to be drawn to.
*/
draw(ctx)
{
for (let i = 1; i < this.polyLineList.length; i++)
{
// drawing edge
let from = this.polyLineList[i-1];
let to = this.polyLineList[i];
ctx.lineWidth = 6;
ctx.strokeStyle = 'black';
ctx.beginPath();
ctx.moveTo(from.x,from.y);
ctx.lineTo(to.x,to.y);
ctx.stroke();
// draw direction arrow at midpoint
let midpt = {x: (from.x+to.x)/2, y: (from.y+to.y)/2};
this.arrow_helper(ctx,from,midpt);
}
}
// this helper is based on SO code.
/**
* Helper to draw arrows on segments to show edge directions.
* @param {object} ctx - The canvas context to be drawn to.
* @param {object} from - Point for from direction of arrow.
* @param {object} to - Point for to direction of arrow.
*/
arrow_helper(ctx,from,to)
{
var headlen = 20; // length of arrow head in pixels
var dx = to.x - from.x;
var dy = to.y - from.y;
var angle = Math.atan2(dy, dx);
ctx.lineWidth = 4;
ctx.strokeStyle = 'black';
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.lineTo(to.x - headlen * Math.cos(angle - Math.PI / 6), to.y - headlen * Math.sin(angle - Math.PI / 6));
ctx.moveTo(to.x, to.y);
ctx.lineTo(to.x - headlen * Math.cos(angle + Math.PI / 6), to.y - headlen * Math.sin(angle + Math.PI / 6));
ctx.stroke();
}
/**
* Reverse the direction of this edge.
*/
reverse()
{
this.polyLineList.reverse(); // reverse the poly line list for this edge
// swap from and to entries
let temp = {x:this.from.x, y:this.from.y};
this.from = {x:this.to.x, y:this.to.y};
this.to = temp;
}
/**
* Detect if the input point collides with this edge.
* @param {object} pt - The point to test edge collision against.
* @returns True if input point collides with this edge and false otherwise.
*/
collision(pt)
{
for (let i = 1; i < this.polyLineList.length; i++)
if (this.intersectSegment(this.polyLineList[i-1],this.polyLineList[i],pt,this.collisionRadius))
return true
return false;
}
// From: https://codereview.stackexchange.com/questions/192477/circle-line-segment-collision?rq=1
/**
* Returns true if line segment thru AB intercepts the circle of given radius at C or false otherwise.
* @param {object} A - The starting point of the segment to detect collision on.
* @param {object} B - The ending point of the segment to detect collision on.
* @param {object} C - The point to test segment collision with.
* @param {number} radius - The radius in which collisions may occur.
* @returns True if line segment thru AB intercepts the circle centered at C of the given radius else false.
*/
intersectSegment(A, B, C, radius)
{
var dist;
const v1x = B.x - A.x;
const v1y = B.y - A.y;
const v2x = C.x - A.x;
const v2y = C.y - A.y;
// get the unit distance along the line of the closest point to
// circle center
const u = (v2x * v1x + v2y * v1y) / (v1y * v1y + v1x * v1x);
// if the point is on the line segment get the distance squared
// from that point to the circle center
if(u >= 0 && u <= 1)
{
dist = (A.x + v1x * u - C.x) ** 2 + (A.y + v1y * u - C.y) ** 2;
}
else
{
// if closest point not on the line segment
// use the unit distance to determine which end is closest
// and get dist square to circle
dist = u < 0 ?
(A.x - C.x) ** 2 + (A.y - C.y) ** 2 :
(B.x - C.x) ** 2 + (B.y - C.y) ** 2;
}
return dist < radius * radius;
}
/**
* Converts the given edge to a textual representation.
* @returns JSON representation of the edge in a string.
*/
toText()
{
let out = JSON.stringify(this);
return out+"\n";
}
/**
* Set up the state of the widget based on the input file.
* @param {object} file - The file as an array of strings to load the edge from.
*/
reconfigure(file)
{
let temp = JSON.parse(file[0]);
this.from = temp.from;
this.to = temp.to;
this.polyLineList = temp.polyLineList;
this.collisionRadius = temp.collisionRadius;
}
}
// TODO: Want to make this a static variable in Node class, but kept get NaN errors
nodeCount = 0; // Tracks the number of nodes we have created so far
/**
* The node class is used to represent the individual nodes of our graphs.
* @class
* @public
*/
class Node
{
/**
* The height of the rectangle representing our node.
*/
height = 100;
/**
* The size of the font for our rectangle.
*/
fontSize = 25;
/**
* The width of the rectangle representing our node.
*/
width = 100;
/**
* The list of input rectangles for the node.
* These rectangles denote the inputs to the node on the main rectangle denoting the node.
*/
inputList = Array(); // the list of input rectangles
/**
* The list of output rectangles for the node.
* These rectangles denote the outputs to the node on the main rectangle denoting the node.
*/
outputList = Array();
/**
* The name of this node.
*/
name = "";
/**
* The location of this node.
*/
pt = {x:0, y:0};
/**
* The list of nodes connected to this one as inputs to this node.
*/
inputNodes = Array();
/**
* The list of nodes connected to this one as outputs from this node.
*/
outputNodes = Array();
/**
* The string containing the types corresponding to the types for each outputs of this node.
* This is a string in csv format.
* Each entry is the variable type prefix from csound of the corresponding output.
* The node outputs are specified in order in the list from left to right.
* See the outputColorMap variables initial values for the support output types (and their associated color coding).
*/
outTypes = "";
// Color palette generated from here: https://mokole.com/palette.html
/**
* Mapping of output type to colors for drawing.
* This allows for the rectangles representing outputs on this node to be color coded based on their type.
*/
outputColorMap = new Map([["a","#006400"],
["k","#00008b"],
["i","#b03060"],
["ga","#ff0000"],
["gk","#ffff00"],
["gi","#00ff00"],
["p","#00ffff"],
["S","#ff00ff"],
["pvs","#6495ed"],
["w","#ffdead"]]);
/**
* A flag used to determine whether or not the current node has been printed by objects using this node class.
*/
printedFlag = false;
/**
* A numerical identifier for the current node.
* Nodes are numbered by their order of creation.
*/
id = -1;
/**
* The type of node this is for output purposes, can be "FUNCTIONAL" or "MACRO" for now.
* In functional mode code is emitted using csound opcode functional notation (without typing).
* In macro mode code is emitted as is but with some inputs substituted for text based macros of the form @
* where n is an integer.
*/
nodeType = "FUNCTIONAL";
/**
* Construct a node instance.
* @param {object} pt - The point containing the location of the node.
* @param {string} name - The name of the node.
* @param {number} inputs - The number of inputs to the node.
* @param {string} outputs - String containing csv format list of output types.
* @param {string} outStyle - The output style of the node (should be "FUNCTIONAL" or "MACRO").
* @param {object} ctx - The html canvas context the node draws itself to.
*/
constructor(pt,name,inputs,outputs,outStyle,ctx)
{
// Generate and assign an id for the current node
this.id = nodeCount++;
this.pt.x = pt.x;
this.pt.y = pt.y;
let rHeight = this.height/3; // Height of rectangle is divided into thirds based on inputs, name, outputs
let rinWidth = this.width/inputs;
let routWidth = this.width/outputs.length;
for (let i = 0; i < inputs; i++) // build the list of input rectangles
{
let topLeft = {x:rinWidth*i, y:0};
let bottomRight = {x:rinWidth*(i+1), y:rHeight};
this.inputList.push([topLeft,bottomRight]);
this.inputNodes.push(null);
}
this.outTypes = outputs;
for (let i = 0; i < outputs.length; i++) // build the list of output rectangles
{
let topLeft = {x:routWidth*i, y:2*rHeight};
let bottomRight = {x:routWidth*(i+1), y:3*rHeight};
this.outputList.push([topLeft,bottomRight]);
this.outputNodes.push(null);
}
// we also need to pick a font height that will fit our box correctly
ctx.font = "bold "+this.fontSize+"px Arial";
while (ctx.measureText('M').width > rHeight) // the width of a capital M approximates height
{
this.fontSize--;
ctx.font = "bold "+this.fontSize+"px Arial";
}
this.nodeType = outStyle;
this.name = name;
}
/**
* If collision between argument pt and an input rectangle occurs return the rectangle midpoint, else return null.
* @param {object} pt - The point to test collision against.
* @returns midpoint of rectangle collision occurs with or null.
*/
collision(pt)
{
// convert input point to local coords
pt.x -= this.pt.x;
pt.y -= this.pt.y;
// check for collisions
for (let i = 0; i < this.inputList.length; i++)
if (this.pointInRectangle(pt,this.inputList[i]))
{
// Take midpoint of collision rectangle
let midx = (this.inputList[i][0].x+this.inputList[i][1].x)/2;
let midy = (this.inputList[i][0].y+this.inputList[i][1].y)/2;
// Convert output point to global coords
midx += this.pt.x;
midy += this.pt.y;
return {x: midx, y: midy};
}
for (let i = 0; i < this.outputList.length; i++)
if (this.pointInRectangle(pt,this.outputList[i]))
{
let midx = (this.outputList[i][0].x+this.outputList[i][1].x)/2;
let midy = (this.outputList[i][0].y+this.outputList[i][1].y)/2;
midx += this.pt.x;
midy += this.pt.y;
return {x: midx, y: midy};
}
// convert input point back to global coords
pt.x += this.pt.x;
pt.y += this.pt.y;
return null;
}
/**
* Checks input argument for collision with an input or output rectangle and returns the type of collision that occurs
* or null if no collision.
* @param {object} pt - The point to test collision against.
* @returns "INPUT" or "OUTPUT" if collision occurs with an input or output rectangle else null if no collision occurs.
*/
collisionType(pt)
{
// convert input point to local coords
pt.x -= this.pt.x;
pt.y -= this.pt.y;
// check for collisions
for (let i = 0; i < this.inputList.length; i++)
if (this.pointInRectangle(pt,this.inputList[i]))
{
pt.x += this.pt.x; // convert input point back to global coords
pt.y += this.pt.y;
return "INPUT";
}
for (let i = 0; i < this.outputList.length; i++)
if (this.pointInRectangle(pt,this.outputList[i]))
{
pt.x += this.pt.x; // convert input point back to global coords
pt.y += this.pt.y;
return "OUTPUT";
}
// convert input point back to global coords
pt.x += this.pt.x;
pt.y += this.pt.y;
return null;
}
/**
* Check if input argument point collides with an input rectangle and return the index of the collision input rectangle if
* collision occurs else returns -1.
* @param {object} pt - The point to test collision against.
* @returns The index of the input rectangle that collision occurs with else -1.
*/
collisionInputParam(pt)
{
// convert input point to local coords
pt.x -= this.pt.x;
pt.y -= this.pt.y;
// check for collisions
for (let i = 0; i < this.inputList.length; i++)
if (this.pointInRectangle(pt,this.inputList[i]))
{
pt.x += this.pt.x; // convert input point back to global coords
pt.y += this.pt.y;
return i;
}
// convert input point back to global coords
pt.x += this.pt.x;
pt.y += this.pt.y;
return -1;
}
/**
* Check if input argument point collides with an output rectangle and return the index of the collision output rectangle if
* collision occurs else returns -1.
* @param {object} pt - The point to test collision against.
* @returns The index of the output rectangle that collision occurs with else -1.
*/
collisionOutputParam(pt)
{
// convert input point to local coords
pt.x -= this.pt.x;
pt.y -= this.pt.y;
for (let i = 0; i < this.outputList.length; i++)
if (this.pointInRectangle(pt,this.outputList[i]))
{
pt.x += this.pt.x; // convert input point back to global coords
pt.y += this.pt.y;
return i;
}
// convert input point back to global coords
pt.x += this.pt.x;
pt.y += this.pt.y;
return -1;
}
/**
* Check if input argument pt collides with node bounding rectangle.
* @param {object} pt - The point to test collision against.
* @returns True if point lies in node bounding rectangle else false.
*/
boundingCollision(pt)
{
let collision = false
// convert input point to local coords
pt.x -= this.pt.x;
pt.y -= this.pt.y;
let origin = {x:0,y:0};
let boundary = {x:this.width,y:this.height};
if (this.pointInRectangle(pt,[origin,boundary])) collision = true;
// convert input point back to global coords
pt.x += this.pt.x;
pt.y += this.pt.y;
return collision;
}
// TODO: There is no point in storing the context this node draws itself to if we're just going to pass in the context each
// draw.
/**
* Draw this node to the supplied canvas context.
* @param {object} ctx - The canvas context to be drawn to.
*/
draw(ctx)
{
// switch to local coords
ctx.translate(this.pt.x,this.pt.y);
// draw outline
let origin = {x:0,y:0};
let boundary = {x:this.width,y:this.height};
this.drawRectangle([origin,boundary],ctx,"black","white");
for (let i = 0; i < this.inputList.length; i++)
{
this.drawRectangle(this.inputList[i],ctx,"black","blue");
}
for (let i = 0; i < this.outputList.length; i++)
{
let outColor = this.outputColorMap.get(this.outTypes[i]);
this.drawRectangle(this.outputList[i],ctx,"black",outColor);
}
//draw name next
let midPt = this.height/2;
let pad = 5;
ctx.font = "bold "+this.fontSize+"px Arial";
ctx.fillStyle = 'black';
ctx.fillText(this.name,0,midPt+pad,this.width);
// switch back to regular coords
ctx.translate(-this.pt.x,-this.pt.y);
}
/**
* Draw this node to the supplied canvas context.
* @param {object} pt - Array of pts containing the rectangle to draw.
* @param {object} ctx - The canvas context to be drawn to.
* @param {string} outlineColor - The color to outline the rectangle with.
* @param {string} fillColor - The color to fill the rectangle with.
*/
drawRectangle(pt,ctx,outlineColor,fillColor)
{
// Now we can draw the rectangle
ctx.beginPath();
ctx.moveTo(pt[0].x,pt[0].y);
ctx.lineTo(pt[0].x,pt[1].y);
ctx.lineTo(pt[1].x,pt[1].y);
ctx.lineTo(pt[1].x,pt[0].y);
ctx.lineTo(pt[0].x,pt[0].y);
ctx.fillStyle = fillColor;
ctx.fill();
// Draw rectangle outlines
ctx.beginPath();
ctx.moveTo(pt[0].x,pt[0].y);
ctx.lineTo(pt[0].x,pt[1].y);
ctx.lineTo(pt[1].x,pt[1].y);
ctx.lineTo(pt[1].x,pt[0].y);
ctx.lineTo(pt[0].x,pt[0].y);
ctx.lineWidth = 2;
ctx.strokeStyle = outlineColor;
ctx.stroke();
}
/**
* Check if the argument pt lies in the argument rectangle.
* @param {object} pt - The point to test.
* @param {object} rect - The rectangle to test the point with.
* @returns True if pt lies in rect else false.
*/
pointInRectangle(pt,rect)
{
let xBound = rect[0].x <= pt.x && pt.x <= rect[1].x;
let yBound = rect[0].y <= pt.y && pt.y <= rect[1].y;
return xBound && yBound;
}
/**
* Return the number of input nodes for this node.
* @returns the number of input nodes for this node.
*/
inputNodeCount()
{
return this.inputNodes.length;
}
/**
* Return the number of output nodes for this node.
* @returns the number of output nodes for this node.
*/
outputNodeCount()
{
return this.outputNodes.length;
}
/**
* Add an node to the list of input nodes of this node.
* We cannot have multiple outputs connect to the same input, so return true if the adding the new node is successful and
* return false otherwise.
* @param {object} node - The node to add to the list of input nodes.
* @param {object} fromParam - The index that the node outputting to this node is outputting from.
* @param {object} toParam - The index this node is receiving input to.
* @returns True if adding the node succeeds else return false.
*/
addInputNode(node,fromParam,toParam)
{
if (this.inputNodes[toParam]!=null) return false;
this.inputNodes[toParam] = [node,fromParam,toParam];
return true;
}
/**
* Add an node to the list of output nodes of this node.
* @param {object} node - The node to add to the list of output nodes.
* @param {object} fromParam - The index that this node is outputting from.
* @param {object} toParam - The index that the node receiving input from this node is receiving input to.
*/
addOutputNode(node,fromParam,toParam)
{
this.outputNodes[fromParam] = [node,fromParam,toParam];
}
/**
* Returns the name of this node.
* @returns The name of this node.
*/
getName()
{
return this.name;
}
/**
* Emit this node and all of its inputs (if they have not already been rendered) as csound code.
* @returns A string containing csound code for this node and all of its inputs.
*/
renderToText()
{
// We recursively build the following string
let outString = "";
// Print the inputs to this node if they have not already been printed
for (let i = 0; i < this.inputNodeCount(); i++)
if (this.inputNodes[i]!=null && !this.inputNodes[i][0].getPrintFlag())
outString += this.inputNodes[i][0].renderToText();
// We can now print the current node
this.printedFlag = true;
// Build the list of outputs
let outputs = "";
for (let i = 0; i < this.outputNodeCount(); i++)
if (i==this.outputNodeCount()-1)
if (this.nodeType == "MACRO") // macro nodes do not include an equal sign on left of opcode by default
outputs += this.outTypes[i]+"_"+this.getId()+"_"+i+" ";
else // functional nodes do include an equal sign on the left of the opcode
outputs += this.outTypes[i]+"_"+this.getId()+"_"+i+" = ";
else
outputs += this.outTypes[i]+"_"+this.getId()+"_"+i+", ";
// Build the output string using the syntax style specified by the nodeType
if (this.nodeType == "FUNCTIONAL") outString += "\t"+outputs+this.functionalSyntaxHelper();
else outString += "\t"+outputs+this.macroSyntaxHelper();
// return the finished string
return outString;
}
/**
* Helper function for rendering the current node in functional mode.
* Functional syntax is always of the form '<outputs> = name(<parameter list>)'.
* Here we build the 'name(<parameter list>)' part.
* @returns A string in the above mentioned format.
*/
functionalSyntaxHelper()
{
// Build the list of input parameters to this opcode
let params = Array();
for (let i = 0; i < this.inputNodeCount(); i++)
if(this.inputNodes[i]!=null)
{
let str = this.inputNodes[i][0].getOutputType(this.inputNodes[i][1]);
str += "_";
str += this.inputNodes[i][0].getId();
str += "_";
str += this.inputNodes[i][1];
params.push(str);
}
else params.push("NULL");
let paramStr = "";
for (let i = 0; i < params.length; i++)
if (i==params.length-1) paramStr += params[i]; // last param has no comma
else paramStr += params[i]+", ";
// Set up parentheses for function notation
if (params.length > 0) paramStr = "("+paramStr+")";
else paramStr = "";
return this.getName()+paramStr+"\n";
}
// Macro nodes allow us to specify parameters using notation @1,...,@n notation
// this allows us to do arithmetic and specify constants as well as to use the more
// traditional csound syntax for opcodes in place of the functional syntax.
// This function finds and replaces @n with its corresponding parameter using a
// regex
/**
* Helper function for rendering the current node in macro mode.
* Macro nodes allow us to specify parameters using notation @1,...,@n notation.
* This allows us to do arithmetic and specify constants in csound as well as to use the more
* traditional csound syntax for opcodes in place of the functional syntax.
* This function finds and replaces @n with its corresponding parameter using a regex.
* @returns A string in the above mentioned format.
*/
macroSyntaxHelper()
{
// Build the list of input parameters to this opcode
let params = Array();
for (let i = 0; i < this.inputNodeCount(); i++)
if(this.inputNodes[i]!=null)
{
let str = this.inputNodes[i][0].getOutputType(this.inputNodes[i][1]);
str += "_";
str += this.inputNodes[i][0].getId();
str += "_";
str += this.inputNodes[i][1];
params.push(str);
}
else params.push("NULL");
let paramText = this.getName();
for (let i = 0; i < params.length; i++)
paramText = paramText.replace(new RegExp("@"+String(i+1),"g"),params[i]);
return paramText+"\n";
}
/**
* Get the output type of the nth output for this node.
* @param {number} n - Index of the nth output.
* @returns The type of the nth output.
*/
getOutputType(n)
{ return this.outTypes[n]; }
/**
* Get the ID of this node.
* @returns The ID of this node.
*/
getId()
{ return this.id; }
/**
* Get the print flag of this node.
* @returns The print flag of this node.
*/
getPrintFlag()
{ return this.printedFlag; }
/**
* Set the print flag of this node.
* @param {boolean} flag - The value to set the flag to.
*/
setPrintFlag(flag)
{ this.printedFlag = flag; }
/**
* Get the nth output node of this node.
* @param {number} n - The node to retrieve from the list of output nodes.
* @returns The corresponding output node.
*/
getOutputNode(n)
{ return this.outputNodes[n]; }
/**
* Get the nth input node of this node.
* @param {number} n - The node to retrieve from the list of input nodes.
* @returns The corresponding input node.
*/
getInputNode(n)
{ return this.inputNodes[n]; }
/**
* Reset the nth output node of this node to null.
* @param {number} n - The node to reset.
*/
resetOutput(n)
{ this.outputNodes[n] = null; }
/**
* Reset the nth input node of this node to null.
* @param {number} n - The node to reset.
*/
resetInput(n)
{ this.inputNodes[n] = null; }
/**
* Render the current node to JSON string.
* This is used for saving/loading nodes to a file.
* to avoid circularity we replace input nodes with their corresponding indices using the nodeIndexDict argument.
* These indices represent the index of the node in the output file. Later when we read this file back
* in these values will allow us to swap out indices for the actual node objects cleanly.
* @param {dictionary} nodeIndexDict - Dictionary containing the node/index mapping mentioned above.
* @returns A textual representation of this node.
*/
toText(nodeIndexDict)
{
// to avoid circularity we replace input nodes with their corresponding indices in the nodeIndexDict
// These indices represent the index of the node in the output file. Later when we read this file back
// in these values will allow us to swap out indices for the actual node objects cleanly.
let out = JSON.stringify(this, (key,value) => {
if(key=="inputNodes" || key=="outputNodes")
{
return value.map((tup)=>{return [nodeIndexDict[tup[0].getId()],tup[1],tup[2]]; })
}
else return value;
});
return out+"\n";
}
/**
* Set up the state of the widget based on the input file.
* @param {object} file - The file as an array of strings to load the node from.
*/
reconfigure(file)
{
let temp = JSON.parse(file[0]);
this.height = temp.height;
this.fontSize = temp.fontSize;
this.width = temp.width;
this.inputList = temp.inputList;
this.outputList = temp.outputList;
this.nodeType = temp.nodeType;
this.name = temp.name;
this.pt = temp.pt;
this.inputNodes = temp.inputNodes;
this.outputNodes = temp.outputNodes;
this.outTypes = temp.outTypes;
}
}