diff --git a/index.html b/index.html index 222c439..b2b115f 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,7 @@ + diff --git a/js/components/connection.js b/js/components/connection.js index 0f99867..30380e5 100644 --- a/js/components/connection.js +++ b/js/components/connection.js @@ -13,31 +13,31 @@ class Connection { getAnchorPoint() { const dx = this.stateB.x - this.stateA.x; const dy = this.stateB.y - this.stateA.y; - const scale = Math.sqrt(dx * dx + dy * dy); + const scale = Math.sqrt(dx ** 2 + dy ** 2); return { x: this.stateA.x + dx * this.parallelPart - dy * this.perpendicularPart / scale, y: this.stateA.y + dy * this.parallelPart + dx * this.perpendicularPart / scale, }; } - setAnchorPoint() { + setAnchorPoint(x, y) { const dx = this.stateB.x - this.stateA.x; const dy = this.stateB.y - this.stateA.y; - const scale = Math.sqrt(dx ^ 2 + dy ^ 2); + const scale = Math.sqrt(dx ** 2 + dy ** 2); - this.parallelPart = (dx * (x - this.stateA.x) + dy * (y - this.stateA.y)) / (scale ^ 2); + this.parallelPart = (dx * (x - this.stateA.x) + dy * (y - this.stateA.y)) / (scale ** 2); this.perpendicularPart = (dx * (y - this.stateA.y) - dy * (x - this.stateA.x)) / scale; - if(this.parallelPart > 0 && this.parallelPart < 1 && Math.abs(this.perpendicularPart) < settings.snapToPadding) { + if (this.parallelPart > 0 && this.parallelPart < 1 && Math.abs(this.perpendicularPart) < settings.snapToPadding) { this.lineAngleAdjust = (this.perpendicularPart < 0) * Math.PI; this.perpendicularPart = 0; } } getEndPointsAndCircle() { - if(this.perpendicularPart === 0) { - const middleX = (this.stateA.x + this.stateB.y) / 2; - const middleY = (this.stateB.y + this.stateB.y) / 2; + if (this.perpendicularPart === 0) { + const middleX = (this.stateA.x + this.stateB.x) / 2; + const middleY = (this.stateA.y + this.stateB.y) / 2; const start = this.stateA.closestPointOnCircle(middleX, middleY); const end = this.stateB.closestPointOnCircle(middleX, middleY); @@ -59,7 +59,7 @@ class Connection { const reverseScale = isReversed ? 1 : -1; const startAngle = Math.atan2(this.stateA.y - circle.y, this.stateA.x - circle.x) - reverseScale * radius / circle.radius; - const endAngle = Math.atan2(this.stateB.y - circle.y, this.stateB.x - circle.y) + reverseScale * radius / circle.radius; + const endAngle = Math.atan2(this.stateB.y - circle.y, this.stateB.x - circle.x) + reverseScale * radius / circle.radius; const startX = circle.x + circle.radius * Math.cos(startAngle); const startY = circle.y + circle.radius * Math.sin(startAngle); @@ -80,24 +80,83 @@ class Connection { } draw() { - const stuff = this.getEndPointsAndCircle(); + const stuff = this.getEndPointsAndCircle(); - ctx.beginPath(); + ctx.beginPath(); + ctx.strokeStyle = ctx.fillStyle = settings.colors.getColor(this); - if(stuff.isCircle) { - ctx.arc(stuff.circle.x, stuff.circle.y, stuff.circle.radius, stuff.startAngle, stuff.endAngle, stuff.isReversed); - } else { - ctx.moveTo(stuff.start.x, stuff.start.y); - ctx.lineTo(stuff.end.x, stuff.end.y); - } + if (stuff.isCircle) { + ctx.arc(stuff.circle.x, stuff.circle.y, stuff.circle.radius, stuff.startAngle, stuff.endAngle, stuff.isReversed); + } else { + ctx.moveTo(stuff.start.x, stuff.start.y); + ctx.lineTo(stuff.end.x, stuff.end.y); + } - ctx.stroke(); + ctx.stroke(); - if(stuff.isCircle) { - ctx.drawArrow(stuff.end.x, stuff.end.y, stuff.endAngle - stuff.reverseScale * (Math.PI / 2)); - } else { - ctx.drawArrow(stuff.end.x, stuff.end.y, Math.atan2(stuff.end.y - stuff.start.y, stuff.end.x - stuff.start.x)); - } + if (stuff.isCircle) { + ctx.drawArrow(stuff.end.x, stuff.end.y, stuff.endAngle - stuff.reverseScale * (Math.PI / 2)); + } else { + ctx.drawArrow(stuff.end.x, stuff.end.y, Math.atan2(stuff.end.y - stuff.start.y, stuff.end.x - stuff.start.x)); + } + + ctx.closePath(); + + // Draws the text onto the line + if(stuff.isCircle) { + let startAngle = stuff.startAngle, + endAngle = stuff.endAngle; + + if(endAngle < startAngle) { + endAngle += Math.PI * 2; + } + + const textAngle = (startAngle + endAngle) / 2 + stuff.isReversed * Math.PI, + textX = stuff.circle.x + stuff.circle.radius * Math.cos(textAngle), + textY = stuff.circle.y + stuff.circle.radius * Math.sin(textAngle); + ctx.drawText(this.text, textX, textY, textAngle, selectedObject === this); + } else { + const textX = (stuff.start.x + stuff.end.x) / 2, + textY = (stuff.start.y + stuff.end.y) / 2, + textAngle = Math.atan2(stuff.end.x - stuff.start.x, stuff.start.y - stuff.end.y); + ctx.drawText(this.text, textX, textY, textAngle + this.lineAngleAdjust, selectedObject === this); + } + } + + containsPoint(x, y) { + const endPoints = this.getEndPointsAndCircle(); + + if (endPoints.isCircle) { + const dx = x - endPoints.circle.x, + dy = y - endPoints.circle.y, + distance = Math.sqrt(dx ** 2 + dy ** 2) - endPoints.circle.radius; + + if (Math.abs(distance) < settings.hitTargetPadding) { + let angle = Math.atan2(dy, dx), + startAngle = endPoints.startAngle, + endAngle = endPoints.endAngle; + if (endPoints.isReversed) { + [startAngle, endAngle] = [endAngle, startAngle]; + } + if (endAngle < startAngle) { + endAngle += Math.PI * 2; + } + if (angle < startAngle) { + angle += Math.PI * 2; + } else if (angle > endAngle) { + angle -= Math.PI * 2; + } + return (angle > startAngle && angle < endAngle); + } + } else { + const dx = endPoints.end.x - endPoints.start.x, + dy = endPoints.end.y - endPoints.start.y, + length = Math.sqrt(dx ** 2 + dy ** 2), + percent = (dx * (x - endPoints.start.x) + dy * (y - endPoints.start.y)) / (length ** 2), + distance = (dx * (y - endPoints.start.y) - dy * (x - endPoints.start.x)) / length; + return (percent > 0 && percent < 1 && Math.abs(distance) < settings.hitTargetPadding); + } + return false; } } diff --git a/js/components/self-connection.js b/js/components/self-connection.js index d11397a..6b70c88 100644 --- a/js/components/self-connection.js +++ b/js/components/self-connection.js @@ -63,13 +63,28 @@ class SelfConnection { draw() { const endPoints = this.getEndPointsAndCircle(); + ctx.strokeStyle = ctx.fillStyle = settings.colors.getColor(this); + ctx.beginPath(); ctx.arc(endPoints.circle.x, endPoints.circle.y, endPoints.circle.radius, endPoints.startAngle, endPoints.endAngle); - ctx.stroke(); + ctx.closePath(); + + const textX = endPoints.circle.x + endPoints.circle.radius * Math.cos(this.anchorAngle), + textY = endPoints.circle.y + endPoints.circle.radius * Math.sin(this.anchorAngle); + ctx.drawText(this.text, textX, textY, this.anchorAngle, selectedObject === this); + ctx.drawArrow(endPoints.end.x, endPoints.end.y, endPoints.endAngle + Math.PI * .4); } + containsPoint(x, y) { + const endPoints = this.getEndPointsAndCircle(), + dx = x - endPoints.circle.x, + dy = y - endPoints.circle.y, + distance = Math.sqrt(dx ** 2 + dy ** 2) - endPoints.circle.radius; + return Math.abs(distance) < settings.hitTargetPadding; + } + } diff --git a/js/components/start-connection.js b/js/components/start-connection.js index e39ac8f..f5f44bc 100644 --- a/js/components/start-connection.js +++ b/js/components/start-connection.js @@ -37,14 +37,32 @@ class StartConnection { } draw() { - const endPoints = this.getEndPoints(); + const points = this.getEndPoints(); + + ctx.strokeStyle = ctx.fillStyle = settings.colors.getColor(this); ctx.beginPath(); - ctx.moveTo(endPoints.start.x, endPoints.start.y); - ctx.lineTo(endPoints.end.x, endPoints.end.y); - + ctx.moveTo(points.start.x, points.start.y); + ctx.lineTo(points.end.x, points.end.y); ctx.stroke(); + + ctx.closePath(); + + const textAngle = Math.atan2(points.start.y - points.end.y, points.start.x - points.end.x); + ctx.drawText(this.text, points.start.x, points.start.y, textAngle, selectedObject === this); + + ctx.drawArrow(points.end.x, points.end.y, Math.atan2(-this.deltaY, -this.deltaX)) + } + + containsPoint(x, y) { + const points = this.getEndPoints(); + const dx = points.end.x - points.start.x, + dy = points.end.y - points.start.y, + length = Math.sqrt(dx ** 2 + dy ** 2), + percent = (dx * (x - points.start.x) + dy * (y - points.start.y)) / (length ** 2), + distance = (dx * (y - points.start.y) - dy * (x - points.start.x)) / length; + return (percent > 0 && percent < 1 && Math.abs(distance) < settings.hitTargetPadding); } } diff --git a/js/components/state.js b/js/components/state.js index 8aea642..34aa6e2 100644 --- a/js/components/state.js +++ b/js/components/state.js @@ -1,28 +1,48 @@ class State { - constructor(x, y, color) { + constructor(x, y) { this.x = x; this.y = y; - this.color = color; + this.mouseOffsetX = 0; + this.mouseOffsetY = 0; + this.color = '#f0f'; + this.isAcceptState = false; this.text = ''; } + setMouseStart(x, y) { + this.mouseOffsetX = this.x - x; + this.mouseOffsetY = this.y - y; + } + + setAnchorPoint(x, y) { + this.x = x + this.mouseOffsetX; + this.y = y + this.mouseOffsetY; + } + draw() { ctx.fillStyle = this.color; - ctx.strokeStyle = '#000'; + ctx.strokeStyle = settings.colors.getColor(this); ctx.beginPath(); - ctx.arc(this.x, this.y, radius, 0, 360); + ctx.arc(this.x, this.y, radius, 0, 2 * Math.PI); ctx.fill(); ctx.stroke(); ctx.closePath(); - } - jumpTo(x, y) { - this.x = Math.min(Math.max(x, 0), width); - this.y = Math.min(Math.max(y, 0), height); + ctx.fillStyle = settings.colors.getColor(this); + ctx.drawText(this.text, this.x, this.y, null, selectedObject === this); + + if(this.isAcceptState) { + ctx.beginPath(); + + ctx.arc(this.x, this.y, radius - 6, 0, 2 * Math.PI); + ctx.stroke(); + + ctx.closePath(); + } } moveTo(x, y) { @@ -75,11 +95,15 @@ class State { closestPointOnCircle(x, y) { const dx = x - this.x; const dy = y - this.y; - const scale = Math.sqrt(dx ^ 2 + dy ^ 2); + const scale = Math.sqrt(dx ** 2 + dy ** 2); return { x: this.x + dx * radius / scale, y: this.y + dy * radius / scale, }; } + containsPoint(x, y) { + return (x - this.x) ** 2 + (y - this.y) ** 2 < radius ** 2; + } + } diff --git a/js/components/temporary-connection.js b/js/components/temporary-connection.js new file mode 100644 index 0000000..6433ea0 --- /dev/null +++ b/js/components/temporary-connection.js @@ -0,0 +1,22 @@ +class TemporaryConnection { + + constructor(from, to) { + this.from = from; + this.to = to; + } + + draw() { + ctx.beginPath(); + + ctx.strokeStyle = '#000'; + ctx.fillStyle = '#000'; + + ctx.moveTo(this.to.x, this.to.y); + ctx.lineTo(this.from.x, this.from.y); + + ctx.stroke(); + + ctx.drawArrow(this.to.x, this.to.y, Math.atan2(this.to.y - this.from.y, this.to.x - this.from.x)); + } + +} \ No newline at end of file diff --git a/js/export.js b/js/export.js new file mode 100644 index 0000000..e69de29 diff --git a/js/main.js b/js/main.js index e895ab3..323a65d 100644 --- a/js/main.js +++ b/js/main.js @@ -1,11 +1,17 @@ -const width = 600; -const height = 300; +const width = 800; +const height = 600; const radius = 25; const settings = { physics: true, speed: 2, - snapToPadding: 10 + snapToPadding: 6, + hitTargetPadding: 6, + colors: { + default: '#000', + active: '#00f', + getColor: (object) => selectedObject === object ? settings.colors.active : settings.colors.default + } }; const canvas = document.getElementById('canvas'); @@ -17,6 +23,17 @@ canvas.height = height; const states = []; const connections = []; +let isPaused = false; + +let caretTimer, + caretVisible = true; + +function resetCaret() { + clearInterval(caretTimer); + caretTimer = setInterval(() => caretVisible = !caretVisible, 500); + caretVisible = true; +} + function tick() { states.forEach(stateA => { states.forEach(stateB => { @@ -42,132 +59,206 @@ function tick() { } }); - setTimeout(tick, 1000 / 30); + if (!isPaused) + setTimeout(tick, 1000 / 30); } function draw() { ctx.clearRect(0, 0, width, height); - - connections.forEach(connection => connection.draw()); + ctx.save(); + ctx.translate(0.5, 0.5); states.forEach(state => state.draw()); - setTimeout(draw, 1000 / 60); + connections.forEach(connection => connection.draw()); + + if (!!currentConnection) { + currentConnection.draw(); + } + + ctx.restore(); + + if (!isPaused) + setTimeout(draw, 1000 / 60); } function selectObject(x, y) { for (let state of states) { - if ((x - state.x) * (x - state.x) + (y - state.y) * (y - state.y) < radius * radius) { + if (state.containsPoint(x, y)) { return state; } } + + for (let connection of connections) { + if (connection.containsPoint(x, y)) { + return connection; + } + } + + return null; } -function getStateByPos(x, y) { -} - -function checkForState(x, y) { - return !!getStateByPos(x, y); -} - -function addState(x, y) { - const state = new State(x, y, '#f0f'); - states.push(state); -} - -function addConnection(stateA, stateB, angleA, angleB) { - const connection = new Connection(stateA, stateB, angleA, angleB); - connections.push(connection); -} - -let isMouseDown = false, - isDragging = false, - lastDrag = 0, +let cursorVisible = true, selectedObject = null, - shiftPressed = false, currentConnection = null, - movingObject = false; + movingObject = false, + originalClick = null, + deltaMouseX = 0, + deltaMouseY = 0, + shiftPressed = false; canvas.addEventListener('mousedown', (event) => { - selectedObject = selectObject(event.x, event.y); - - shiftPressed = event.shiftKey; + const mouse = cbRelMousePos(event); + selectedObject = selectObject(mouse.x, mouse.y); + movingObject = false; + originalClick = mouse; if (!!selectedObject) { if (shiftPressed && selectedObject instanceof State) { - currentConnection = new SelfConnection(selectedObject, event); + currentConnection = new SelfConnection(selectedObject, mouse); } else { movingObject = true; + deltaMouseX = deltaMouseY = 0; + if (selectedObject.setMouseStart) { + selectedObject.setMouseStart(mouse.x, mouse.y); + } } + resetCaret(); + } else if (shiftPressed) { + currentConnection = new TemporaryConnection(mouse, mouse); + } + + if (canvasHasFocus()) { + return false; + } else { + resetCaret(); + return true; } }); canvas.addEventListener('mousemove', (event) => { - if (isMouseDown && !isDragging) - isDragging = true; - - if (isMouseDown && isDragging) { - if (!shiftPressed) { - selectedObject.jumpTo(event.clientX, event.clientY); - } - - selectedObject.setAnchorPoint(event.clientX, event.clientY); - } + const mouse = cbRelMousePos(event); if (!!currentConnection) { - let targetState = selectObject(event.x, event.y); - - if (!targetState instanceof State) { - targetState = null; + let targetNode = selectObject(mouse.x, mouse.y); + if (!(targetNode instanceof State)) { + targetNode = null; } - if (!!selectedObject) { - if (targetState === selectedObject) { - currentConnection = new SelfConnection(selectedObject, event); - } else if(!!targetState) { - currentConnection = new Connection(selectedObject, targetState); + if (!selectedObject) { + if (!!targetNode) { + currentConnection = new StartConnection(targetNode, originalClick); } else { - // currentConnection = new + currentConnection = new TemporaryConnection(originalClick, mouse); } } else { + if (targetNode === selectedObject) { + currentConnection = new SelfConnection(selectedObject, mouse); + } else if (!!targetNode) { + currentConnection = new Connection(selectedObject, targetNode); + } else { + currentConnection = new TemporaryConnection(selectedObject.closestPointOnCircle(mouse.x, mouse.y), mouse) + } + } + } + if (movingObject) { + selectedObject.setAnchorPoint(mouse.x, mouse.y); + if (selectedObject instanceof State) { + snapNode(selectedObject); } } }); canvas.addEventListener('mouseup', (event) => { - if (isDragging) { - lastDrag = Date.now() - } + movingObject = false; - if (shiftPressed) { - const state = getStateByPos(event.clientX, event.clientY); - addConnection(selectedObject, state, Math.PI, -Math.PI); - } - - isMouseDown = false; - isDragging = false; - selectedObject = null; -}); - -canvas.addEventListener('click', (event) => { - const x = event.clientX, - y = event.clientY; - - if (Date.now() - lastDrag < 100) { - return; - } - - if (checkForState(x, y)) { + if (!!currentConnection) { + if (!(currentConnection instanceof TemporaryConnection)) { + selectedObject = currentConnection; + connections.push(currentConnection); + resetCaret(); + } + currentConnection = null; } }); canvas.addEventListener('dblclick', (event) => { - const x = event.clientX, - y = event.clientY; + const mouse = cbRelMousePos(event); + selectedObject = selectObject(mouse.x, mouse.y); - addState(x, y); + if (!selectedObject) { + selectedObject = new State(mouse.x, mouse.y); + states.push(selectedObject); + resetCaret(); + } else if (selectedObject instanceof State) { + selectedObject.isAcceptState = !selectedObject.isAcceptState; + } +}); + +document.addEventListener('keydown', (event) => { + const key = crossBrowserKey(event); + + if (!canvasHasFocus()) { + return true; + } + + switch (key) { + case 16: // Shift + shiftPressed = true; + break; + case 8: // Backspace + if (!!selectedObject) { + selectedObject.text = selectedObject.text.substr(0, selectedObject.text.length - 1); + resetCaret(); + } + + return false; + case 46: // Delete + if (!!selectedObject) { + if(selectedObject instanceof State) { + states.splice(states.findIndex(state => state === selectedObject), 1); + } + + for (let i = 0; i < connections.length; i++) { + const con = connections[i]; + if (con === selectedObject || con.state === selectedObject || con.stateA === selectedObject || con.stateB === selectedObject) { + connections.splice(i--, 1); + } + } + + selectedObject = null; + } + break; + } +}); + +document.addEventListener('keyup', (event) => { + const key = crossBrowserKey(event); + + if (key === 16) { + shiftPressed = false; + } +}); + +document.addEventListener('keypress', (event) => { + const key = crossBrowserKey(event); + + if(!canvasHasFocus()) { + return true; + } + + if(key >= 0x20 && key <= 0x7E && !event.metaKey && !event.altKey && !event.ctrlKey && !!selectedObject && 'text' in selectedObject) { + selectedObject.text += String.fromCharCode(key); + resetCaret(); + + return false; + } + + if(key === 8) { + return false; + } }); CanvasRenderingContext2D.prototype.drawArrow = function (x, y, angle) { @@ -183,5 +274,97 @@ CanvasRenderingContext2D.prototype.drawArrow = function (x, y, angle) { this.fill(); }; +CanvasRenderingContext2D.prototype.drawText = function(originalText, x, y, angleOrNull, isSelected) { + const text = originalText; + this.font = '20px Roboto'; + const width = this.measureText(text).width; + + x -= width / 2; // Centers the text + + if(!!angleOrNull) { + const dx = Math.cos(angleOrNull), + dy = Math.sin(angleOrNull), + cornerPointX = (width / 2 + 5) * (dx > 0 ? 1 : -1), + cornerPointY = (10 + 5) * (dy > 0 ? 1 : -1), + slide = dy * (Math.abs(dy) ** 40) * cornerPointX - dx * (Math.abs(dx) ** 10) * cornerPointY; + x += cornerPointX - dy * slide; + y += cornerPointY + dx * slide; + } + + // Draw text and caret + if('advancedFillText' in this) { + // this.advancedFillText() + } else { + x = Math.round(x); + y = Math.round(y); + this.fillText(text, x, y + 6); + if(isSelected && caretVisible && canvasHasFocus() && document.hasFocus()) { + x += width; + + this.beginPath(); + + this.moveTo(x, y - 10); + this.lineTo(x, y + 10); + + this.stroke(); + this.closePath(); + } + } +}; + +function snapNode(targetState) { + for (let state of states) { + if (state === targetState) + continue; + + if (Math.abs(targetState.x - state.x) < settings.snapToPadding) { + targetState.x = state.x; + } + + if (Math.abs(targetState.y - state.y) < settings.snapToPadding) { + targetState.y = state.y; + } + } +} + +function canvasHasFocus() { + return (document.activeElement || document.body) === document.body; +} + +function crossBrowserKey(event) { + event = event || window.event; + return event.which || event.keyCode; +} + +function cbElementPos(event) { + event = event || window.event; + let obj = event.target || event.srcElement, + x = 0, + y = 0; + while (obj.offsetParent) { + x += obj.offsetLeft; + y += obj.offsetTop; + obj = obj.offsetParent; + } + return {x, y}; +} + +function cbMousePos(event) { + event = event || window.event; + return { + x: event.pageX || event.clientX + document.body.scrollLeft + document.documentElement.scrollLeft, + y: event.pageY || event.clientY + document.body.scrollTop + document.documentElement.scrollTop, + } +} + +function cbRelMousePos(event) { + const elem = cbElementPos(event), + mouse = cbMousePos(event); + return { + x: mouse.x - elem.x, + y: mouse.y - elem.y, + }; +} + tick(); draw(); diff --git a/js/math.js b/js/math.js index 9ac4020..a2d8f75 100644 --- a/js/math.js +++ b/js/math.js @@ -1,5 +1,5 @@ function det(a, b, c, d, e, f, g, h, i) { - return (a * e * i) + (b * f * g) + (c * d * h) - (a * f * h) - (b * d * i) - (c * e * g); + return a * e * i + b * f * g + c * d * h - a * f * h - b * d * i - c * e * g; } function circleFromPoints(p1, p2, p3) { @@ -11,6 +11,6 @@ function circleFromPoints(p1, p2, p3) { return { x: -bx / (2 * a), y: -by / (2 * a), - radius: Math.sqrt(bx ^ 2 + by ^ 2 - 4 * a * c) / (2 * Math.abs(a)), + radius: Math.sqrt(bx ** 2 + by ** 2 - 4 * a * c) / (2 * Math.abs(a)), }; }