From 4e84cf7e4736c8db5427c221e3276b5b168bef4d Mon Sep 17 00:00:00 2001 From: RareDrops <54132759+RareDrops@users.noreply.github.com> Date: Thu, 3 Apr 2025 20:14:48 +1300 Subject: [PATCH 01/18] Update build.py --- build.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/build.py b/build.py index 51ac835..fa98119 100755 --- a/build.py +++ b/build.py @@ -1,4 +1,5 @@ #!/usr/bin/python +# -*- coding: utf-8 -*- import os, time, sys @@ -8,10 +9,18 @@ def sources(): def build(): path = './www/fsm.js' - data = '\n'.join(open(file, 'r').read() for file in sources()) - with open(path, 'w') as f: - f.write(data) - print 'built %s (%u bytes)' % (path, len(data)) + try: + data = '\n'.join(open(file, 'r', encoding='utf-8').read() for file in sources()) + with open(path, 'w', encoding='utf-8') as f: + f.write(data) + print('built %s (%u bytes)' % (path, len(data))) + except Exception as e: + print('Error:', str(e)) + # Try alternative encoding if utf-8 fails + data = '\n'.join(open(file, 'r', encoding='latin-1').read() for file in sources()) + with open(path, 'w', encoding='utf-8') as f: + f.write(data) + print('built %s (%u bytes) using latin-1 fallback' % (path, len(data))) def stat(): return [os.stat(file).st_mtime for file in sources()] From 287c39b5ea99ac38a4876eb523b314f5a55d8054 Mon Sep 17 00:00:00 2001 From: RareDrops <54132759+RareDrops@users.noreply.github.com> Date: Thu, 3 Apr 2025 20:14:52 +1300 Subject: [PATCH 02/18] Update fsm.js --- src/main/fsm.js | 193 +++++++++++++++++++++++++++--------------------- 1 file changed, 107 insertions(+), 86 deletions(-) diff --git a/src/main/fsm.js b/src/main/fsm.js index 39b03fe..5e33d0f 100644 --- a/src/main/fsm.js +++ b/src/main/fsm.js @@ -1,34 +1,55 @@ -var greekLetterNames = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', 'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu', 'Nu', 'Xi', 'Omicron', 'Pi', 'Rho', 'Sigma', 'Tau', 'Upsilon', 'Phi', 'Chi', 'Psi', 'Omega' ]; - function convertLatexShortcuts(text) { - // html greek characters - for(var i = 0; i < greekLetterNames.length; i++) { - var name = greekLetterNames[i]; - text = text.replace(new RegExp('\\\\' + name, 'g'), String.fromCharCode(913 + i + (i > 16))); - text = text.replace(new RegExp('\\\\' + name.toLowerCase(), 'g'), String.fromCharCode(945 + i + (i > 16))); + // Greek letters + const greekLetters = { + '\\alpha': 'α', '\\beta': 'β', '\\gamma': 'γ', '\\delta': 'δ', '\\epsilon': 'ε', + '\\zeta': 'ζ', '\\eta': 'η', '\\theta': 'θ', '\\iota': 'ι', '\\kappa': 'κ', + '\\lambda': 'λ', '\\mu': 'μ', '\\nu': 'ν', '\\xi': 'ξ', '\\pi': 'π', + '\\rho': 'ρ', '\\sigma': 'σ', '\\tau': 'τ', '\\upsilon': 'υ', '\\phi': 'φ', + '\\chi': 'χ', '\\psi': 'ψ', '\\omega': 'ω', + '\\Gamma': 'Γ', '\\Delta': 'Δ', '\\Theta': 'Θ', '\\Lambda': 'Λ', '\\Xi': 'Ξ', + '\\Pi': 'Π', '\\Sigma': 'Σ', '\\Phi': 'Φ', '\\Psi': 'Ψ', '\\Omega': 'Ω' + }; + + // Basic mathematical operators + const operators = { + '\\times': '×', '\\div': '÷', '\\pm': '±', '\\mp': '∓', + '\\leq': '≤', '\\geq': '≥', '\\neq': '≠', '\\approx': '≈', + '\\infty': '∞', '\\sum': '∑', '\\prod': '∏', '\\int': '∫', + // Set theory symbols + '\\cup': '∪', '\\cap': '∩', '\\subset': '⊂', '\\supset': '⊃', + '\\subseteq': '⊆', '\\supseteq': '⊇', '\\in': '∈', '\\notin': '∉', + '\\emptyset': '∅', '\\varnothing': '∅', + // Additional operators + '\\cdot': '·', '\\bullet': '•', '\\circ': '∘', '\\oplus': '⊕', + '\\otimes': '⊗', '\\setminus': '∖', '\\subsetneq': '⊊', '\\supsetneq': '⊋' + }; + + // Replace Greek letters + for (const [latex, unicode] of Object.entries(greekLetters)) { + text = text.replace(new RegExp(latex, 'g'), unicode); } - // subscripts - for(var i = 0; i < 10; i++) { - text = text.replace(new RegExp('_' + i, 'g'), String.fromCharCode(8320 + i)); + // Replace operators + for (const [latex, unicode] of Object.entries(operators)) { + text = text.replace(new RegExp(latex, 'g'), unicode); } return text; } - -function textToXML(text) { - text = text.replace(/&/g, '&').replace(//g, '>'); - var result = ''; - for(var i = 0; i < text.length; i++) { - var c = text.charCodeAt(i); - if(c >= 0x20 && c <= 0x7E) { - result += text[i]; - } else { - result += '&#' + c + ';'; - } - } - return result; -} + +function textToXML(text) { + text = text.replace(/&/g, '&').replace(//g, '>'); + var result = ''; + for(var i = 0; i < text.length; i++) { + var c = text.charCodeAt(i); + if(c >= 0x20 && c <= 0x7E) { + result += text[i]; + } else { + result += '&#' + c + ';'; + } + } + return result; +} function drawArrow(c, x, y, angle) { var dx = Math.cos(angle); @@ -39,12 +60,12 @@ function drawArrow(c, x, y, angle) { c.lineTo(x - 8 * dx - 5 * dy, y - 8 * dy + 5 * dx); c.fill(); } - -function canvasHasFocus() { - return (document.activeElement || document.body) == document.body; -} -function drawText(c, originalText, x, y, angleOrNull, isSelected) { +function canvasHasFocus() { + return (document.activeElement || document.body) == document.body; +} + +function drawText(c, originalText, x, y, angleOrNull, isSelected) { text = convertLatexShortcuts(originalText); c.font = '20px "Times New Roman", serif'; var width = c.measureText(text).width; @@ -63,13 +84,13 @@ function drawText(c, originalText, x, y, angleOrNull, isSelected) { y += cornerPointY + cos * slide; } - // draw text and caret (round the coordinates so the caret falls on a pixel) + // draw text and caret (round the coordinates so the caret falls on a pixel) if('advancedFillText' in c) { - c.advancedFillText(text, originalText, x + width / 2, y, angleOrNull); + c.advancedFillText(text, originalText, x + width / 2, y, angleOrNull); } else { x = Math.round(x); - y = Math.round(y); - c.fillText(text, x, y + 6); + y = Math.round(y); + c.fillText(text, x, y + 6); if(isSelected && caretVisible && canvasHasFocus() && document.hasFocus()) { x += width; c.beginPath(); @@ -101,8 +122,8 @@ var selectedObject = null; // either a Link or a Node var currentLink = null; // a Link var movingObject = false; var originalClick; - -function drawUsing(c) { + +function drawUsing(c) { c.clearRect(0, 0, canvas.width, canvas.height); c.save(); c.translate(0.5, 0.5); @@ -124,12 +145,12 @@ function drawUsing(c) { } c.restore(); -} - -function draw() { - drawUsing(canvas.getContext('2d')); - saveBackup(); -} +} + +function draw() { + drawUsing(canvas.getContext('2d')); + saveBackup(); +} function selectObject(x, y) { for(var i = 0; i < nodes.length; i++) { @@ -160,7 +181,7 @@ function snapNode(node) { } window.onload = function() { - canvas = document.getElementById('canvas'); + canvas = document.getElementById('canvas'); restoreBackup(); draw(); @@ -186,14 +207,14 @@ window.onload = function() { } draw(); - - if(canvasHasFocus()) { - // disable drag-and-drop only if the canvas is already focused - return false; + + if(canvasHasFocus()) { + // disable drag-and-drop only if the canvas is already focused + return false; } else { - // otherwise, let the browser switch the focus away from wherever it was + // otherwise, let the browser switch the focus away from wherever it was resetCaret(); - return true; + return true; } }; @@ -265,13 +286,13 @@ window.onload = function() { var shift = false; -document.onkeydown = function(e) { - var key = crossBrowserKey(e); +document.onkeydown = function(e) { + var key = crossBrowserKey(e); if(key == 16) { shift = true; - } else if(!canvasHasFocus()) { - // don't read keystrokes when other things have focus + } else if(!canvasHasFocus()) { + // don't read keystrokes when other things have focus return true; } else if(key == 8) { // backspace key if(selectedObject != null && 'text' in selectedObject) { @@ -279,7 +300,7 @@ document.onkeydown = function(e) { resetCaret(); draw(); } - + // backspace is a shortcut for the back button, but do NOT want to change pages return false; } else if(key == 46) { // delete key @@ -310,9 +331,9 @@ document.onkeyup = function(e) { document.onkeypress = function(e) { // don't read keystrokes when other things have focus - var key = crossBrowserKey(e); - if(!canvasHasFocus()) { - // don't read keystrokes when other things have focus + var key = crossBrowserKey(e); + if(!canvasHasFocus()) { + // don't read keystrokes when other things have focus return true; } else if(key >= 0x20 && key <= 0x7E && !e.metaKey && !e.altKey && !e.ctrlKey && selectedObject != null && 'text' in selectedObject) { selectedObject.text += String.fromCharCode(key); @@ -321,7 +342,7 @@ document.onkeypress = function(e) { // don't let keys do their actions (like space scrolls down the page) return false; - } else if(key == 8) { + } else if(key == 8) { // backspace is a shortcut for the back button, but do NOT want to change pages return false; } @@ -360,40 +381,40 @@ function crossBrowserRelativeMousePos(e) { 'y': mouse.y - element.y }; } - -function output(text) { - var element = document.getElementById('output'); - element.style.display = 'block'; - element.value = text; -} + +function output(text) { + var element = document.getElementById('output'); + element.style.display = 'block'; + element.value = text; +} function saveAsPNG() { - var oldSelectedObject = selectedObject; - selectedObject = null; - drawUsing(canvas.getContext('2d')); - selectedObject = oldSelectedObject; + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(canvas.getContext('2d')); + selectedObject = oldSelectedObject; var pngData = canvas.toDataURL('image/png'); document.location.href = pngData; -} - -function saveAsSVG() { +} + +function saveAsSVG() { var exporter = new ExportAsSVG(); - var oldSelectedObject = selectedObject; - selectedObject = null; - drawUsing(exporter); - selectedObject = oldSelectedObject; - var svgData = exporter.toSVG(); - output(svgData); - // Chrome isn't ready for this yet, the 'Save As' menu item is disabled - // document.location.href = 'data:image/svg+xml;base64,' + btoa(svgData); + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(exporter); + selectedObject = oldSelectedObject; + var svgData = exporter.toSVG(); + output(svgData); + // Chrome isn't ready for this yet, the 'Save As' menu item is disabled + // document.location.href = 'data:image/svg+xml;base64,' + btoa(svgData); } - -function saveAsLaTeX() { + +function saveAsLaTeX() { var exporter = new ExportAsLaTeX(); - var oldSelectedObject = selectedObject; - selectedObject = null; - drawUsing(exporter); - selectedObject = oldSelectedObject; - var texData = exporter.toLaTeX(); - output(texData); + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(exporter); + selectedObject = oldSelectedObject; + var texData = exporter.toLaTeX(); + output(texData); } From 1137c9579a47629f9d7a83eb2224620d0657b787 Mon Sep 17 00:00:00 2001 From: RareDrops <54132759+RareDrops@users.noreply.github.com> Date: Thu, 3 Apr 2025 20:14:55 +1300 Subject: [PATCH 03/18] Create fsm.js --- www/fsm.js | 1113 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1113 insertions(+) create mode 100644 www/fsm.js diff --git a/www/fsm.js b/www/fsm.js new file mode 100644 index 0000000..662e4c7 --- /dev/null +++ b/www/fsm.js @@ -0,0 +1,1113 @@ +/* + Finite State Machine Designer (http://madebyevan.com/fsm/) + License: MIT License (see below) + + Copyright (c) 2010 Evan Wallace + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. +*/ + +function Link(a, b) { + this.nodeA = a; + this.nodeB = b; + this.text = ''; + this.lineAngleAdjust = 0; // value to add to textAngle when link is straight line + + // make anchor point relative to the locations of nodeA and nodeB + this.parallelPart = 0.5; // percentage from nodeA to nodeB + this.perpendicularPart = 0; // pixels from line between nodeA and nodeB +} + +Link.prototype.getAnchorPoint = function() { + var dx = this.nodeB.x - this.nodeA.x; + var dy = this.nodeB.y - this.nodeA.y; + var scale = Math.sqrt(dx * dx + dy * dy); + return { + 'x': this.nodeA.x + dx * this.parallelPart - dy * this.perpendicularPart / scale, + 'y': this.nodeA.y + dy * this.parallelPart + dx * this.perpendicularPart / scale + }; +}; + +Link.prototype.setAnchorPoint = function(x, y) { + var dx = this.nodeB.x - this.nodeA.x; + var dy = this.nodeB.y - this.nodeA.y; + var scale = Math.sqrt(dx * dx + dy * dy); + this.parallelPart = (dx * (x - this.nodeA.x) + dy * (y - this.nodeA.y)) / (scale * scale); + this.perpendicularPart = (dx * (y - this.nodeA.y) - dy * (x - this.nodeA.x)) / scale; + // snap to a straight line + if(this.parallelPart > 0 && this.parallelPart < 1 && Math.abs(this.perpendicularPart) < snapToPadding) { + this.lineAngleAdjust = (this.perpendicularPart < 0) * Math.PI; + this.perpendicularPart = 0; + } +}; + +Link.prototype.getEndPointsAndCircle = function() { + if(this.perpendicularPart == 0) { + var midX = (this.nodeA.x + this.nodeB.x) / 2; + var midY = (this.nodeA.y + this.nodeB.y) / 2; + var start = this.nodeA.closestPointOnCircle(midX, midY); + var end = this.nodeB.closestPointOnCircle(midX, midY); + return { + 'hasCircle': false, + 'startX': start.x, + 'startY': start.y, + 'endX': end.x, + 'endY': end.y, + }; + } + var anchor = this.getAnchorPoint(); + var circle = circleFromThreePoints(this.nodeA.x, this.nodeA.y, this.nodeB.x, this.nodeB.y, anchor.x, anchor.y); + var isReversed = (this.perpendicularPart > 0); + var reverseScale = isReversed ? 1 : -1; + var startAngle = Math.atan2(this.nodeA.y - circle.y, this.nodeA.x - circle.x) - reverseScale * nodeRadius / circle.radius; + var endAngle = Math.atan2(this.nodeB.y - circle.y, this.nodeB.x - circle.x) + reverseScale * nodeRadius / circle.radius; + var startX = circle.x + circle.radius * Math.cos(startAngle); + var startY = circle.y + circle.radius * Math.sin(startAngle); + var endX = circle.x + circle.radius * Math.cos(endAngle); + var endY = circle.y + circle.radius * Math.sin(endAngle); + return { + 'hasCircle': true, + 'startX': startX, + 'startY': startY, + 'endX': endX, + 'endY': endY, + 'startAngle': startAngle, + 'endAngle': endAngle, + 'circleX': circle.x, + 'circleY': circle.y, + 'circleRadius': circle.radius, + 'reverseScale': reverseScale, + 'isReversed': isReversed, + }; +}; + +Link.prototype.draw = function(c) { + var stuff = this.getEndPointsAndCircle(); + // draw arc + c.beginPath(); + if(stuff.hasCircle) { + c.arc(stuff.circleX, stuff.circleY, stuff.circleRadius, stuff.startAngle, stuff.endAngle, stuff.isReversed); + } else { + c.moveTo(stuff.startX, stuff.startY); + c.lineTo(stuff.endX, stuff.endY); + } + c.stroke(); + // draw the head of the arrow + if(stuff.hasCircle) { + drawArrow(c, stuff.endX, stuff.endY, stuff.endAngle - stuff.reverseScale * (Math.PI / 2)); + } else { + drawArrow(c, stuff.endX, stuff.endY, Math.atan2(stuff.endY - stuff.startY, stuff.endX - stuff.startX)); + } + // draw the text + if(stuff.hasCircle) { + var startAngle = stuff.startAngle; + var endAngle = stuff.endAngle; + if(endAngle < startAngle) { + endAngle += Math.PI * 2; + } + var textAngle = (startAngle + endAngle) / 2 + stuff.isReversed * Math.PI; + var textX = stuff.circleX + stuff.circleRadius * Math.cos(textAngle); + var textY = stuff.circleY + stuff.circleRadius * Math.sin(textAngle); + drawText(c, this.text, textX, textY, textAngle, selectedObject == this); + } else { + var textX = (stuff.startX + stuff.endX) / 2; + var textY = (stuff.startY + stuff.endY) / 2; + var textAngle = Math.atan2(stuff.endX - stuff.startX, stuff.startY - stuff.endY); + drawText(c, this.text, textX, textY, textAngle + this.lineAngleAdjust, selectedObject == this); + } +}; + +Link.prototype.containsPoint = function(x, y) { + var stuff = this.getEndPointsAndCircle(); + if(stuff.hasCircle) { + var dx = x - stuff.circleX; + var dy = y - stuff.circleY; + var distance = Math.sqrt(dx*dx + dy*dy) - stuff.circleRadius; + if(Math.abs(distance) < hitTargetPadding) { + var angle = Math.atan2(dy, dx); + var startAngle = stuff.startAngle; + var endAngle = stuff.endAngle; + if(stuff.isReversed) { + var temp = startAngle; + startAngle = endAngle; + endAngle = temp; + } + 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 { + var dx = stuff.endX - stuff.startX; + var dy = stuff.endY - stuff.startY; + var length = Math.sqrt(dx*dx + dy*dy); + var percent = (dx * (x - stuff.startX) + dy * (y - stuff.startY)) / (length * length); + var distance = (dx * (y - stuff.startY) - dy * (x - stuff.startX)) / length; + return (percent > 0 && percent < 1 && Math.abs(distance) < hitTargetPadding); + } + return false; +}; + +function Node(x, y) { + this.x = x; + this.y = y; + this.mouseOffsetX = 0; + this.mouseOffsetY = 0; + this.isAcceptState = false; + this.text = ''; +} + +Node.prototype.setMouseStart = function(x, y) { + this.mouseOffsetX = this.x - x; + this.mouseOffsetY = this.y - y; +}; + +Node.prototype.setAnchorPoint = function(x, y) { + this.x = x + this.mouseOffsetX; + this.y = y + this.mouseOffsetY; +}; + +Node.prototype.draw = function(c) { + // draw the circle + c.beginPath(); + c.arc(this.x, this.y, nodeRadius, 0, 2 * Math.PI, false); + c.stroke(); + + // draw the text + drawText(c, this.text, this.x, this.y, null, selectedObject == this); + + // draw a double circle for an accept state + if(this.isAcceptState) { + c.beginPath(); + c.arc(this.x, this.y, nodeRadius - 6, 0, 2 * Math.PI, false); + c.stroke(); + } +}; + +Node.prototype.closestPointOnCircle = function(x, y) { + var dx = x - this.x; + var dy = y - this.y; + var scale = Math.sqrt(dx * dx + dy * dy); + return { + 'x': this.x + dx * nodeRadius / scale, + 'y': this.y + dy * nodeRadius / scale, + }; +}; + +Node.prototype.containsPoint = function(x, y) { + return (x - this.x)*(x - this.x) + (y - this.y)*(y - this.y) < nodeRadius*nodeRadius; +}; + +function SelfLink(node, mouse) { + this.node = node; + this.anchorAngle = 0; + this.mouseOffsetAngle = 0; + this.text = ''; + + if(mouse) { + this.setAnchorPoint(mouse.x, mouse.y); + } +} + +SelfLink.prototype.setMouseStart = function(x, y) { + this.mouseOffsetAngle = this.anchorAngle - Math.atan2(y - this.node.y, x - this.node.x); +}; + +SelfLink.prototype.setAnchorPoint = function(x, y) { + this.anchorAngle = Math.atan2(y - this.node.y, x - this.node.x) + this.mouseOffsetAngle; + // snap to 90 degrees + var snap = Math.round(this.anchorAngle / (Math.PI / 2)) * (Math.PI / 2); + if(Math.abs(this.anchorAngle - snap) < 0.1) this.anchorAngle = snap; + // keep in the range -pi to pi so our containsPoint() function always works + if(this.anchorAngle < -Math.PI) this.anchorAngle += 2 * Math.PI; + if(this.anchorAngle > Math.PI) this.anchorAngle -= 2 * Math.PI; +}; + +SelfLink.prototype.getEndPointsAndCircle = function() { + var circleX = this.node.x + 1.5 * nodeRadius * Math.cos(this.anchorAngle); + var circleY = this.node.y + 1.5 * nodeRadius * Math.sin(this.anchorAngle); + var circleRadius = 0.75 * nodeRadius; + var startAngle = this.anchorAngle - Math.PI * 0.8; + var endAngle = this.anchorAngle + Math.PI * 0.8; + var startX = circleX + circleRadius * Math.cos(startAngle); + var startY = circleY + circleRadius * Math.sin(startAngle); + var endX = circleX + circleRadius * Math.cos(endAngle); + var endY = circleY + circleRadius * Math.sin(endAngle); + return { + 'hasCircle': true, + 'startX': startX, + 'startY': startY, + 'endX': endX, + 'endY': endY, + 'startAngle': startAngle, + 'endAngle': endAngle, + 'circleX': circleX, + 'circleY': circleY, + 'circleRadius': circleRadius + }; +}; + +SelfLink.prototype.draw = function(c) { + var stuff = this.getEndPointsAndCircle(); + // draw arc + c.beginPath(); + c.arc(stuff.circleX, stuff.circleY, stuff.circleRadius, stuff.startAngle, stuff.endAngle, false); + c.stroke(); + // draw the text on the loop farthest from the node + var textX = stuff.circleX + stuff.circleRadius * Math.cos(this.anchorAngle); + var textY = stuff.circleY + stuff.circleRadius * Math.sin(this.anchorAngle); + drawText(c, this.text, textX, textY, this.anchorAngle, selectedObject == this); + // draw the head of the arrow + drawArrow(c, stuff.endX, stuff.endY, stuff.endAngle + Math.PI * 0.4); +}; + +SelfLink.prototype.containsPoint = function(x, y) { + var stuff = this.getEndPointsAndCircle(); + var dx = x - stuff.circleX; + var dy = y - stuff.circleY; + var distance = Math.sqrt(dx*dx + dy*dy) - stuff.circleRadius; + return (Math.abs(distance) < hitTargetPadding); +}; + +function StartLink(node, start) { + this.node = node; + this.deltaX = 0; + this.deltaY = 0; + this.text = ''; + + if(start) { + this.setAnchorPoint(start.x, start.y); + } +} + +StartLink.prototype.setAnchorPoint = function(x, y) { + this.deltaX = x - this.node.x; + this.deltaY = y - this.node.y; + + if(Math.abs(this.deltaX) < snapToPadding) { + this.deltaX = 0; + } + + if(Math.abs(this.deltaY) < snapToPadding) { + this.deltaY = 0; + } +}; + +StartLink.prototype.getEndPoints = function() { + var startX = this.node.x + this.deltaX; + var startY = this.node.y + this.deltaY; + var end = this.node.closestPointOnCircle(startX, startY); + return { + 'startX': startX, + 'startY': startY, + 'endX': end.x, + 'endY': end.y, + }; +}; + +StartLink.prototype.draw = function(c) { + var stuff = this.getEndPoints(); + + // draw the line + c.beginPath(); + c.moveTo(stuff.startX, stuff.startY); + c.lineTo(stuff.endX, stuff.endY); + c.stroke(); + + // draw the text at the end without the arrow + var textAngle = Math.atan2(stuff.startY - stuff.endY, stuff.startX - stuff.endX); + drawText(c, this.text, stuff.startX, stuff.startY, textAngle, selectedObject == this); + + // draw the head of the arrow + drawArrow(c, stuff.endX, stuff.endY, Math.atan2(-this.deltaY, -this.deltaX)); +}; + +StartLink.prototype.containsPoint = function(x, y) { + var stuff = this.getEndPoints(); + var dx = stuff.endX - stuff.startX; + var dy = stuff.endY - stuff.startY; + var length = Math.sqrt(dx*dx + dy*dy); + var percent = (dx * (x - stuff.startX) + dy * (y - stuff.startY)) / (length * length); + var distance = (dx * (y - stuff.startY) - dy * (x - stuff.startX)) / length; + return (percent > 0 && percent < 1 && Math.abs(distance) < hitTargetPadding); +}; + +function TemporaryLink(from, to) { + this.from = from; + this.to = to; +} + +TemporaryLink.prototype.draw = function(c) { + // draw the line + c.beginPath(); + c.moveTo(this.to.x, this.to.y); + c.lineTo(this.from.x, this.from.y); + c.stroke(); + + // draw the head of the arrow + drawArrow(c, this.to.x, this.to.y, Math.atan2(this.to.y - this.from.y, this.to.x - this.from.x)); +}; + +// draw using this instead of a canvas and call toLaTeX() afterward +function ExportAsLaTeX() { + this._points = []; + this._texData = ''; + this._scale = 0.1; // to convert pixels to document space (TikZ breaks if the numbers get too big, above 500?) + + this.toLaTeX = function() { + return '\\documentclass[12pt]{article}\n' + + '\\usepackage{tikz}\n' + + '\n' + + '\\begin{document}\n' + + '\n' + + '\\begin{center}\n' + + '\\begin{tikzpicture}[scale=0.2]\n' + + '\\tikzstyle{every node}+=[inner sep=0pt]\n' + + this._texData + + '\\end{tikzpicture}\n' + + '\\end{center}\n' + + '\n' + + '\\end{document}\n'; + }; + + this.beginPath = function() { + this._points = []; + }; + this.arc = function(x, y, radius, startAngle, endAngle, isReversed) { + x *= this._scale; + y *= this._scale; + radius *= this._scale; + if(endAngle - startAngle == Math.PI * 2) { + this._texData += '\\draw [' + this.strokeStyle + '] (' + fixed(x, 3) + ',' + fixed(-y, 3) + ') circle (' + fixed(radius, 3) + ');\n'; + } else { + if(isReversed) { + var temp = startAngle; + startAngle = endAngle; + endAngle = temp; + } + if(endAngle < startAngle) { + endAngle += Math.PI * 2; + } + // TikZ needs the angles to be in between -2pi and 2pi or it breaks + if(Math.min(startAngle, endAngle) < -2*Math.PI) { + startAngle += 2*Math.PI; + endAngle += 2*Math.PI; + } else if(Math.max(startAngle, endAngle) > 2*Math.PI) { + startAngle -= 2*Math.PI; + endAngle -= 2*Math.PI; + } + startAngle = -startAngle; + endAngle = -endAngle; + this._texData += '\\draw [' + this.strokeStyle + '] (' + fixed(x + radius * Math.cos(startAngle), 3) + ',' + fixed(-y + radius * Math.sin(startAngle), 3) + ') arc (' + fixed(startAngle * 180 / Math.PI, 5) + ':' + fixed(endAngle * 180 / Math.PI, 5) + ':' + fixed(radius, 3) + ');\n'; + } + }; + this.moveTo = this.lineTo = function(x, y) { + x *= this._scale; + y *= this._scale; + this._points.push({ 'x': x, 'y': y }); + }; + this.stroke = function() { + if(this._points.length == 0) return; + this._texData += '\\draw [' + this.strokeStyle + ']'; + for(var i = 0; i < this._points.length; i++) { + var p = this._points[i]; + this._texData += (i > 0 ? ' --' : '') + ' (' + fixed(p.x, 2) + ',' + fixed(-p.y, 2) + ')'; + } + this._texData += ';\n'; + }; + this.fill = function() { + if(this._points.length == 0) return; + this._texData += '\\fill [' + this.strokeStyle + ']'; + for(var i = 0; i < this._points.length; i++) { + var p = this._points[i]; + this._texData += (i > 0 ? ' --' : '') + ' (' + fixed(p.x, 2) + ',' + fixed(-p.y, 2) + ')'; + } + this._texData += ';\n'; + }; + this.measureText = function(text) { + var c = canvas.getContext('2d'); + c.font = '20px "Times New Romain", serif'; + return c.measureText(text); + }; + this.advancedFillText = function(text, originalText, x, y, angleOrNull) { + if(text.replace(' ', '').length > 0) { + var nodeParams = ''; + // x and y start off as the center of the text, but will be moved to one side of the box when angleOrNull != null + if(angleOrNull != null) { + var width = this.measureText(text).width; + var dx = Math.cos(angleOrNull); + var dy = Math.sin(angleOrNull); + if(Math.abs(dx) > Math.abs(dy)) { + if(dx > 0) nodeParams = '[right] ', x -= width / 2; + else nodeParams = '[left] ', x += width / 2; + } else { + if(dy > 0) nodeParams = '[below] ', y -= 10; + else nodeParams = '[above] ', y += 10; + } + } + x *= this._scale; + y *= this._scale; + this._texData += '\\draw (' + fixed(x, 2) + ',' + fixed(-y, 2) + ') node ' + nodeParams + '{$' + originalText.replace(/ /g, '\\mbox{ }') + '$};\n'; + } + }; + + this.translate = this.save = this.restore = this.clearRect = function(){}; +} + +// draw using this instead of a canvas and call toSVG() afterward +function ExportAsSVG() { + this.fillStyle = 'black'; + this.strokeStyle = 'black'; + this.lineWidth = 1; + this.font = '12px Arial, sans-serif'; + this._points = []; + this._svgData = ''; + this._transX = 0; + this._transY = 0; + + this.toSVG = function() { + return '\n\n\n\n' + this._svgData + '\n'; + }; + + this.beginPath = function() { + this._points = []; + }; + this.arc = function(x, y, radius, startAngle, endAngle, isReversed) { + x += this._transX; + y += this._transY; + var style = 'stroke="' + this.strokeStyle + '" stroke-width="' + this.lineWidth + '" fill="none"'; + + if(endAngle - startAngle == Math.PI * 2) { + this._svgData += '\t\n'; + } else { + if(isReversed) { + var temp = startAngle; + startAngle = endAngle; + endAngle = temp; + } + + if(endAngle < startAngle) { + endAngle += Math.PI * 2; + } + + var startX = x + radius * Math.cos(startAngle); + var startY = y + radius * Math.sin(startAngle); + var endX = x + radius * Math.cos(endAngle); + var endY = y + radius * Math.sin(endAngle); + var useGreaterThan180 = (Math.abs(endAngle - startAngle) > Math.PI); + var goInPositiveDirection = 1; + + this._svgData += '\t\n'; + } + }; + this.moveTo = this.lineTo = function(x, y) { + x += this._transX; + y += this._transY; + this._points.push({ 'x': x, 'y': y }); + }; + this.stroke = function() { + if(this._points.length == 0) return; + this._svgData += '\t\n'; + }; + this.fill = function() { + if(this._points.length == 0) return; + this._svgData += '\t\n'; + }; + this.measureText = function(text) { + var c = canvas.getContext('2d'); + c.font = '20px "Times New Romain", serif'; + return c.measureText(text); + }; + this.fillText = function(text, x, y) { + x += this._transX; + y += this._transY; + if(text.replace(' ', '').length > 0) { + this._svgData += '\t' + textToXML(text) + '\n'; + } + }; + this.translate = function(x, y) { + this._transX = x; + this._transY = y; + }; + + this.save = this.restore = this.clearRect = function(){}; +} + +function convertLatexShortcuts(text) { + // Greek letters + const greekLetters = { + '\\alpha': 'α', '\\beta': 'β', '\\gamma': 'γ', '\\delta': 'δ', '\\epsilon': 'ε', + '\\zeta': 'ζ', '\\eta': 'η', '\\theta': 'θ', '\\iota': 'ι', '\\kappa': 'κ', + '\\lambda': 'λ', '\\mu': 'μ', '\\nu': 'ν', '\\xi': 'ξ', '\\pi': 'π', + '\\rho': 'ρ', '\\sigma': 'σ', '\\tau': 'τ', '\\upsilon': 'υ', '\\phi': 'φ', + '\\chi': 'χ', '\\psi': 'ψ', '\\omega': 'ω', + '\\Gamma': 'Γ', '\\Delta': 'Δ', '\\Theta': 'Θ', '\\Lambda': 'Λ', '\\Xi': 'Ξ', + '\\Pi': 'Π', '\\Sigma': 'Σ', '\\Phi': 'Φ', '\\Psi': 'Ψ', '\\Omega': 'Ω' + }; + + // Basic mathematical operators + const operators = { + '\\times': '×', '\\div': '÷', '\\pm': '±', '\\mp': '∓', + '\\leq': '≤', '\\geq': '≥', '\\neq': '≠', '\\approx': '≈', + '\\infty': '∞', '\\sum': '∑', '\\prod': '∏', '\\int': '∫', + // Set theory symbols + '\\cup': '∪', '\\cap': '∩', '\\subset': '⊂', '\\supset': '⊃', + '\\subseteq': '⊆', '\\supseteq': '⊇', '\\in': '∈', '\\notin': '∉', + '\\emptyset': '∅', '\\varnothing': '∅', + // Additional operators + '\\cdot': '·', '\\bullet': '•', '\\circ': '∘', '\\oplus': '⊕', + '\\otimes': '⊗', '\\setminus': '∖', '\\subsetneq': '⊊', '\\supsetneq': '⊋' + }; + + // Replace Greek letters + for (const [latex, unicode] of Object.entries(greekLetters)) { + text = text.replace(new RegExp(latex, 'g'), unicode); + } + + // Replace operators + for (const [latex, unicode] of Object.entries(operators)) { + text = text.replace(new RegExp(latex, 'g'), unicode); + } + + return text; +} + +function textToXML(text) { + text = text.replace(/&/g, '&').replace(//g, '>'); + var result = ''; + for(var i = 0; i < text.length; i++) { + var c = text.charCodeAt(i); + if(c >= 0x20 && c <= 0x7E) { + result += text[i]; + } else { + result += '&#' + c + ';'; + } + } + return result; +} + +function drawArrow(c, x, y, angle) { + var dx = Math.cos(angle); + var dy = Math.sin(angle); + c.beginPath(); + c.moveTo(x, y); + c.lineTo(x - 8 * dx + 5 * dy, y - 8 * dy - 5 * dx); + c.lineTo(x - 8 * dx - 5 * dy, y - 8 * dy + 5 * dx); + c.fill(); +} + +function canvasHasFocus() { + return (document.activeElement || document.body) == document.body; +} + +function drawText(c, originalText, x, y, angleOrNull, isSelected) { + text = convertLatexShortcuts(originalText); + c.font = '20px "Times New Roman", serif'; + var width = c.measureText(text).width; + + // center the text + x -= width / 2; + + // position the text intelligently if given an angle + if(angleOrNull != null) { + var cos = Math.cos(angleOrNull); + var sin = Math.sin(angleOrNull); + var cornerPointX = (width / 2 + 5) * (cos > 0 ? 1 : -1); + var cornerPointY = (10 + 5) * (sin > 0 ? 1 : -1); + var slide = sin * Math.pow(Math.abs(sin), 40) * cornerPointX - cos * Math.pow(Math.abs(cos), 10) * cornerPointY; + x += cornerPointX - sin * slide; + y += cornerPointY + cos * slide; + } + + // draw text and caret (round the coordinates so the caret falls on a pixel) + if('advancedFillText' in c) { + c.advancedFillText(text, originalText, x + width / 2, y, angleOrNull); + } else { + x = Math.round(x); + y = Math.round(y); + c.fillText(text, x, y + 6); + if(isSelected && caretVisible && canvasHasFocus() && document.hasFocus()) { + x += width; + c.beginPath(); + c.moveTo(x, y - 10); + c.lineTo(x, y + 10); + c.stroke(); + } + } +} + +var caretTimer; +var caretVisible = true; + +function resetCaret() { + clearInterval(caretTimer); + caretTimer = setInterval('caretVisible = !caretVisible; draw()', 500); + caretVisible = true; +} + +var canvas; +var nodeRadius = 30; +var nodes = []; +var links = []; + +var cursorVisible = true; +var snapToPadding = 6; // pixels +var hitTargetPadding = 6; // pixels +var selectedObject = null; // either a Link or a Node +var currentLink = null; // a Link +var movingObject = false; +var originalClick; + +function drawUsing(c) { + c.clearRect(0, 0, canvas.width, canvas.height); + c.save(); + c.translate(0.5, 0.5); + + for(var i = 0; i < nodes.length; i++) { + c.lineWidth = 1; + c.fillStyle = c.strokeStyle = (nodes[i] == selectedObject) ? 'blue' : 'black'; + nodes[i].draw(c); + } + for(var i = 0; i < links.length; i++) { + c.lineWidth = 1; + c.fillStyle = c.strokeStyle = (links[i] == selectedObject) ? 'blue' : 'black'; + links[i].draw(c); + } + if(currentLink != null) { + c.lineWidth = 1; + c.fillStyle = c.strokeStyle = 'black'; + currentLink.draw(c); + } + + c.restore(); +} + +function draw() { + drawUsing(canvas.getContext('2d')); + saveBackup(); +} + +function selectObject(x, y) { + for(var i = 0; i < nodes.length; i++) { + if(nodes[i].containsPoint(x, y)) { + return nodes[i]; + } + } + for(var i = 0; i < links.length; i++) { + if(links[i].containsPoint(x, y)) { + return links[i]; + } + } + return null; +} + +function snapNode(node) { + for(var i = 0; i < nodes.length; i++) { + if(nodes[i] == node) continue; + + if(Math.abs(node.x - nodes[i].x) < snapToPadding) { + node.x = nodes[i].x; + } + + if(Math.abs(node.y - nodes[i].y) < snapToPadding) { + node.y = nodes[i].y; + } + } +} + +window.onload = function() { + canvas = document.getElementById('canvas'); + restoreBackup(); + draw(); + + canvas.onmousedown = function(e) { + var mouse = crossBrowserRelativeMousePos(e); + selectedObject = selectObject(mouse.x, mouse.y); + movingObject = false; + originalClick = mouse; + + if(selectedObject != null) { + if(shift && selectedObject instanceof Node) { + currentLink = new SelfLink(selectedObject, mouse); + } else { + movingObject = true; + deltaMouseX = deltaMouseY = 0; + if(selectedObject.setMouseStart) { + selectedObject.setMouseStart(mouse.x, mouse.y); + } + } + resetCaret(); + } else if(shift) { + currentLink = new TemporaryLink(mouse, mouse); + } + + draw(); + + if(canvasHasFocus()) { + // disable drag-and-drop only if the canvas is already focused + return false; + } else { + // otherwise, let the browser switch the focus away from wherever it was + resetCaret(); + return true; + } + }; + + canvas.ondblclick = function(e) { + var mouse = crossBrowserRelativeMousePos(e); + selectedObject = selectObject(mouse.x, mouse.y); + + if(selectedObject == null) { + selectedObject = new Node(mouse.x, mouse.y); + nodes.push(selectedObject); + resetCaret(); + draw(); + } else if(selectedObject instanceof Node) { + selectedObject.isAcceptState = !selectedObject.isAcceptState; + draw(); + } + }; + + canvas.onmousemove = function(e) { + var mouse = crossBrowserRelativeMousePos(e); + + if(currentLink != null) { + var targetNode = selectObject(mouse.x, mouse.y); + if(!(targetNode instanceof Node)) { + targetNode = null; + } + + if(selectedObject == null) { + if(targetNode != null) { + currentLink = new StartLink(targetNode, originalClick); + } else { + currentLink = new TemporaryLink(originalClick, mouse); + } + } else { + if(targetNode == selectedObject) { + currentLink = new SelfLink(selectedObject, mouse); + } else if(targetNode != null) { + currentLink = new Link(selectedObject, targetNode); + } else { + currentLink = new TemporaryLink(selectedObject.closestPointOnCircle(mouse.x, mouse.y), mouse); + } + } + draw(); + } + + if(movingObject) { + selectedObject.setAnchorPoint(mouse.x, mouse.y); + if(selectedObject instanceof Node) { + snapNode(selectedObject); + } + draw(); + } + }; + + canvas.onmouseup = function(e) { + movingObject = false; + + if(currentLink != null) { + if(!(currentLink instanceof TemporaryLink)) { + selectedObject = currentLink; + links.push(currentLink); + resetCaret(); + } + currentLink = null; + draw(); + } + }; +} + +var shift = false; + +document.onkeydown = function(e) { + var key = crossBrowserKey(e); + + if(key == 16) { + shift = true; + } else if(!canvasHasFocus()) { + // don't read keystrokes when other things have focus + return true; + } else if(key == 8) { // backspace key + if(selectedObject != null && 'text' in selectedObject) { + selectedObject.text = selectedObject.text.substr(0, selectedObject.text.length - 1); + resetCaret(); + draw(); + } + + // backspace is a shortcut for the back button, but do NOT want to change pages + return false; + } else if(key == 46) { // delete key + if(selectedObject != null) { + for(var i = 0; i < nodes.length; i++) { + if(nodes[i] == selectedObject) { + nodes.splice(i--, 1); + } + } + for(var i = 0; i < links.length; i++) { + if(links[i] == selectedObject || links[i].node == selectedObject || links[i].nodeA == selectedObject || links[i].nodeB == selectedObject) { + links.splice(i--, 1); + } + } + selectedObject = null; + draw(); + } + } +}; + +document.onkeyup = function(e) { + var key = crossBrowserKey(e); + + if(key == 16) { + shift = false; + } +}; + +document.onkeypress = function(e) { + // don't read keystrokes when other things have focus + var key = crossBrowserKey(e); + if(!canvasHasFocus()) { + // don't read keystrokes when other things have focus + return true; + } else if(key >= 0x20 && key <= 0x7E && !e.metaKey && !e.altKey && !e.ctrlKey && selectedObject != null && 'text' in selectedObject) { + selectedObject.text += String.fromCharCode(key); + resetCaret(); + draw(); + + // don't let keys do their actions (like space scrolls down the page) + return false; + } else if(key == 8) { + // backspace is a shortcut for the back button, but do NOT want to change pages + return false; + } +}; + +function crossBrowserKey(e) { + e = e || window.event; + return e.which || e.keyCode; +} + +function crossBrowserElementPos(e) { + e = e || window.event; + var obj = e.target || e.srcElement; + var x = 0, y = 0; + while(obj.offsetParent) { + x += obj.offsetLeft; + y += obj.offsetTop; + obj = obj.offsetParent; + } + return { 'x': x, 'y': y }; +} + +function crossBrowserMousePos(e) { + e = e || window.event; + return { + 'x': e.pageX || e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft, + 'y': e.pageY || e.clientY + document.body.scrollTop + document.documentElement.scrollTop, + }; +} + +function crossBrowserRelativeMousePos(e) { + var element = crossBrowserElementPos(e); + var mouse = crossBrowserMousePos(e); + return { + 'x': mouse.x - element.x, + 'y': mouse.y - element.y + }; +} + +function output(text) { + var element = document.getElementById('output'); + element.style.display = 'block'; + element.value = text; +} + +function saveAsPNG() { + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(canvas.getContext('2d')); + selectedObject = oldSelectedObject; + var pngData = canvas.toDataURL('image/png'); + document.location.href = pngData; +} + +function saveAsSVG() { + var exporter = new ExportAsSVG(); + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(exporter); + selectedObject = oldSelectedObject; + var svgData = exporter.toSVG(); + output(svgData); + // Chrome isn't ready for this yet, the 'Save As' menu item is disabled + // document.location.href = 'data:image/svg+xml;base64,' + btoa(svgData); +} + +function saveAsLaTeX() { + var exporter = new ExportAsLaTeX(); + var oldSelectedObject = selectedObject; + selectedObject = null; + drawUsing(exporter); + selectedObject = oldSelectedObject; + var texData = exporter.toLaTeX(); + output(texData); +} + +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; +} + +function circleFromThreePoints(x1, y1, x2, y2, x3, y3) { + var a = det(x1, y1, 1, x2, y2, 1, x3, y3, 1); + var bx = -det(x1*x1 + y1*y1, y1, 1, x2*x2 + y2*y2, y2, 1, x3*x3 + y3*y3, y3, 1); + var by = det(x1*x1 + y1*y1, x1, 1, x2*x2 + y2*y2, x2, 1, x3*x3 + y3*y3, x3, 1); + var c = -det(x1*x1 + y1*y1, x1, y1, x2*x2 + y2*y2, x2, y2, x3*x3 + y3*y3, x3, y3); + return { + 'x': -bx / (2*a), + 'y': -by / (2*a), + 'radius': Math.sqrt(bx*bx + by*by - 4*a*c) / (2*Math.abs(a)) + }; +} + +function fixed(number, digits) { + return number.toFixed(digits).replace(/0+$/, '').replace(/\.$/, ''); +} + +function restoreBackup() { + if(!localStorage || !JSON) { + return; + } + + try { + var backup = JSON.parse(localStorage['fsm']); + + for(var i = 0; i < backup.nodes.length; i++) { + var backupNode = backup.nodes[i]; + var node = new Node(backupNode.x, backupNode.y); + node.isAcceptState = backupNode.isAcceptState; + node.text = backupNode.text; + nodes.push(node); + } + for(var i = 0; i < backup.links.length; i++) { + var backupLink = backup.links[i]; + var link = null; + if(backupLink.type == 'SelfLink') { + link = new SelfLink(nodes[backupLink.node]); + link.anchorAngle = backupLink.anchorAngle; + link.text = backupLink.text; + } else if(backupLink.type == 'StartLink') { + link = new StartLink(nodes[backupLink.node]); + link.deltaX = backupLink.deltaX; + link.deltaY = backupLink.deltaY; + link.text = backupLink.text; + } else if(backupLink.type == 'Link') { + link = new Link(nodes[backupLink.nodeA], nodes[backupLink.nodeB]); + link.parallelPart = backupLink.parallelPart; + link.perpendicularPart = backupLink.perpendicularPart; + link.text = backupLink.text; + link.lineAngleAdjust = backupLink.lineAngleAdjust; + } + if(link != null) { + links.push(link); + } + } + } catch(e) { + localStorage['fsm'] = ''; + } +} + +function saveBackup() { + if(!localStorage || !JSON) { + return; + } + + var backup = { + 'nodes': [], + 'links': [], + }; + for(var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var backupNode = { + 'x': node.x, + 'y': node.y, + 'text': node.text, + 'isAcceptState': node.isAcceptState, + }; + backup.nodes.push(backupNode); + } + for(var i = 0; i < links.length; i++) { + var link = links[i]; + var backupLink = null; + if(link instanceof SelfLink) { + backupLink = { + 'type': 'SelfLink', + 'node': nodes.indexOf(link.node), + 'text': link.text, + 'anchorAngle': link.anchorAngle, + }; + } else if(link instanceof StartLink) { + backupLink = { + 'type': 'StartLink', + 'node': nodes.indexOf(link.node), + 'text': link.text, + 'deltaX': link.deltaX, + 'deltaY': link.deltaY, + }; + } else if(link instanceof Link) { + backupLink = { + 'type': 'Link', + 'nodeA': nodes.indexOf(link.nodeA), + 'nodeB': nodes.indexOf(link.nodeB), + 'text': link.text, + 'lineAngleAdjust': link.lineAngleAdjust, + 'parallelPart': link.parallelPart, + 'perpendicularPart': link.perpendicularPart, + }; + } + if(backupLink != null) { + backup.links.push(backupLink); + } + } + + localStorage['fsm'] = JSON.stringify(backup); +} From a7110e38fcb0e6e8198a707b4b7408def3223e1e Mon Sep 17 00:00:00 2001 From: RareDrops <54132759+RareDrops@users.noreply.github.com> Date: Thu, 3 Apr 2025 20:22:13 +1300 Subject: [PATCH 04/18] moved www to docs --- .gitignore | 2 +- {www => docs}/fsm.js | 0 {www => docs}/index.html | 242 +++++++++++++++++++-------------------- 3 files changed, 122 insertions(+), 122 deletions(-) rename {www => docs}/fsm.js (100%) rename {www => docs}/index.html (96%) mode change 100755 => 100644 diff --git a/.gitignore b/.gitignore index 1b3145e..2554624 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -/www/fsm.js +# /www/fsm.js diff --git a/www/fsm.js b/docs/fsm.js similarity index 100% rename from www/fsm.js rename to docs/fsm.js diff --git a/www/index.html b/docs/index.html old mode 100755 new mode 100644 similarity index 96% rename from www/index.html rename to docs/index.html index 31583c6..f0b3147 --- a/www/index.html +++ b/docs/index.html @@ -1,121 +1,121 @@ - - - Finite State Machine Designer - by Evan Wallace - - - - - -

