class DiagramNode { id: number; x: number; y: number; label: string; width: number; height: number; hover: boolean = false; inputHover: boolean = false; outputHover: boolean = false; parents: Array; children: Array; meta: Object = {}; constructor( id: number, x: number, y: number, label: string, meta: Object = {} ){ this.id = id; this.x = x; this.y = y; this.label = label; this.meta = meta; this.resize(); } resize(){ let textSize = _diagram.ctx.measureText(this.label); let height = 2 * (textSize.actualBoundingBoxAscent + textSize.actualBoundingBoxDescent); this.width = textSize.width + height; this.height = height; } pointInNode(x: number, y: number){ if (x < this.x){ return false; } if (y < this.y){ return false; } if (x > this.x + this.width) { return false; } if (y > this.y + this.height) { return false; } return true; } pointNearNode(x: number, y: number){ // including the input/output circles if (x < this.x - this.height / 3){ return false; } if (y < this.y){ return false; } if (x > this.x + this.width + this.height / 3){ return false; } if (y > this.y + this.height) { return false; } return true; } getInputCircleXY(){ return [this.x, this.y + this.height / 2] } getOutputCircleXY(){ return [this.x + this.width, this.y + this.height / 2] } pointInInputCircle(x: number, y: number){ let [circleX, circleY] = this.getInputCircleXY() let radiusSqrd = Math.pow(this.height / 3, 2); return Math.pow(x - circleX, 2) + Math.pow(y - circleY, 2) <= radiusSqrd; } pointInOutputCircle(x: number, y: number){ let [circleX, circleY] = this.getOutputCircleXY() let radiusSqrd = Math.pow(this.height / 3, 2); return Math.pow(x - circleX, 2) + Math.pow(y - circleY, 2) <= radiusSqrd; } } class ContextMenuItem { x: number = 0; y: number = 0; hover: boolean = false; callback: (node: DiagramNode) => void = function(node){} label: string; constructor(label: string, callback: (node: DiagramNode) => void){ this.label = label; this.callback = callback; } } class ContextMenu { x: number = 0; y: number = 0; active: boolean = false; mouseOver: boolean = false; textWidth: number = 0; textHeight: number = 0; textMargin: number = 0; width: number = 0; height: number = 0; ctx: CanvasRenderingContext2D; items: Array = new Array(); contextNode: DiagramNode | null = null; constructor(ctx: CanvasRenderingContext2D){ this.ctx = ctx; this.ctx.font = "20px Helvetica"; let textSize = this.ctx.measureText("SomeLongerText"); this.textWidth = textSize.width; this.textHeight = textSize.actualBoundingBoxAscent + textSize.actualBoundingBoxDescent; this.textMargin = this.textWidth / 8; } fitContextMenu(){ this.width = this.textWidth + this.textMargin * 2; this.height = this.textHeight + this.textMargin * (this.items.length + 2) let index = 0; for (let item of this.items){ item.x = this.textMargin; item.y = this.textHeight * index + this.textMargin * (index + 2); index++; } } pointIn(x: number, y: number){ if (x < this.x){ this.mouseOver = false; return false; } if (y < this.y){ this.mouseOver = false; return false; } if (x > this.x + this.width) { this.mouseOver = false; return false; } if (y > this.y + this.height) { this.mouseOver = false; return false; } for (let item of this.items){ if (y >= this.y + item.y - this.textHeight && y <= this.y + item.y + this.textHeight){ item.hover = true; } else { item.hover = false; } } this.mouseOver = true; return true; } clickOn(){ if(this.contextNode == null){ console.warn("No contextNode"); return } for (let item of this.items){ if(item.hover){ item.callback(this.contextNode); } } } draw(){ let cameraX = _diagram.cameraX; let cameraY = _diagram.cameraY; this.ctx.fillStyle = "lightblue"; this.ctx.fillRect(this.x + cameraX, this.y + cameraY, this.width, this.height); for (let item of this.items){ this.ctx.fillStyle = this.mouseOver && item.hover ? "red" : "black"; this.ctx.fillText(item.label, this.x + item.x + cameraX, this.y + item.y + cameraY); } } } let _diagram: Diagrams; function diargramOnResize(){ _diagram.onresize(); } function diagramOnMouseDown(ev: MouseEvent){ _diagram.onmousedown(ev); } function diagramOnMouseUp(ev: MouseEvent){ _diagram.onmouseup(ev); } function diagramOnMouseMove(ev: MouseEvent){ _diagram.onmousemove(ev); } function diagramOnWheel(ev: WheelEvent){ //_diagram.onwheel(ev); } function diagramOnContext(ev: MouseEvent){ ev.preventDefault(); } class Diagrams { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; nodes: Map = new Map(); connections: Array<[DiagramNode, DiagramNode]> = new Array(); cameraX: number = 0; // camera position cameraY: number = 0; mouseX: number = 0; // mouse position on the canvas mouseY: number = 0; worldX: number = 0; // relative mouse position worldY: number = 0; panning: boolean = false; nodeDragging: DiagramNode | null = null; nodeHover: DiagramNode | null = null; makingConnectionNode: DiagramNode | null = null; scale: number = 1.0; editNodeCallback: (node: DiagramNode) => void = function (){}; deleteNodeCallback: (node: DiagramNode) => void = function (){}; contextMenu: ContextMenu; constructor( canvasId: string, editNodeCallback: (node: DiagramNode) => void = function (){}, deleteNodeCallback: (node: DiagramNode) => void = function (){} ){ this.canvas = document.getElementById(canvasId) as HTMLCanvasElement; if (this.canvas === null){ throw `Could not getElementById ${canvasId}`; } let ctx = this.canvas.getContext("2d"); if (ctx === null){ throw `Could not get 2d rendering context` } _diagram = this; this.ctx = ctx; this.contextMenu = new ContextMenu(this.ctx); this.contextMenu.items.push(new ContextMenuItem("Edit", editNodeCallback)) this.contextMenu.items.push(new ContextMenuItem("Delete", deleteNodeCallback)) this.contextMenu.fitContextMenu(); this.ctx.font = "20px Helvetica"; this.canvas.onmousemove = diagramOnMouseMove; this.canvas.onmousedown = diagramOnMouseDown; this.canvas.onmouseup = diagramOnMouseUp; this.canvas.onwheel = diagramOnWheel; this.canvas.oncontextmenu = diagramOnContext; window.onresize = diargramOnResize; this.editNodeCallback = editNodeCallback; this.deleteNodeCallback = deleteNodeCallback; } onmousemove(ev: MouseEvent){ let canvasRect = this.canvas.getBoundingClientRect(); this.mouseX = ev.x - canvasRect.left; this.mouseY = ev.y - canvasRect.top; this.worldX = this.mouseX - this.cameraX; this.worldY = this.mouseY - this.cameraY; if (this.contextMenu.active){ this.contextMenu.pointIn(this.worldX, this.worldY); } else if (this.nodeHover != null){ this.nodeHover.hover = false; this.nodeHover.inputHover = false this.nodeHover.outputHover = false this.nodeHover = null; } if (this.panning){ this.cameraX += ev.movementX; this.cameraY += ev.movementY; } else if (this.nodeDragging != null){ // this.nodeDragging.x = worldX - this.nodeDragging.width / 2; // this.nodeDragging.y = worldY - this.nodeDragging.height / 2; this.nodeDragging.x += ev.movementX; this.nodeDragging.y += ev.movementY; } else { for (let [_, node] of this.nodes){ if (node.pointNearNode(this.worldX, this.worldY)){ if (node.pointInInputCircle(this.worldX, this.worldY)) { node.inputHover = true; this.nodeHover = node; break; } else if (this.makingConnectionNode == null && node.pointInOutputCircle(this.worldX, this.worldY)){ node.outputHover = true; this.nodeHover = node; break; }else if (node.pointInNode(this.worldX, this.worldY)){ node.hover = true; this.nodeHover = node; break; } } } } this.draw(); } onmousedown(ev: MouseEvent){ if (ev.button != 0){ return; } //this.contextMenu.active = false; let canvasRect = this.canvas.getBoundingClientRect(); this.mouseX = ev.x - canvasRect.left; this.mouseY = ev.y - canvasRect.top; this.worldX = this.mouseX - this.cameraX; this.worldY = this.mouseY - this.cameraY; for (let [_, node] of this.nodes){ if (node.pointNearNode(this.worldX, this.worldY)){ if (node.pointInInputCircle(this.worldX, this.worldY)) { // no dragging from inputs ? } else if (node.pointInOutputCircle(this.worldX, this.worldY)){ this.makingConnectionNode = node; return; } } if (node.pointInNode(this.worldX, this.worldY)) { this.nodeDragging = node; return; } } this.panning = true; } onmouseup(ev: MouseEvent){ if (ev.button == 2) { for (let [_, node] of this.nodes){ if (node.pointInNode(this.worldX, this.worldY)){ this.contextMenu.x = this.worldX; this.contextMenu.y = this.worldY; this.contextMenu.active = true; this.contextMenu.contextNode = node; this.draw(); } } } if (ev.button != 0){ return; } this.panning = false; this.nodeDragging = null; if (this.makingConnectionNode !== null){ for (let [_, node] of this.nodes){ if (node == this.makingConnectionNode){ continue; } if(node.pointInInputCircle(this.worldX, this.worldY)){ let connectionExists = false; for (let [output, input] of this.connections) { if (this.makingConnectionNode.id == output.id && node.id == input.id){ connectionExists = true; } } if (!connectionExists){ this.addConnection(this.makingConnectionNode, node); } } } this.makingConnectionNode = null; } if (this.contextMenu.active){ if (this.contextMenu.pointIn(this.worldX, this.worldY)){ this.contextMenu.clickOn(); this.draw(); } this.contextMenu.active = false; } for (let [output, input] of this.connections){ let [outputX, outputY] = output.getOutputCircleXY(); outputX += this.cameraX; outputY += this.cameraY; let [inputX, inputY] = input.getInputCircleXY(); inputX += this.cameraX; inputY += this.cameraY; let dX = Math.abs(outputX - inputX); this.ctx.beginPath(); this.ctx.moveTo(outputX, outputY); this.ctx.strokeStyle = "black"; let cp1x = (outputX + dX / 2); let cp1y = outputY; let cp2x = (inputX - dX / 2); let cp2y = inputY; this.ctx.bezierCurveTo( cp1x, cp1y, cp2x, cp2y, inputX, inputY ); this.ctx.stroke(); this.ctx.closePath(); let halfway = getBezierXY(0.5, outputX, outputY, cp1x, cp1y, cp2x, cp2y, inputX, inputY) let mouseOnHalfway = Math.pow(this.mouseX - halfway.x, 2) + Math.pow(this.mouseY - halfway.y, 2) <= 10*10 if (mouseOnHalfway){ this.removeConnection(output, input); break; } } this.draw(); } onwheel(ev: WheelEvent){ if(ev.deltaY > 0){ return; } this.scale = Math.min(Math.max(this.scale - 0.1, 0.1), 1.0); this.ctx.scale(this.scale, this.scale); } drawBackground(){ this.ctx.fillStyle = "#D8D8D8"; this.ctx.fillRect(0,0,this.canvas.width, this.canvas.height); this.ctx.strokeStyle = "#888"; this.ctx.lineWidth = 5; this.ctx.strokeRect(0, 0, this.canvas.width, this.canvas.height); } draw(){ let scale = 1 / this.scale; this.ctx.clearRect(0,0, this.canvas.width * scale, this.canvas.height * scale); this.drawBackground(); let fullCircleRadians = Math.PI + (Math.PI * 3); if (this.makingConnectionNode != null){ let [circleX, circleY] = this.makingConnectionNode.getOutputCircleXY(); let dX = Math.abs((circleX + this.cameraX) - this.mouseX); this.ctx.beginPath(); this.ctx.moveTo(circleX + this.cameraX, circleY + this.cameraY); this.ctx.strokeStyle = "black"; let cp1x = (circleX + dX / 2) + this.cameraX; let cp1y = circleY + this.cameraY; let cp2x = (this.mouseX - dX / 2); let cp2y = this.mouseY; this.ctx.bezierCurveTo( cp1x, cp1y, cp2x, cp2y, this.mouseX, this.mouseY ); this.ctx.stroke(); this.ctx.closePath(); } for (let [output, input] of this.connections){ let [outputX, outputY] = output.getOutputCircleXY(); outputX += this.cameraX; outputY += this.cameraY; let [inputX, inputY] = input.getInputCircleXY(); inputX += this.cameraX; inputY += this.cameraY; let dX = Math.abs(outputX - inputX); this.ctx.beginPath(); this.ctx.moveTo(outputX, outputY); this.ctx.strokeStyle = "black"; let cp1x = (outputX + dX / 2); let cp1y = outputY; let cp2x = (inputX - dX / 2); let cp2y = inputY; this.ctx.bezierCurveTo( cp1x, cp1y, cp2x, cp2y, inputX, inputY ); this.ctx.stroke(); this.ctx.closePath(); let halfway = getBezierXY(0.5, outputX, outputY, cp1x, cp1y, cp2x, cp2y, inputX, inputY) let mouseOnHalfway = Math.pow(this.mouseX - halfway.x, 2) + Math.pow(this.mouseY - halfway.y, 2) <= 10*10 this.ctx.beginPath(); this.ctx.strokeStyle = mouseOnHalfway ? "red" : "rgba(200, 200, 200, 0.8)"; this.ctx.moveTo(halfway.x - 10, halfway.y - 10); this.ctx.lineTo(halfway.x + 10, halfway.y + 10); this.ctx.moveTo(halfway.x + 10, halfway.y - 10); this.ctx.lineTo(halfway.x - 10, halfway.y + 10); this.ctx.stroke(); this.ctx.closePath(); } for (let [_, node] of this.nodes){ this.ctx.fillStyle = node.hover ? "#303030" : "#161616"; this.ctx.fillRect(node.x + this.cameraX, node.y + this.cameraY, node.width, node.height); this.ctx.fillStyle = "#D3D3D3"; this.ctx.font = "20px Helvetica"; this.ctx.fillText( node.label, node.x + this.cameraX + node.height / 2, node.y + this.cameraY + node.height / 1.5 ); this.ctx.fillStyle = node.inputHover ? "red" : "green"; this.ctx.beginPath() this.ctx.arc(node.x + this.cameraX, node.y + node.height / 2 + this.cameraY, node.height / 3, 0, fullCircleRadians); this.ctx.fill(); this.ctx.beginPath() this.ctx.fillStyle = node.outputHover ? "red" : "green"; this.ctx.moveTo(node.x + node.width + this.cameraX, node.y + node.height / 2 + this.cameraY); this.ctx.arc(node.x + node.width + this.cameraX, node.y + node.height / 2 + this.cameraY, node.height / 3, 0, fullCircleRadians); this.ctx.fill(); } if (this.contextMenu.active){ this.contextMenu.draw(); } } addNode(id: number, x: number, y: number, label: string, meta: Object = {}){ this.nodes.set(id, new DiagramNode(id, x, y, label, meta)); } addConnection(A: DiagramNode, B: DiagramNode){ this.connections.push([A, B]); } addConnectionById(a: number, b: number){ let A = this.nodes.get(a); if (A === undefined){ console.error(`No node with ID: ${a}`); return; } let B = this.nodes.get(b); if (B === undefined){ console.error(`No node with ID: ${b}`); return; } this.connections.push([A, B]) } removeConnection(A: DiagramNode, B: DiagramNode){ let index = 0; for (let [output, input] of this.connections){ if (output.id == A.id && input.id == B.id) { this.connections.splice(index, 1); } index++; } } onresize(){ this.fillParent(); } fillParent(){ this.canvas.width = this.canvas.clientWidth; this.canvas.height = this.canvas.clientHeight; this.draw(); } } // http://www.independent-software.com/determining-coordinates-on-a-html-canvas-bezier-curve.html function getBezierXY(t, sx, sy, cp1x, cp1y, cp2x, cp2y, ex, ey) { return { x: Math.pow(1-t,3) * sx + 3 * t * Math.pow(1 - t, 2) * cp1x + 3 * t * t * (1 - t) * cp2x + t * t * t * ex, y: Math.pow(1-t,3) * sy + 3 * t * Math.pow(1 - t, 2) * cp1y + 3 * t * t * (1 - t) * cp2y + t * t * t * ey }; }