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, width: number, height: number, label: string, meta: Object = {} ){ this.id = id; this.x = x; this.y = y; this.width = width; this.height = height; this.label = label; this.meta = meta; } 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; } } 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: Array = new Array(); 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; constructor(canvasId: string){ 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.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; } 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.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; } 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 != 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; } 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(); } } addNode(id: number, x: number, y: number, label: string, meta: Object = {}){ let textSize = this.ctx.measureText(label); let textHeight = 2 * (textSize.actualBoundingBoxAscent + textSize.actualBoundingBoxDescent); this.nodes.push(new DiagramNode(id, x, y, textSize.width + textHeight, textHeight, label, meta)); } addConnection(A: DiagramNode, B: DiagramNode){ this.connections.push([A, B]); } removeConnection(A: DiagramNode, B: DiagramNode){ this.connections.splice(this.connections.indexOf([A, B]), 1); } 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 }; }