Finite State Machine Designer

- - Your browser does not support
the HTML5 <canvas> element
-
-
-

Export as: PNG | SVG | LaTeX

- -

The big white box above is the FSM designer.  Here's how to use it:

-
    -
  • Add a state: double-click on the canvas
  • -
  • Add an arrow: shift-drag on the canvas
  • -
  • Move something: drag it around
  • -
  • Delete something: click it and press the delete key (not the backspace key)
  • -
    -
  • Make accept state: double-click on an existing state
  • -
  • Type numeric subscript: put an underscore before the number (like "S_0")
  • -
  • Type greek letter: put a backslash before it (like "\beta")
  • -
-

This was made in HTML5 and JavaScript using the canvas element.

-
-

Created by Evan Wallace in 2010

- + + + Finite State Machine Designer - by Evan Wallace + + + + + +

Finite State Machine Designer

+ + Your browser does not support
the HTML5 <canvas> element
+
+
+

Export as: PNG | SVG | LaTeX

+ +

The big white box above is the FSM designer.  Here's how to use it:

+
    +
  • Add a state: double-click on the canvas
  • +
  • Add an arrow: shift-drag on the canvas
  • +
  • Move something: drag it around
  • +
  • Delete something: click it and press the delete key (not the backspace key)
  • +
    +
  • Make accept state: double-click on an existing state
  • +
  • Type numeric subscript: put an underscore before the number (like "S_0")
  • +
  • Type greek letter: put a backslash before it (like "\beta")
  • +
+

This was made in HTML5 and JavaScript using the canvas element.

+
+

Created by Evan Wallace in 2010

+ From 68f0934c83aeba84c78f938aaf34a0e558b0f94c Mon Sep 17 00:00:00 2001 From: RareDrops <54132759+RareDrops@users.noreply.github.com> Date: Thu, 3 Apr 2025 20:27:21 +1300 Subject: [PATCH 05/18] moved to public folder --- index.html | 12 ++++++++++++ {docs => public}/fsm.js | 0 {docs => public}/index.html | 0 3 files changed, 12 insertions(+) create mode 100644 index.html rename {docs => public}/fsm.js (100%) rename {docs => public}/index.html (100%) diff --git a/index.html b/index.html new file mode 100644 index 0000000..9575166 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + + Finite State Machine Designer + + + + + \ No newline at end of file diff --git a/docs/fsm.js b/public/fsm.js similarity index 100% rename from docs/fsm.js rename to public/fsm.js diff --git a/docs/index.html b/public/index.html similarity index 100% rename from docs/index.html rename to public/index.html From 537c23b6fca6850861e4c0902b1ff0c78697f9ed Mon Sep 17 00:00:00 2001 From: RareDrops <54132759+RareDrops@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:48:28 +1300 Subject: [PATCH 06/18] Update build.py --- build.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.py b/build.py index fa98119..0ddc3db 100755 --- a/build.py +++ b/build.py @@ -8,7 +8,7 @@ def sources(): return [os.path.join(base, f) for base, folders, files in os.walk(path) for f in files if f.endswith('.js')] def build(): - path = './www/fsm.js' + path = './public/fsm.js' try: data = '\n'.join(open(file, 'r', encoding='utf-8').read() for file in sources()) with open(path, 'w', encoding='utf-8') as f: From 8a805e0e97720df006a2012c016254bb0a190bfa Mon Sep 17 00:00:00 2001 From: RareDrops <54132759+RareDrops@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:48:30 +1300 Subject: [PATCH 07/18] Update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2554624..30d74d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -# /www/fsm.js +test \ No newline at end of file From 46e12e1c9ed3221ceebca4d9da012f526ba9f6f7 Mon Sep 17 00:00:00 2001 From: RareDrops <54132759+RareDrops@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:48:46 +1300 Subject: [PATCH 08/18] added more latex shortcuts --- public/fsm.js | 41 +++++++++++++++++++++++------------------ src/main/fsm.js | 42 ++++++++++++++++++++++++------------------ 2 files changed, 47 insertions(+), 36 deletions(-) diff --git a/public/fsm.js b/public/fsm.js index 662e4c7..0f1256b 100644 --- a/public/fsm.js +++ b/public/fsm.js @@ -575,37 +575,42 @@ function ExportAsSVG() { function convertLatexShortcuts(text) { // Greek letters const greekLetters = { - '\\alpha': 'α', '\\beta': 'β', '\\gamma': 'γ', '\\delta': 'δ', '\\epsilon': 'ε', - '\\zeta': 'ζ', '\\eta': 'η', '\\theta': 'θ', '\\iota': 'ι', '\\kappa': 'κ', - '\\lambda': 'λ', '\\mu': 'μ', '\\nu': 'ν', '\\xi': 'ξ', '\\pi': 'π', - '\\rho': 'ρ', '\\sigma': 'σ', '\\tau': 'τ', '\\upsilon': 'υ', '\\phi': 'φ', - '\\chi': 'χ', '\\psi': 'ψ', '\\omega': 'ω', - '\\Gamma': 'Γ', '\\Delta': 'Δ', '\\Theta': 'Θ', '\\Lambda': 'Λ', '\\Xi': 'Ξ', - '\\Pi': 'Π', '\\Sigma': 'Σ', '\\Phi': 'Φ', '\\Psi': 'Ψ', '\\Omega': 'Ω' + '\\alpha': '\u03B1', '\\beta': '\u03B2', '\\gamma': '\u03B3', '\\delta': '\u03B4', '\\epsilon': '\u03B5', + '\\zeta': '\u03B6', '\\eta': '\u03B7', '\\theta': '\u03B8', '\\iota': '\u03B9', '\\kappa': '\u03BA', + '\\lambda': '\u03BB', '\\mu': '\u03BC', '\\nu': '\u03BD', '\\xi': '\u03BE', '\\pi': '\u03C0', + '\\rho': '\u03C1', '\\sigma': '\u03C3', '\\tau': '\u03C4', '\\upsilon': '\u03C5', '\\phi': '\u03C6', + '\\chi': '\u03C7', '\\psi': '\u03C8', '\\omega': '\u03C9', + '\\Gamma': '\u0393', '\\Delta': '\u0394', '\\Theta': '\u0398', '\\Lambda': '\u039B', '\\Xi': '\u039E', + '\\Pi': '\u03A0', '\\Sigma': '\u03A3', '\\Phi': '\u03A6', '\\Psi': '\u03A8', '\\Omega': '\u03A9' }; - // Basic mathematical operators + // Mathematical operators const operators = { - '\\times': '×', '\\div': '÷', '\\pm': '±', '\\mp': '∓', - '\\leq': '≤', '\\geq': '≥', '\\neq': '≠', '\\approx': '≈', - '\\infty': '∞', '\\sum': '∑', '\\prod': '∏', '\\int': '∫', + '\\times': '\u00D7', '\\div': '\u00F7', '\\pm': '\u00B1', '\\mp': '\u2213', + '\\leq': '\u2264', '\\geq': '\u2265', '\\neq': '\u2260', '\\approx': '\u2248', + '\\infty': '\u221E', '\\sum': '\u2211', '\\prod': '\u220F', '\\int': '\u222B', // Set theory symbols - '\\cup': '∪', '\\cap': '∩', '\\subset': '⊂', '\\supset': '⊃', - '\\subseteq': '⊆', '\\supseteq': '⊇', '\\in': '∈', '\\notin': '∉', - '\\emptyset': '∅', '\\varnothing': '∅', + '\\cup': '\u222A', '\\cap': '\u2229', '\\subset': '\u2282', '\\supset': '\u2283', + '\\subseteq': '\u2286', '\\supseteq': '\u2287', '\\in': '\u2208', '\\notin': '\u2209', + '\\emptyset': '\u2205', '\\varnothing': '\u2205', // Additional operators - '\\cdot': '·', '\\bullet': '•', '\\circ': '∘', '\\oplus': '⊕', - '\\otimes': '⊗', '\\setminus': '∖', '\\subsetneq': '⊊', '\\supsetneq': '⊋' + '\\cdot': '\u22C5', '\\bullet': '\u2022', '\\circ': '\u2218', '\\oplus': '\u2295', + '\\otimes': '\u2297', '\\setminus': '\u2216', '\\subsetneq': '\u228A', '\\supsetneq': '\u228B' }; // Replace Greek letters for (const [latex, unicode] of Object.entries(greekLetters)) { - text = text.replace(new RegExp(latex, 'g'), unicode); + text = text.replace(new RegExp('\\'+latex, 'g'), unicode); } // Replace operators for (const [latex, unicode] of Object.entries(operators)) { - text = text.replace(new RegExp(latex, 'g'), unicode); + text = text.replace(new RegExp('\\'+latex, 'g'), unicode); + } + + // subscripts + for(var i = 0; i < 10; i++) { + text = text.replace(new RegExp('_' + i, 'g'), String.fromCharCode(8320 + i)); } return text; diff --git a/src/main/fsm.js b/src/main/fsm.js index 5e33d0f..9cfb1b5 100644 --- a/src/main/fsm.js +++ b/src/main/fsm.js @@ -1,37 +1,43 @@ function convertLatexShortcuts(text) { + // Add more latex shortcuts here if needed // Greek letters const greekLetters = { - '\\alpha': 'α', '\\beta': 'β', '\\gamma': 'γ', '\\delta': 'δ', '\\epsilon': 'ε', - '\\zeta': 'ζ', '\\eta': 'η', '\\theta': 'θ', '\\iota': 'ι', '\\kappa': 'κ', - '\\lambda': 'λ', '\\mu': 'μ', '\\nu': 'ν', '\\xi': 'ξ', '\\pi': 'π', - '\\rho': 'ρ', '\\sigma': 'σ', '\\tau': 'τ', '\\upsilon': 'υ', '\\phi': 'φ', - '\\chi': 'χ', '\\psi': 'ψ', '\\omega': 'ω', - '\\Gamma': 'Γ', '\\Delta': 'Δ', '\\Theta': 'Θ', '\\Lambda': 'Λ', '\\Xi': 'Ξ', - '\\Pi': 'Π', '\\Sigma': 'Σ', '\\Phi': 'Φ', '\\Psi': 'Ψ', '\\Omega': 'Ω' + '\\alpha': '\u03B1', '\\beta': '\u03B2', '\\gamma': '\u03B3', '\\delta': '\u03B4', '\\epsilon': '\u03B5', + '\\zeta': '\u03B6', '\\eta': '\u03B7', '\\theta': '\u03B8', '\\iota': '\u03B9', '\\kappa': '\u03BA', + '\\lambda': '\u03BB', '\\mu': '\u03BC', '\\nu': '\u03BD', '\\xi': '\u03BE', '\\pi': '\u03C0', + '\\rho': '\u03C1', '\\sigma': '\u03C3', '\\tau': '\u03C4', '\\upsilon': '\u03C5', '\\phi': '\u03C6', + '\\chi': '\u03C7', '\\psi': '\u03C8', '\\omega': '\u03C9', + '\\Gamma': '\u0393', '\\Delta': '\u0394', '\\Theta': '\u0398', '\\Lambda': '\u039B', '\\Xi': '\u039E', + '\\Pi': '\u03A0', '\\Sigma': '\u03A3', '\\Phi': '\u03A6', '\\Psi': '\u03A8', '\\Omega': '\u03A9' }; - // Basic mathematical operators + // Mathematical operators const operators = { - '\\times': '×', '\\div': '÷', '\\pm': '±', '\\mp': '∓', - '\\leq': '≤', '\\geq': '≥', '\\neq': '≠', '\\approx': '≈', - '\\infty': '∞', '\\sum': '∑', '\\prod': '∏', '\\int': '∫', + '\\times': '\u00D7', '\\div': '\u00F7', '\\pm': '\u00B1', '\\mp': '\u2213', + '\\leq': '\u2264', '\\geq': '\u2265', '\\neq': '\u2260', '\\approx': '\u2248', + '\\infty': '\u221E', '\\sum': '\u2211', '\\prod': '\u220F', '\\int': '\u222B', // Set theory symbols - '\\cup': '∪', '\\cap': '∩', '\\subset': '⊂', '\\supset': '⊃', - '\\subseteq': '⊆', '\\supseteq': '⊇', '\\in': '∈', '\\notin': '∉', - '\\emptyset': '∅', '\\varnothing': '∅', + '\\cup': '\u222A', '\\cap': '\u2229', '\\subset': '\u2282', '\\supset': '\u2283', + '\\subseteq': '\u2286', '\\supseteq': '\u2287', '\\in': '\u2208', '\\notin': '\u2209', + '\\emptyset': '\u2205', '\\varnothing': '\u2205', // Additional operators - '\\cdot': '·', '\\bullet': '•', '\\circ': '∘', '\\oplus': '⊕', - '\\otimes': '⊗', '\\setminus': '∖', '\\subsetneq': '⊊', '\\supsetneq': '⊋' + '\\cdot': '\u22C5', '\\bullet': '\u2022', '\\circ': '\u2218', '\\oplus': '\u2295', + '\\otimes': '\u2297', '\\setminus': '\u2216', '\\subsetneq': '\u228A', '\\supsetneq': '\u228B' }; // Replace Greek letters for (const [latex, unicode] of Object.entries(greekLetters)) { - text = text.replace(new RegExp(latex, 'g'), unicode); + text = text.replace(new RegExp('\\'+latex, 'g'), unicode); } // Replace operators for (const [latex, unicode] of Object.entries(operators)) { - text = text.replace(new RegExp(latex, 'g'), unicode); + text = text.replace(new RegExp('\\'+latex, 'g'), unicode); + } + + // subscripts + for(var i = 0; i < 10; i++) { + text = text.replace(new RegExp('_' + i, 'g'), String.fromCharCode(8320 + i)); } return text; From c4dff8be75a44d871e7f263f469bce75c59660e7 Mon Sep 17 00:00:00 2001 From: RareDrops <54132759+RareDrops@users.noreply.github.com> Date: Thu, 3 Apr 2025 22:51:13 +1300 Subject: [PATCH 09/18] Update index.html --- public/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index f0b3147..3bca832 100644 --- a/public/index.html +++ b/public/index.html @@ -1,6 +1,6 @@ - Finite State Machine Designer - by Evan Wallace + Finite State Machine Designer