├── .eslintrc.json ├── .github └── workflows │ └── lint_and_test.yml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── pre-commit.sample └── src ├── css ├── main.css └── mdi.css ├── img ├── dfa-help.mp4 ├── dfa-help.webm ├── e_key.png ├── favicon.png ├── logo.png ├── nfa-help.mp4 └── nfa-help.webm └── js ├── canvas ├── draggable_canvas.js ├── drawables │ ├── arrow.js │ ├── arrowed_straight_line.js │ ├── bezier_curved_line.js │ ├── circle.js │ ├── drawable.js │ ├── quadratic_curved_line.js │ ├── straight_line.js │ └── text.js ├── location.js └── renderer.js ├── elements ├── add_node_menu.js ├── edit_node_menu.js ├── edit_transition_menu.js ├── fsa_description.js └── overlay_message.js ├── fsa ├── animated_nfa_converter.js ├── fsa.js ├── nfa_converter.js └── visual_fsa.js ├── index.js ├── test ├── fsa.test.js ├── nfa_converter.test.js └── visual_fsa.test.js └── util ├── array.js ├── errors.js ├── event_handler.js └── util.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "mocha": true 5 | }, 6 | "extends": "standard", 7 | "parserOptions": { 8 | "ecmaVersion": 12, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "indent": [ 13 | "error", 14 | 4 15 | ], 16 | "quote-props": [ 17 | "error", 18 | "consistent" 19 | ] 20 | } 21 | } -------------------------------------------------------------------------------- /.github/workflows/lint_and_test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of dependencies and lint and test the project 2 | 3 | name: Lint and Test 4 | 5 | on: 6 | push: 7 | branches: [ master ] 8 | pull_request: 9 | branches: [ master ] 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: 14 22 | - name: Install dependencies 23 | run: npm install 24 | - name: Run linter 25 | run: npm run lint 26 | - name: Run tests 27 | run: npm test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | requirements.txt 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.format.enable": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": true 5 | } 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Visual NFA to DFA Converter 2 | ![Lint and Test](https://github.com/joeylemon/nfa-to-dfa/workflows/Lint%20and%20Test/badge.svg) 3 | 4 | https://joeylemon.github.io/nfa-to-dfa/ 5 | 6 | ## Overview 7 | 8 | Screenshot of the main application interface 9 | 10 | This tool is used to convert [non-deterministic finite automata](https://en.wikipedia.org/wiki/Nondeterministic_finite_automaton) (NFA) to [deterministic finite automata](https://en.wikipedia.org/wiki/Deterministic_finite_automaton) (DFA) through an interactive and visual interface. More specifically, you can: 11 | - Create an NFA interactively or from a saved JSON file 12 | - Export an NFA to a JSON file 13 | - View the description of both the NFA and the DFA, including a full delta transition table 14 | - Convert the NFA to an equivalent DFA in three possible ways: 15 | - **Step-by-step**: the DFA is constructed in controlled increments 16 | - **All at once**: completely convert the NFA to the DFA in one leap 17 | - **Animated**: watch the conversion process take place automatically 18 | 19 | ### Technology 20 | 21 | ![image](https://user-images.githubusercontent.com/8845512/121960347-f907db80-cd33-11eb-9ec1-f249496ae452.png) 22 | 23 | _Originally created by [Alex Klibisz](https://github.com/alexklibisz) and [Connor Minton](https://github.com/c-minton), COSC 312, Spring 2015, University of Tennessee, Knoxville._ 24 | 25 | _Rewritten and enhanced by [Joey Lemon](https://github.com/joeylemon) and [Camille Williford](https://github.com/awillif), COSC 493, Fall 2021, University of Tennessee, Knoxville._ 26 | 27 | ## Contributing 28 | 29 | ### Prerequisites 30 | 31 | You must have [Node.js v12.19.0+ and npm](https://nodejs.org/en/) installed to run the application locally. Node versions below v12.19.0 are unable to run the unit tests. 32 | 33 | ### Running Application 34 | 35 | To set up the application locally, first clone this repository: 36 | ```shell 37 | > git clone https://github.com/joeylemon/nfa-to-dfa.git 38 | ``` 39 | 40 | Then, install the dependencies: 41 | ```shell 42 | > cd nfa-to-dfa 43 | > npm install 44 | ``` 45 | 46 | Then, simply run the start script to create a local webserver: 47 | ```shell 48 | > npm start 49 | ``` 50 | 51 | Running this script should give an output similar to below: 52 | ```shell 53 | > nfa-to-dfa@0.0.2 start ~/Desktop/nfa-to-dfa 54 | > browser-sync start -s -f . --no-notify --host localhost --port 8000 55 | 56 | [Browsersync] Access URLs: 57 | -------------------------------------- 58 | Local: http://localhost:8000 59 | External: http://192.168.1.127:8000 60 | -------------------------------------- 61 | UI: http://localhost:3001 62 | UI External: http://localhost:3001 63 | -------------------------------------- 64 | [Browsersync] Serving files from: ./ 65 | [Browsersync] Watching files... 66 | ``` 67 | 68 | You can now navigate to `http://localhost:8000` in the browser to view the application. The website will automatically reload upon changes to the code. 69 | 70 | ### Linting 71 | Prior to adding changes to the repository, you should run the linter on the code to ensure there are no syntax errors and to maintain a uniform coding style: 72 | ```shell 73 | > npm run lint 74 | ``` 75 | 76 | To automatically lint files before committing them, you should add a pre-commit hook. Copy the `pre-commit.sample` file to `.git/hooks/pre-commit`: 77 | ```shell 78 | > cp pre-commit.sample .git/hooks/pre-commit 79 | ``` 80 | 81 | Now, git will automatically lint all changed files before committing them to the repository. 82 | 83 | ### Testing 84 | You should also test your changes before committing them to the repository: 85 | ```shell 86 | > npm test 87 | ``` 88 | 89 | This will run all unit tests in the `src/js/test` directory and report any errors. 90 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | NFA to DFA Converter 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 54 | 55 | 59 | 60 | 97 | 98 | 136 | 137 |
138 |
139 |
140 |
141 |
142 |
143 |

144 | Non-deterministic 145 | Finite Automaton 146 | (NFA) 147 |

148 |
149 |
150 | 155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 | 166 |
167 |
168 | 172 |
173 |
174 | 195 |
196 |
197 | 201 |
202 |
203 |
204 |
205 | 206 |
207 |
208 |
209 |

210 | N = (Q, E, 𝛿, q0, F ) 211 |

212 |
213 |
214 |
215 |
216 |
217 | Q = 218 |
219 |
220 | E = 221 |
222 |
223 | q0 = 224 |
225 |
226 | F = 227 |
228 |
229 |
230 | 𝛿 = 231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |

243 | Deterministic Finite Automaton (DFA) 244 |

245 |
246 |
247 | 252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 | 263 |
264 |
265 | 269 |
270 |
271 | 275 |
276 |
277 | 281 |
282 |
283 | 287 |
288 |
289 |
290 |
291 |

292 | 293 |
294 |
295 |
296 |

297 | M = (Q', E, 𝛿', q0', F' ) 298 |

299 |
300 |
301 |
302 |
303 |
304 | Q' = 305 |
306 |
307 | E = 308 |
309 |
310 | q0' = 311 |
312 |
313 | F' = 314 |
315 |
316 |
317 | 𝛿 = 318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 | 326 |
327 |
328 |

329 | This application was created by 330 | Joey Lemon 331 | 332 | and Camille Williford 333 | 334 | during the summer of 2021 for Dr. Michael 335 | Berry as an independent study project in COSC 493 at the 336 | University of Tennessee. It is an 337 | adaptation of 338 | the original application by 339 | Alex Klibisz 340 | 341 | and 342 | Connor Minton 343 | , also created for Dr. 345 | Michael Berry as an honors-by-contract 347 | project in COSC 312 during the spring of 2015. 348 |

349 | 350 | Source 351 | 352 |
353 |
354 | 355 | 356 | 357 | 358 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nfa-to-dfa", 3 | "type": "module", 4 | "version": "0.0.2", 5 | "description": "An application for visually converting NFAs to DFAs", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/joeylemon/nfa-to-dfa" 9 | }, 10 | "author": "Alex Klibisz, Connor Minton, Camille Williford, Joey Lemon, Lauren Proctor", 11 | "scripts": { 12 | "start": "browser-sync start -s -f src --no-notify --host localhost --port 8000", 13 | "test": "mocha --require should --global global,document 'src/js/test/*.test.js'", 14 | "lint": "eslint src --ext .js,.ts --fix" 15 | }, 16 | "devDependencies": { 17 | "browser-sync": "^2.26.14", 18 | "eslint": "^7.26.0", 19 | "eslint-config-standard": "^16.0.2", 20 | "eslint-plugin-import": "^2.23.2", 21 | "eslint-plugin-node": "^11.1.0", 22 | "eslint-plugin-promise": "^5.1.0", 23 | "jsdom": "^16.6.0", 24 | "mocha": "^8.4.0", 25 | "should": "^13.2.3" 26 | }, 27 | "dependencies": {} 28 | } -------------------------------------------------------------------------------- /pre-commit.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Move this file to .git/hooks and remove the ".sample" extension to create a 4 | # pre-commit hook that lints all modified files before allowing a new commit 5 | 6 | FILES=$(git diff --cached --name-only --diff-filter=ACMR "*.js" "*.ts" | sed 's| |\\ |g') 7 | [ -z "$FILES" ] && exit 0 8 | 9 | # Lint all selected files 10 | echo "$FILES" | xargs ./node_modules/.bin/eslint --fix 11 | 12 | if [ $? -ne 0 ]; then 13 | echo "Error while linting staged files: canceling commit" 14 | exit 1 15 | fi 16 | 17 | # Add back the modified files to staging 18 | echo "$FILES" | xargs git add 19 | 20 | exit 0 -------------------------------------------------------------------------------- /src/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 20px; 3 | padding-top: 80px; 4 | background-color: #f6f6f6; 5 | } 6 | 7 | .title { 8 | text-align: center; 9 | } 10 | 11 | .title h1 { 12 | font-family: Roboto; 13 | font-size: 30px; 14 | } 15 | 16 | .title-help-icon { 17 | position: relative; 18 | top: 4px; 19 | left: 2px; 20 | font-weight: normal; 21 | transform: scale(0.8, 0.8); 22 | } 23 | 24 | .help-dropdown { 25 | width: 300px; 26 | position: absolute; 27 | left: -260px; 28 | font-weight: normal; 29 | } 30 | 31 | .subtitle { 32 | font-family: Roboto; 33 | font-size: 15px; 34 | line-height: 20px; 35 | } 36 | 37 | .footnotes { 38 | margin-top: 60px; 39 | } 40 | 41 | .footnotes p { 42 | font-family: Roboto; 43 | font-size: 14px; 44 | } 45 | 46 | .footnotes p a { 47 | color: #517caa; 48 | } 49 | 50 | .footnotes p .mdi::before { 51 | top: 0px; 52 | } 53 | 54 | .blue-button, .blue-button:focus, .blue-button:hover, .blue-button[disabled] { 55 | background-color: #34b1eb; 56 | border-color: transparent; 57 | color: #fff; 58 | box-shadow: 1px 1px 3px rgba(0,0,0,0.2); 59 | } 60 | 61 | .blue-button:hover { 62 | background-color: #2ba5de; 63 | } 64 | 65 | .orange-button, .orange-button:focus, .orange-button:hover, .orange-button[disabled] { 66 | background-color: #f3b75b; 67 | border-color: transparent; 68 | color: #fff; 69 | box-shadow: 1px 1px 3px rgba(0,0,0,0.2); 70 | } 71 | 72 | .orange-button:hover { 73 | background-color: #e3a444; 74 | } 75 | 76 | .dropdown-item { 77 | font-family: Roboto; 78 | } 79 | 80 | .card-header { 81 | background-color: #f0f0f0; 82 | box-shadow: none; 83 | } 84 | 85 | .card-header-title { 86 | font-family: Roboto; 87 | font-weight: normal; 88 | } 89 | 90 | .navbar { 91 | box-shadow: 1px 1px 5px rgba(0,0,0,0.2); 92 | } 93 | 94 | canvas { 95 | width: 100%; 96 | height: 500px; 97 | } 98 | 99 | .message-overlay { 100 | position: absolute; 101 | width: 100%; 102 | height: 100%; 103 | left: 0px; 104 | top: 0px; 105 | background-color: rgba(0,0,0,0.5); 106 | display:flex; 107 | justify-content:center; 108 | align-items:center; 109 | color: #fff; 110 | font-family: Roboto; 111 | font-size: 25px; 112 | user-select: none; 113 | } 114 | 115 | .edit-menu { 116 | position: fixed; 117 | top: 50px; 118 | left: 50px; 119 | width: 250px; 120 | background-color: #fff; 121 | box-shadow: 1px 1px 5px rgba(0,0,0,0.3); 122 | border-radius: 5px; 123 | padding: 5px; 124 | } 125 | 126 | .edit-menu .option { 127 | width: 100%; 128 | padding: 5px 0px 5px 0px; 129 | color: #000; 130 | font-family: 'Roboto', sans-serif; 131 | cursor: pointer; 132 | user-select: none; 133 | } 134 | 135 | .edit-menu .option:hover { 136 | background-color: #f6f6f6; 137 | } 138 | 139 | .edit-menu .option:active { 140 | transform: scale(0.95); 141 | } 142 | 143 | .edit-menu > *:not(:last-child) { 144 | border-bottom: 1px solid #aaa; 145 | } 146 | 147 | .dfa-conversion-step { 148 | position: absolute; 149 | bottom: 5px; 150 | right: 10px; 151 | font-family: Roboto; 152 | font-size: 18px; 153 | user-select: none; 154 | } 155 | 156 | .fsa-description { 157 | font-family: 'Cambria Math', 'Cambria', serif; 158 | font-size: 18px; 159 | } 160 | 161 | .help-modal .modal-card .modal-card-body p { 162 | text-align: justify; 163 | margin-bottom: 15px; 164 | } 165 | 166 | .help-modal .modal-card .modal-card-body .subtitle { 167 | font-size: 12px; 168 | font-style: italic; 169 | color: gray; 170 | } -------------------------------------------------------------------------------- /src/css/mdi.css: -------------------------------------------------------------------------------- 1 | .mdi::before { 2 | font-size: 24px; 3 | line-height: 14px; 4 | } 5 | 6 | .button .mdi::before { 7 | position: relative; 8 | top: 2px; 9 | margin-right: 5px; 10 | } 11 | 12 | .edit-menu .mdi::before { 13 | position: relative; 14 | top: 2px; 15 | margin-right: 5px; 16 | } 17 | 18 | p .mdi::before { 19 | position: relative; 20 | top: 3px; 21 | } -------------------------------------------------------------------------------- /src/img/dfa-help.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeylemon/nfa-to-dfa/ce1d7d51dfcafec6a5ad54b08caf69f507a06e40/src/img/dfa-help.mp4 -------------------------------------------------------------------------------- /src/img/dfa-help.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeylemon/nfa-to-dfa/ce1d7d51dfcafec6a5ad54b08caf69f507a06e40/src/img/dfa-help.webm -------------------------------------------------------------------------------- /src/img/e_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeylemon/nfa-to-dfa/ce1d7d51dfcafec6a5ad54b08caf69f507a06e40/src/img/e_key.png -------------------------------------------------------------------------------- /src/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeylemon/nfa-to-dfa/ce1d7d51dfcafec6a5ad54b08caf69f507a06e40/src/img/favicon.png -------------------------------------------------------------------------------- /src/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeylemon/nfa-to-dfa/ce1d7d51dfcafec6a5ad54b08caf69f507a06e40/src/img/logo.png -------------------------------------------------------------------------------- /src/img/nfa-help.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeylemon/nfa-to-dfa/ce1d7d51dfcafec6a5ad54b08caf69f507a06e40/src/img/nfa-help.mp4 -------------------------------------------------------------------------------- /src/img/nfa-help.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joeylemon/nfa-to-dfa/ce1d7d51dfcafec6a5ad54b08caf69f507a06e40/src/img/nfa-help.webm -------------------------------------------------------------------------------- /src/js/canvas/draggable_canvas.js: -------------------------------------------------------------------------------- 1 | import Renderer from './renderer.js' 2 | import StraightLine from './drawables/straight_line.js' 3 | import Drawable from './drawables/drawable.js' 4 | import Location from './location.js' 5 | import EventHandler from '../util/event_handler.js' 6 | 7 | const GRID_CELL_SIZE = 40 8 | const GRID_SIZE = 500 9 | const GRID_LINE_WIDTH = 1 10 | const GRID_LINE_COLOR = '#e6e6e6' 11 | 12 | // How much below 1 can the canvas be zoomed? 13 | const MIN_ZOOM_DELTA = -0.5 14 | 15 | // How much above 1 can the canvas be zoomed? 16 | const MAX_ZOOM_DELTA = 1 17 | 18 | export default class DraggableCanvas extends EventHandler { 19 | /** 20 | * DraggableCanvas represents a canvas element with added functionality such as: 21 | * - zooming and panning the canvas 22 | * - adding and removing objects with custom draw functions 23 | * 24 | * @param {String} selector The query selector to get the canvas element 25 | */ 26 | constructor (selector) { 27 | super() 28 | this.canvas = document.querySelector(selector) 29 | 30 | // Perform some sanity checks on the element 31 | if (this.canvas === null) { throw new Error(`cannot create canvas because the given selector ${selector} does not exist`) } 32 | 33 | this.ctx = this.canvas.getContext('2d') 34 | 35 | // Find optimal pixel ratio for the user's screen 36 | const dpr = window.devicePixelRatio || 1 37 | const bsr = this.ctx.webkitBackingStorePixelRatio || 38 | this.ctx.mozBackingStorePixelRatio || 39 | this.ctx.msBackingStorePixelRatio || 40 | this.ctx.oBackingStorePixelRatio || 41 | this.ctx.backingStorePixelRatio || 1 42 | this.pixelRatio = dpr / bsr 43 | 44 | // Initialize the canvas with the pixel ratio 45 | const width = this.canvas.clientWidth 46 | const height = this.canvas.clientHeight 47 | this.canvas.width = width * this.pixelRatio 48 | this.canvas.height = height * this.pixelRatio 49 | // this.canvas.style.width = `${width}px` 50 | // this.canvas.style.height = `${height}px` 51 | this.ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0) 52 | 53 | // Translating by 0.5 helps to make lines less sharp 54 | this.ctx.translate(0.5, 0.5) 55 | 56 | window.addEventListener('resize', function (e) { 57 | const width = this.canvas.clientWidth 58 | const height = this.canvas.clientHeight 59 | this.canvas.width = width * this.pixelRatio 60 | this.canvas.height = height * this.pixelRatio 61 | this.scale = 1 62 | this.zoomDelta = 0 63 | this.setZoom(1) 64 | this.translation = { x: 0, y: 0 } 65 | this.redrawCanvas = true 66 | }.bind(this)) 67 | 68 | this.canvas.addEventListener('contextmenu', function (e) { 69 | const loc = this.normalizeLocation(new Location(e.offsetX, e.offsetY)) 70 | 71 | e.preventDefault() 72 | 73 | const obj = this.getObjectAt(loc) 74 | if (obj) { 75 | obj.dispatchEvent('edit', { 76 | clientX: e.clientX, 77 | clientY: e.clientY 78 | }) 79 | } else { 80 | this.dispatchEvent('rightclick', { 81 | clientX: e.clientX, 82 | clientY: e.clientY, 83 | loc: loc 84 | }) 85 | } 86 | }.bind(this)) 87 | 88 | this.canvas.addEventListener('mousedown', function (e) { 89 | if (e.button !== 0) return 90 | 91 | const loc = this.normalizeLocation(new Location(e.offsetX, e.offsetY)) 92 | const obj = this.getObjectAt(loc) 93 | this.dispatchEvent('mousedown', { loc: loc, obj: obj }) 94 | 95 | if (obj && typeof obj.move === 'function') { 96 | // If we found an object on the mousedown location, start dragging it 97 | this.draggingObject = obj 98 | document.body.style.cursor = 'grabbing' 99 | } else { 100 | // Otherwise, we want to pan the canvas 101 | this.panGrabLocation = loc 102 | document.body.style.cursor = 'grabbing' 103 | } 104 | }.bind(this)) 105 | 106 | this.canvas.addEventListener('mousemove', function (e) { 107 | const loc = this.normalizeLocation(new Location(e.offsetX, e.offsetY)) 108 | this.dispatchEvent('mousemove', { loc: loc }) 109 | 110 | if (this.panGrabLocation) { 111 | // Pan the canvas by the difference between the mouse location and the grab location 112 | this.pan(loc.x - this.panGrabLocation.x, loc.y - this.panGrabLocation.y) 113 | this.redrawCanvas = true 114 | } else if (this.draggingObject) { 115 | // Move the object to the new mouse location 116 | this.draggingObject.move(loc) 117 | this.redrawCanvas = true 118 | } 119 | }.bind(this)) 120 | 121 | this.canvas.addEventListener('mouseup', function (e) { 122 | const loc = this.normalizeLocation(new Location(e.offsetX, e.offsetY)) 123 | 124 | if (this.panGrabLocation) { 125 | this.redrawCanvas = true 126 | this.panGrabLocation = undefined 127 | document.body.style.cursor = 'auto' 128 | } else if (this.draggingObject) { 129 | this.draggingObject.move(loc) 130 | this.redrawCanvas = true 131 | this.draggingObject = undefined 132 | document.body.style.cursor = 'auto' 133 | } 134 | }.bind(this)) 135 | 136 | this.canvas.addEventListener('mousewheel', function (e) { 137 | e.preventDefault() 138 | 139 | // Keep zoom level between the min and max values 140 | this.zoomDelta = Math.min(Math.max(this.zoomDelta + (0.0008 * -e.deltaY), MIN_ZOOM_DELTA), MAX_ZOOM_DELTA) 141 | this.setZoom(1 + this.zoomDelta) 142 | 143 | this.redrawCanvas = true 144 | }.bind(this)) 145 | 146 | this.renderer = new Renderer(this.canvas, this.ctx) 147 | this.redrawCanvas = true 148 | this.objects = [] 149 | this.translation = { x: 0, y: 0 } 150 | this.scale = 1 151 | this.zoomDelta = 0 152 | } 153 | 154 | /** 155 | * Clear the canvas by removing all objects on it 156 | */ 157 | clear () { 158 | this.objects = [] 159 | this.redrawCanvas = true 160 | } 161 | 162 | /** 163 | * Add an object to the canvas to be drawn 164 | * 165 | * @param {Drawable} drawable The object to add to the canvas, which extends the Drawable class 166 | */ 167 | addObject (drawable) { 168 | if (!(drawable instanceof Drawable)) { throw new Error('object is not an instance of Drawable') } 169 | 170 | this.objects.push(drawable) 171 | this.redrawCanvas = true 172 | } 173 | 174 | /** 175 | * Find the object, if it exists, at the given location 176 | * 177 | * @param {Location} loc The location to find an object at 178 | */ 179 | getObjectAt (loc) { 180 | for (const obj of this.objects) { 181 | if (typeof obj.touches === 'function') { 182 | if (obj.touches(loc)) return obj 183 | } 184 | } 185 | } 186 | 187 | /** 188 | * Normalize a raw canvas location to a scaled and translated location, since the canvas can be panned and zoomed 189 | * 190 | * @param {Location} loc The location to normalize 191 | */ 192 | normalizeLocation (loc) { 193 | return new Location(loc.x / this.scale - this.translation.x, loc.y / this.scale - this.translation.y) 194 | } 195 | 196 | /** 197 | * Pan the canvas by the given deltas 198 | * 199 | * @param {Number} x The amount to pan in the x direction 200 | * @param {Number} y The amount to pan in the y direction 201 | */ 202 | pan (x, y) { 203 | if (Math.abs(this.translation.x + x) < GRID_SIZE) { 204 | this.translation.x += x 205 | this.ctx.translate(x, 0) 206 | } 207 | 208 | if (Math.abs(this.translation.y + y) < GRID_SIZE) { 209 | this.translation.y += y 210 | this.ctx.translate(0, y) 211 | } 212 | 213 | // console.log(this.translation) 214 | } 215 | 216 | /** 217 | * Adjust the zoom value of the canvas, with 1 being normal zoom 218 | * 219 | * @param {Number} amount The new scaling of the canvas (normal zoom = 1) 220 | */ 221 | setZoom (amount) { 222 | // For some reason we have to untranslate canvas before zooming 223 | this.ctx.translate(-this.translation.x, -this.translation.y) 224 | 225 | // Scale back to normal by scaling the recriprocal of current 226 | this.ctx.scale(1 / this.scale, 1 / this.scale) 227 | this.scale = amount 228 | this.ctx.scale(this.scale, this.scale) 229 | 230 | // Return to previous translation 231 | this.ctx.translate(this.translation.x, this.translation.y) 232 | } 233 | 234 | /** 235 | * Draw the background grid of lines on the canvas 236 | */ 237 | drawGrid () { 238 | // Change the size depending on the zoom level 239 | const width = this.canvas.width * Math.min(Math.abs(1 / this.zoomDelta), 2) 240 | const height = this.canvas.height * Math.min(Math.abs(1 / this.zoomDelta), 2) 241 | 242 | for (let x = -GRID_SIZE; x < width + GRID_SIZE; x += GRID_CELL_SIZE) { 243 | new StraightLine(new Location(x, -GRID_SIZE), new Location(x, height + GRID_SIZE), { 244 | width: GRID_LINE_WIDTH, 245 | color: GRID_LINE_COLOR 246 | }).draw(this.renderer) 247 | } 248 | 249 | for (let y = -GRID_SIZE; y < height + GRID_SIZE; y += GRID_CELL_SIZE) { 250 | new StraightLine(new Location(-GRID_SIZE, y), new Location(width + GRID_SIZE, y), { 251 | width: GRID_LINE_WIDTH, 252 | color: GRID_LINE_COLOR 253 | }).draw(this.renderer) 254 | } 255 | } 256 | 257 | /** 258 | * Draw all objects onto the canvas 259 | */ 260 | draw () { 261 | if (!this.redrawCanvas) { return } 262 | this.redrawCanvas = false 263 | 264 | // Change the size depending on the zoom level 265 | const width = this.canvas.width * Math.min(Math.abs(1 / this.zoomDelta), 2) 266 | const height = this.canvas.height * Math.min(Math.abs(1 / this.zoomDelta), 2) 267 | this.ctx.clearRect(-this.translation.x, -this.translation.y - 1, width, height) 268 | 269 | this.drawGrid() 270 | this.objects.forEach(e => e.draw(this.renderer)) 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/js/canvas/drawables/arrow.js: -------------------------------------------------------------------------------- 1 | import Drawable from './drawable.js' 2 | 3 | export default class Arrow extends Drawable { 4 | /** 5 | * @param {Location} from The initial location to start the arrow 6 | * @param {Location} to The end location to end the arrow 7 | * @param {Object} options The options with which to draw the arrow 8 | * @example 9 | * new Arrow(new Location(0, 0), new Location(0, 0), { 10 | * color: '#000', 11 | * arrowRadius: 5 12 | * }) 13 | */ 14 | constructor (from, to, options) { 15 | super() 16 | this.from = from 17 | this.to = to 18 | this.options = options 19 | } 20 | 21 | draw (rend) { 22 | rend.setColor(this.options.color) 23 | rend.ctx.beginPath() 24 | 25 | let angle = Math.atan2(this.to.y - this.from.y, this.to.x - this.from.x) 26 | let x = this.options.arrowRadius * Math.cos(angle) + this.to.x 27 | let y = this.options.arrowRadius * Math.sin(angle) + this.to.y 28 | 29 | rend.ctx.moveTo(x, y) 30 | 31 | angle += (1.0 / 3.0) * (2 * Math.PI) 32 | x = this.options.arrowRadius * Math.cos(angle) + this.to.x 33 | y = this.options.arrowRadius * Math.sin(angle) + this.to.y 34 | 35 | rend.ctx.lineTo(x, y) 36 | 37 | angle += (1.0 / 3.0) * (2 * Math.PI) 38 | x = this.options.arrowRadius * Math.cos(angle) + this.to.x 39 | y = this.options.arrowRadius * Math.sin(angle) + this.to.y 40 | 41 | rend.ctx.lineTo(x, y) 42 | 43 | rend.ctx.closePath() 44 | 45 | rend.ctx.fill() 46 | rend.resetColor() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/js/canvas/drawables/arrowed_straight_line.js: -------------------------------------------------------------------------------- 1 | import Drawable from './drawable.js' 2 | import Arrow from './arrow.js' 3 | import StraightLine from './straight_line.js' 4 | 5 | export default class ArrowedStraightLine extends Drawable { 6 | /** 7 | * @param {Location} from The initial location to start the line 8 | * @param {Location} to The end location to end the line 9 | * @param {Object} options The options with which to draw the line 10 | * @example 11 | * new ArrowedStraightLine(new Location(0, 0), new Location(50, 0), { 12 | * width: 5, 13 | * color: '#fff', 14 | * dash: [10, 2], 15 | * arrowRadius: 5 16 | * }) 17 | */ 18 | constructor (from, to, options) { 19 | super() 20 | this.straightLine = new StraightLine(from, to, options) 21 | this.arrowhead = new Arrow(from, to, options) 22 | } 23 | 24 | draw (rend) { 25 | this.straightLine.draw(rend) 26 | this.arrowhead.draw(rend) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/js/canvas/drawables/bezier_curved_line.js: -------------------------------------------------------------------------------- 1 | import Drawable from './drawable.js' 2 | import Arrow from './arrow.js' 3 | import Location from '../location.js' 4 | 5 | export default class BezierCurvedLine extends Drawable { 6 | /** 7 | * @param {Location} from The initial location to start the line 8 | * @param {Location} to The end location to end the line 9 | * @param {Location} cp1 The first control point for the bezier curve 10 | * @param {Location} cp2 The second control point for the bezier curve 11 | * @param {Object} options The options with which to draw the line 12 | * @example 13 | * new BezierCurvedLine(new Location(0, 0), new Location(50, 0), new Location(50, 50), new Location(50, 100), { 14 | * color: '#000', 15 | * arrowRadius: 5 16 | * }) 17 | */ 18 | constructor (from, to, cp1, cp2, options) { 19 | super() 20 | this.from = from 21 | this.to = to 22 | this.cp1 = cp1 23 | this.cp2 = cp2 24 | this.options = options 25 | 26 | this.arrowhead = new Arrow(cp2, to, options) 27 | } 28 | 29 | /** 30 | * Use the bezier point equation to find a point on the line 31 | * 32 | * @param {Number} t The position on the line (0 <= t <= 1) 33 | */ 34 | locationAt (t) { 35 | return new Location( 36 | Math.pow(1 - t, 3) * this.from.x + 3 * Math.pow(1 - t, 2) * t * this.cp1.x + 3 * (1 - t) * Math.pow(t, 2) * this.cp2.x + Math.pow(t, 3) * this.to.x, 37 | Math.pow(1 - t, 3) * this.from.y + 3 * Math.pow(1 - t, 2) * t * this.cp1.y + 3 * (1 - t) * Math.pow(t, 2) * this.cp2.y + Math.pow(t, 3) * this.to.y 38 | ) 39 | } 40 | 41 | /** 42 | * Use the bezier curve equation to find the angle at a point on the line 43 | * 44 | * @param {Number} t The position on the line (0 <= t <= 1) 45 | */ 46 | angleAt (t) { 47 | const dx = 3 * Math.pow(1 - t, 2) * (this.cp1.x - this.from.x) + 6 * (1 - t) * t * (this.cp2.x - this.cp1.x) + 3 * Math.pow(t, 2) * (this.to.x - this.cp2.x) 48 | const dy = 3 * Math.pow(1 - t, 2) * (this.cp1.y - this.from.y) + 6 * (1 - t) * t * (this.cp2.y - this.cp1.y) + 3 * Math.pow(t, 2) * (this.to.y - this.cp2.y) 49 | return -Math.atan2(dx, dy) + 0.5 * Math.PI 50 | } 51 | 52 | /** 53 | * Get the midpoint of the line 54 | */ 55 | midpoint () { 56 | return this.locationAt(0.5) 57 | } 58 | 59 | /** 60 | * Get the angle of the curve at the midpoint of the line 61 | */ 62 | midpointAngle () { 63 | return this.angleAt(0.5) 64 | } 65 | 66 | /** 67 | * Get the midpoint of the line plus padding at a perpendicular angle 68 | * @param {Number} padding Distance from midpoint 69 | */ 70 | midpointPadded (padding) { 71 | return this.midpoint().moveFromAngle(this.midpointAngle() - (Math.PI / 2), padding) 72 | } 73 | 74 | touches (loc) { 75 | for (let i = 0.05; i < 0.95; i += 0.1) { 76 | const from = this.locationAt(i) 77 | if (loc.distance(from) < 15) return true 78 | } 79 | return false 80 | } 81 | 82 | draw (rend) { 83 | rend.setColor(this.options.color) 84 | rend.ctx.lineWidth = this.options.width 85 | rend.ctx.beginPath() 86 | rend.ctx.moveTo(this.from.x, this.from.y) 87 | rend.ctx.bezierCurveTo(this.cp1.x, this.cp1.y, this.cp2.x, this.cp2.y, this.to.x, this.to.y) 88 | rend.ctx.stroke() 89 | rend.resetColor() 90 | 91 | this.arrowhead.draw(rend) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/js/canvas/drawables/circle.js: -------------------------------------------------------------------------------- 1 | import Drawable from './drawable.js' 2 | import Location from '../location.js' 3 | 4 | export default class Circle extends Drawable { 5 | /** 6 | * @param {Location} loc The location to draw the circle 7 | * @param {Object} options The options with which to draw the circle 8 | * @example 9 | * new Circle(new Location(0, 0), { 10 | * radius: 20, 11 | * color: '#fff', 12 | * text: new Text(...textOptions), 13 | * borderOptions: {color: '#000', width: 2}, 14 | * outlineOptions: {color: '#000', width: 2, distance: 5} 15 | * }) 16 | */ 17 | constructor (loc, options) { 18 | super() 19 | this.loc = loc 20 | this.options = options 21 | this.move(this.loc) 22 | } 23 | 24 | touches (loc) { 25 | return loc.distance(this.loc) < this.options.radius 26 | } 27 | 28 | move (to) { 29 | this.loc = to 30 | if (this.options.text) { this.options.text.loc = new Location(to.x, to.y) } 31 | 32 | this.dispatchEvent('move', { newLocation: to }) 33 | } 34 | 35 | draw (rend) { 36 | if (this.options.borderOptions) { 37 | rend.setColor(this.options.borderOptions.color) 38 | rend.ctx.beginPath() 39 | rend.ctx.arc(this.loc.x, this.loc.y, this.options.radius + this.options.borderOptions.width, 0, 2 * Math.PI) 40 | rend.ctx.fill() 41 | } 42 | 43 | if (this.options.outlineOptions) { 44 | rend.ctx.lineWidth = this.options.outlineOptions.width 45 | rend.setColor(this.options.outlineOptions.color) 46 | rend.ctx.beginPath() 47 | rend.ctx.arc(this.loc.x, this.loc.y, this.options.radius + this.options.outlineOptions.distance, 0, 2 * Math.PI) 48 | rend.ctx.stroke() 49 | } 50 | 51 | rend.setColor(this.options.color) 52 | 53 | rend.ctx.beginPath() 54 | rend.ctx.arc(this.loc.x, this.loc.y, this.options.radius, 0, 2 * Math.PI) 55 | rend.ctx.fill() 56 | 57 | if (this.options.text) { 58 | // Fit the text within the circle's diameter 59 | this.options.text.fitToWidth(2 * this.options.radius - 5, rend.ctx) 60 | this.options.text.draw(rend) 61 | } 62 | 63 | rend.resetColor() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/js/canvas/drawables/drawable.js: -------------------------------------------------------------------------------- 1 | import EventHandler from '../../util/event_handler.js' 2 | 3 | export default class Drawable extends EventHandler { 4 | /** 5 | * Drawable represents a drawable object that can be placed on a DraggableCanvas 6 | */ 7 | constructor () { 8 | super() 9 | 10 | // Generate a random id for this drawable object 11 | this.id = Math.floor(Math.random() * 10000000) 12 | 13 | this.eventListeners = [] 14 | } 15 | 16 | draw () { 17 | throw new Error('a drawable object was used without a draw function defined') 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/js/canvas/drawables/quadratic_curved_line.js: -------------------------------------------------------------------------------- 1 | import Drawable from './drawable.js' 2 | import Arrow from './arrow.js' 3 | import Location from '../location.js' 4 | 5 | export default class QuadraticCurvedLine extends Drawable { 6 | /** 7 | * @param {Location} from The initial location to start the line 8 | * @param {Location} to The end location to end the line 9 | * @param {Location} cp The control point for the quadratic curve 10 | * @param {Object} options The options with which to draw the line 11 | * @example 12 | * new QuadraticCurvedLine(new Location(0, 0), new Location(50, 0), new Location(50, 50), { 13 | * color: '#000', 14 | * arrowRadius: 5 15 | * }) 16 | */ 17 | constructor (from, to, cp, options) { 18 | super() 19 | this.from = from 20 | this.to = to 21 | this.cp = cp 22 | this.options = options 23 | 24 | this.arrowhead = new Arrow(cp, to, options) 25 | } 26 | 27 | /** 28 | * Use the bezier point equation to find a point on the line 29 | * 30 | * @param {Number} t The position on the line (0 <= t <= 1) 31 | */ 32 | locationAt (t) { 33 | return new Location( 34 | Math.pow(1 - t, 2) * this.from.x + 2 * (1 - t) * t * this.cp.x + Math.pow(t, 2) * this.to.x, 35 | Math.pow(1 - t, 2) * this.from.y + 2 * (1 - t) * t * this.cp.y + Math.pow(t, 2) * this.to.y 36 | ) 37 | } 38 | 39 | /** 40 | * Use the bezier curve equation to find the angle at a point on the line 41 | * 42 | * @param {Number} t The position on the line (0 <= t <= 1) 43 | */ 44 | angleAt (t) { 45 | const dx = 2 * (1 - t) * (this.cp.x - this.from.x) + 2 * t * (this.to.x - this.cp.x) 46 | const dy = 2 * (1 - t) * (this.cp.y - this.from.y) + 2 * t * (this.to.y - this.cp.y) 47 | return -Math.atan2(dx, dy) + 0.5 * Math.PI 48 | } 49 | 50 | /** 51 | * Get the midpoint of the line 52 | */ 53 | midpoint () { 54 | return this.locationAt(0.5) 55 | } 56 | 57 | /** 58 | * Get the angle of the curve at the midpoint of the line 59 | */ 60 | midpointAngle () { 61 | return this.angleAt(0.5) 62 | } 63 | 64 | /** 65 | * Get the midpoint of the line plus padding at a perpendicular angle 66 | * @param {Number} padding Distance from midpoint 67 | */ 68 | midpointPadded (padding) { 69 | return this.midpoint().moveFromAngle(this.midpointAngle() + (Math.PI / 2), padding) 70 | } 71 | 72 | touches (loc) { 73 | for (let i = 0.05; i < 0.95; i += 0.1) { 74 | const from = this.locationAt(i) 75 | if (loc.distance(from) < 15) return true 76 | } 77 | return false 78 | } 79 | 80 | draw (rend) { 81 | rend.setColor(this.options.color) 82 | rend.ctx.lineWidth = this.options.width 83 | rend.ctx.beginPath() 84 | rend.ctx.moveTo(this.from.x, this.from.y) 85 | rend.ctx.quadraticCurveTo(this.cp.x, this.cp.y, this.to.x, this.to.y) 86 | rend.ctx.stroke() 87 | rend.resetColor() 88 | 89 | this.arrowhead.draw(rend) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/js/canvas/drawables/straight_line.js: -------------------------------------------------------------------------------- 1 | import Drawable from './drawable.js' 2 | 3 | export default class StraightLine extends Drawable { 4 | /** 5 | * @param {Location} from The initial location to start the line 6 | * @param {Location} to The end location to end the line 7 | * @param {Object} options The options with which to draw the line 8 | * @example 9 | * new StraightLine(new Location(0, 0), new Location(50, 0), { 10 | * width: 5, 11 | * color: '#fff', 12 | * dash: [10, 2] 13 | * }) 14 | */ 15 | constructor (from, to, options) { 16 | super() 17 | this.from = from 18 | this.to = to 19 | this.options = options 20 | 21 | if (!this.options.dash) this.options.dash = [] 22 | } 23 | 24 | draw (rend) { 25 | rend.setColor(this.options.color) 26 | rend.ctx.beginPath() 27 | rend.ctx.setLineDash(this.options.dash) 28 | rend.ctx.moveTo(this.from.x, this.from.y) 29 | rend.ctx.lineTo(this.to.x, this.to.y) 30 | rend.ctx.lineWidth = this.options.width 31 | rend.ctx.stroke() 32 | rend.ctx.setLineDash([]) 33 | rend.resetColor() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/js/canvas/drawables/text.js: -------------------------------------------------------------------------------- 1 | import Drawable from './drawable.js' 2 | 3 | export default class Text extends Drawable { 4 | /** 5 | * @param {Location} loc The location to draw the text 6 | * @param {Object} options The options with which to draw the text 7 | * @example 8 | * new Text(new Location(0, 0), { 9 | * text: 'Hello world!', 10 | * color: '#fff', 11 | * size: 15, 12 | * font: 'Roboto', 13 | * outline: { color: '#000', width: 5 } 14 | * }) 15 | */ 16 | constructor (loc, options) { 17 | super() 18 | this.loc = loc 19 | this.options = options 20 | } 21 | 22 | touches (loc) { 23 | return loc.distance(this.loc) < this.options.size 24 | } 25 | 26 | /** 27 | * Adjust the text's size until it fits within the given width 28 | * @param {Number} width The max width the text can be 29 | * @param {CanvasRenderingContext2D} ctx The canvas context 30 | */ 31 | fitToWidth (width, ctx) { 32 | while (true) { 33 | ctx.font = `${this.options.size}px ${this.options.font}` 34 | const renderedWidth = ctx.measureText(this.options.text).width 35 | 36 | if (renderedWidth > width) { 37 | this.options.size-- 38 | if (this.options.size < 4) break 39 | } else { 40 | break 41 | } 42 | } 43 | } 44 | 45 | draw (rend) { 46 | rend.setColor(this.options.color) 47 | 48 | if (this.options.outline) { rend.ctx.lineWidth = this.options.outline.width } 49 | 50 | rend.ctx.textAlign = 'center' 51 | rend.ctx.font = `${this.options.size}px ${this.options.font}` 52 | if (this.options.rotation) { 53 | rend.rotate(this.options.rotation, this.loc) 54 | if (this.options.outline) { 55 | rend.setColor(this.options.outline.color) 56 | rend.ctx.strokeText(this.options.text, 0, 0 + (this.options.size / 4)) 57 | rend.setColor(this.options.color) 58 | } 59 | rend.ctx.fillText(this.options.text, 0, 0 + (this.options.size / 4)) 60 | rend.unrotate() 61 | } else { 62 | if (this.options.outline) { 63 | rend.setColor(this.options.outline.color) 64 | rend.ctx.strokeText(this.options.text, this.loc.x, this.loc.y + (this.options.size / 4)) 65 | rend.setColor(this.options.color) 66 | } 67 | rend.ctx.fillText(this.options.text, this.loc.x, this.loc.y + (this.options.size / 4)) 68 | } 69 | 70 | rend.resetColor() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/js/canvas/location.js: -------------------------------------------------------------------------------- 1 | export default class Location { 2 | /** 3 | * Location represents a Cartesian point on a grid with an x- and y-coordinate 4 | * 5 | * @param {Number} x The x-coordinate of the location 6 | * @param {Number} y The y-coordinate of the location 7 | */ 8 | constructor (x, y) { 9 | this.x = x 10 | this.y = y 11 | } 12 | 13 | distance (to) { 14 | return Math.hypot(to.x - this.x, to.y - this.y) 15 | } 16 | 17 | angleTo (to) { 18 | return Math.atan2(to.y - this.y, to.x - this.x) 19 | } 20 | 21 | moveToAngle (angle, distance) { 22 | return new Location(this.x + Math.cos(angle) * distance, this.y + Math.sin(angle) * distance) 23 | } 24 | 25 | moveFromAngle (angle, distance) { 26 | return new Location(this.x - Math.cos(angle) * distance, this.y - Math.sin(angle) * distance) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/js/canvas/renderer.js: -------------------------------------------------------------------------------- 1 | export default class Renderer { 2 | /** 3 | * Renderer adds some helper functions to render objects onto the canvas 4 | * 5 | * @param {Element} canvas The canvas element 6 | * @param {CanvasRenderingContext2D} ctx The 2D context of the canvas element 7 | */ 8 | constructor (canvas, ctx) { 9 | this.canvas = canvas 10 | this.ctx = ctx 11 | 12 | this.defaultColor = '#000' 13 | } 14 | 15 | /** 16 | * Rotate the drawing context so later drawings will be rotated. The rotation must be manually reset 17 | * with unrotate() 18 | * 19 | * @param {Number} angle The angle at which to rotate the canvas (in degrees) 20 | * @param {Position} drawLoc The location that will be drawn at (must draw the desired object at 0, 0) 21 | * 22 | * @example 23 | * canvas.renderer.rotate(Math.PI, new Location(50, 15)) 24 | * canvas.renderer.drawText('Hello world!', 15, new Location(0, 0)) 25 | */ 26 | rotate (angle, drawLoc) { 27 | this.ctx.save() 28 | this.ctx.translate(drawLoc.x, drawLoc.y) 29 | this.ctx.rotate(angle) 30 | } 31 | 32 | /** 33 | * Restore the drawing context to undo the rotation 34 | */ 35 | unrotate () { 36 | this.ctx.restore() 37 | } 38 | 39 | /** 40 | * Set the color of the drawing context 41 | * 42 | * @param {String} color The new color (e.g. '#000' or 'rgba(0,0,0,0.5)') 43 | */ 44 | setColor (color) { 45 | this.ctx.fillStyle = color 46 | this.ctx.strokeStyle = color 47 | } 48 | 49 | /** 50 | * Restore the default drawing color 51 | */ 52 | resetColor () { 53 | this.ctx.fillStyle = this.defaultColor 54 | this.ctx.strokeStyle = this.defaultColor 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/js/elements/add_node_menu.js: -------------------------------------------------------------------------------- 1 | import EventHandler from '../util/event_handler.js' 2 | 3 | export default class AddNodeMenu extends EventHandler { 4 | /** 5 | * Create a menu in the DOM that allows the user add nodes to the FSA 6 | * 7 | * @param {Number} x The x-coordinate of the menu 8 | * @param {Number} y The y-coordinate of the menu 9 | */ 10 | constructor (x, y) { 11 | super() 12 | this.deletePrevious() 13 | 14 | document.body.insertAdjacentHTML('beforeend', ` 15 |
16 |
17 | Add state 18 |
19 |
`) 20 | const elem = document.querySelector('#add-node-menu') 21 | 22 | x -= 10 23 | y -= 10 24 | elem.style.top = `${y}px` 25 | elem.style.left = `${x}px` 26 | 27 | // Adjust x-value if the menu extends past the viewport 28 | const left = parseInt(elem.style.left.replace('px', '')) 29 | if (left + elem.clientWidth > window.innerWidth) { 30 | elem.style.left = `${window.innerWidth - elem.clientWidth - 10}px` 31 | } 32 | 33 | document.querySelector('#add-node-menu-add').addEventListener('click', () => { 34 | this.dispatchEvent('create') 35 | }) 36 | 37 | elem.addEventListener('DOMNodeRemoved', () => { 38 | this.dispatchEvent('close') 39 | }) 40 | 41 | // If the user clicks elsewhere in the webpage, delete the menu 42 | window.addEventListener('click', () => { 43 | this.deletePrevious() 44 | }) 45 | } 46 | 47 | /** 48 | * Delete the previous EditNodeMenu if it exists in the DOM 49 | */ 50 | deletePrevious () { 51 | const elem = document.querySelector('.edit-menu') 52 | 53 | // Delete the previous menu first 54 | if (elem !== null) { 55 | elem.parentNode.removeChild(elem) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/js/elements/edit_node_menu.js: -------------------------------------------------------------------------------- 1 | import EventHandler from '../util/event_handler.js' 2 | 3 | export default class EditNodeMenu extends EventHandler { 4 | /** 5 | * Create a menu in the DOM that allows the user to edit aspects of an FSA node 6 | * 7 | * @param {Number} x The x-coordinate of the menu 8 | * @param {Number} y The y-coordinate of the menu 9 | */ 10 | constructor (x, y) { 11 | super() 12 | this.deletePrevious() 13 | 14 | document.body.insertAdjacentHTML('beforeend', ` 15 |
16 |
17 | Add transition 18 |
19 |
20 | Set as start state 21 |
22 |
23 | Toggle as accept state 24 |
25 |
26 | Delete state 27 |
28 |
`) 29 | const elem = document.querySelector('#edit-node-menu') 30 | 31 | x -= 10 32 | y -= 10 33 | elem.style.top = `${y}px` 34 | elem.style.left = `${x}px` 35 | 36 | // Adjust x-value if the menu extends past the viewport 37 | const left = parseInt(elem.style.left.replace('px', '')) 38 | if (left + elem.clientWidth > window.innerWidth) { 39 | elem.style.left = `${window.innerWidth - elem.clientWidth - 10}px` 40 | } 41 | 42 | document.querySelector('#edit-node-menu-add-transition').addEventListener('click', () => { 43 | this.dispatchEvent('addtransition') 44 | }) 45 | 46 | document.querySelector('#edit-node-menu-set-start').addEventListener('click', () => { 47 | this.dispatchEvent('selectedstart') 48 | }) 49 | 50 | document.querySelector('#edit-node-menu-toggle-accept').addEventListener('click', () => { 51 | this.dispatchEvent('toggledaccept') 52 | }) 53 | 54 | document.querySelector('#edit-node-menu-delete').addEventListener('click', () => { 55 | this.dispatchEvent('delete') 56 | }) 57 | 58 | elem.addEventListener('DOMNodeRemoved', () => { 59 | this.dispatchEvent('close') 60 | }) 61 | 62 | // If the user clicks elsewhere in the webpage, delete the menu 63 | window.addEventListener('click', () => { 64 | this.deletePrevious() 65 | }) 66 | } 67 | 68 | /** 69 | * Delete the previous EditNodeMenu if it exists in the DOM 70 | */ 71 | deletePrevious () { 72 | const elem = document.querySelector('.edit-menu') 73 | 74 | // Delete the previous menu first 75 | if (elem !== null) { 76 | elem.parentNode.removeChild(elem) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/js/elements/edit_transition_menu.js: -------------------------------------------------------------------------------- 1 | import EventHandler from '../util/event_handler.js' 2 | 3 | export default class EditTransitionMenu extends EventHandler { 4 | /** 5 | * Create a menu in the DOM that allows the user to edit aspects of an FSA node 6 | * 7 | * @param {Number} x The x-coordinate of the menu 8 | * @param {Number} y The y-coordinate of the menu 9 | */ 10 | constructor (x, y) { 11 | super() 12 | this.deletePrevious() 13 | 14 | document.body.insertAdjacentHTML('beforeend', ` 15 |
16 |
17 | Delete transition 18 |
19 |
20 | Edit transition 21 |
22 |
`) 23 | const elem = document.querySelector('#edit-transition-menu') 24 | 25 | x -= 10 26 | y -= 10 27 | elem.style.top = `${y}px` 28 | elem.style.left = `${x}px` 29 | 30 | // Adjust x-value if the menu extends past the viewport 31 | const left = parseInt(elem.style.left.replace('px', '')) 32 | if (left + elem.clientWidth > window.innerWidth) { 33 | elem.style.left = `${window.innerWidth - elem.clientWidth - 10}px` 34 | } 35 | 36 | document.querySelector('#edit-transition-menu-delete').addEventListener('click', () => { 37 | this.dispatchEvent('delete') 38 | }) 39 | 40 | document.querySelector('#edit-transition-menu-edit').addEventListener('click', () => { 41 | this.dispatchEvent('editTransition') 42 | }) 43 | 44 | elem.addEventListener('DOMNodeRemoved', () => { 45 | this.dispatchEvent('close') 46 | }) 47 | 48 | // If the user clicks elsewhere in the webpage, delete the menu 49 | window.addEventListener('click', () => { 50 | this.deletePrevious() 51 | }) 52 | } 53 | 54 | /** 55 | * Delete the previous EditNodeMenu if it exists in the DOM 56 | */ 57 | deletePrevious () { 58 | const elem = document.querySelector('.edit-menu') 59 | 60 | // Delete the previous menu first 61 | if (elem !== null) { 62 | elem.parentNode.removeChild(elem) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/js/elements/fsa_description.js: -------------------------------------------------------------------------------- 1 | export default class FSADescription { 2 | constructor (selector) { 3 | this.selector = selector 4 | 5 | this.tableSelector = `${selector} .table` 6 | this.statesSelector = `${selector} .states` 7 | this.alphabetSelector = `${selector} .alphabet` 8 | this.acceptStatesSelector = `${selector} .acceptStates` 9 | this.startStateSelector = `${selector} .startState` 10 | 11 | this.reset() 12 | } 13 | 14 | reset () { 15 | document.querySelector(this.statesSelector).innerHTML = '' 16 | document.querySelector(this.alphabetSelector).innerHTML = '' 17 | document.querySelector(this.acceptStatesSelector).innerHTML = '' 18 | document.querySelector(this.startStateSelector).innerHTML = '' 19 | document.querySelector(this.tableSelector).innerHTML = ` 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ` 48 | } 49 | 50 | update (fsa, isNFA) { 51 | const fsaCopy = JSON.parse(JSON.stringify(fsa)) 52 | let states = fsaCopy.states 53 | let startState = fsaCopy.startState 54 | let acceptStates = fsaCopy.acceptStates 55 | 56 | if (!isNFA) { 57 | states = fsaCopy.states.map(e => `{${e}}`) 58 | startState = `{${fsaCopy.startState}}` 59 | acceptStates = fsaCopy.acceptStates.map(e => `{${e}}`) 60 | } 61 | 62 | document.querySelector(this.statesSelector).innerHTML = `{${states.join(', ')}}` 63 | document.querySelector(this.alphabetSelector).innerHTML = `{${fsaCopy.alphabet.filter(e => e !== 'ε').join(', ')}}` 64 | document.querySelector(this.acceptStatesSelector).innerHTML = `{${acceptStates.join(', ')}}` 65 | document.querySelector(this.startStateSelector).innerHTML = startState || '' 66 | 67 | const rows = [] 68 | const alphabet = fsaCopy.alphabet 69 | if (isNFA) { 70 | alphabet.push('ε') 71 | } 72 | 73 | for (const state of fsaCopy.states) { 74 | const transitions = [state] 75 | for (let i = 0; i < alphabet.length; i++) { 76 | const symbol = alphabet[i] 77 | if (fsaCopy.transitions[state] && fsaCopy.transitions[state][symbol]) { 78 | transitions.push(fsaCopy.transitions[state][symbol].join(', ')) 79 | } else { 80 | transitions.push('') 81 | } 82 | } 83 | rows.push(transitions) 84 | } 85 | 86 | document.querySelector(this.tableSelector).innerHTML = ` 87 | 88 | 89 | 90 | ${alphabet.map(e => `${e}`).join('')} 91 | 92 | 93 | 94 | ${rows.map(r => { 95 | return `${r.map(t => `${t}`).join('')}` 96 | }).join('')} 97 | ` 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/js/elements/overlay_message.js: -------------------------------------------------------------------------------- 1 | import EventHandler from '../util/event_handler.js' 2 | 3 | export default class OverlayMessage extends EventHandler { 4 | /** 5 | * Create a message overlay on top of the given element 6 | * 7 | * @param {String} selector The selector for the element 8 | * @param {String} message The message to put in the center of the overlay 9 | */ 10 | constructor (selector, message) { 11 | super() 12 | this.deletePrevious() 13 | 14 | document.querySelector(selector).insertAdjacentHTML('beforeend', `
${message}
`) 15 | 16 | document.addEventListener('keydown', this.keydown.bind(this)) 17 | } 18 | 19 | keydown (e) { 20 | this.dispatchEvent('keydown', e) 21 | } 22 | 23 | /** 24 | * Update the message in the overlay 25 | * 26 | * @param {String} message The new message 27 | */ 28 | setMessage (message) { 29 | document.querySelector('#message-overlay').innerHTML = message 30 | } 31 | 32 | /** 33 | * Delete the previous EditNodeMenu if it exists in the DOM 34 | */ 35 | deletePrevious () { 36 | const elem = document.querySelector('.message-overlay') 37 | 38 | // Delete the previous menu first 39 | if (elem !== null) { 40 | elem.parentNode.removeChild(elem) 41 | document.removeEventListener('keydown', this.keydown.bind(this)) 42 | this.dispatchEvent('close') 43 | this.eventListeners = [] 44 | delete this.eventListeners 45 | delete this 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/js/fsa/animated_nfa_converter.js: -------------------------------------------------------------------------------- 1 | import EventHandler from '../util/event_handler.js' 2 | 3 | export default class AnimatedNFAConverter extends EventHandler { 4 | constructor (converter, visualDFA, speed) { 5 | super() 6 | this.converter = converter 7 | this.visualDFA = visualDFA 8 | this.speed = speed 9 | } 10 | 11 | stop () { 12 | if (this.interval) { 13 | clearInterval(this.interval) 14 | this.dispatchEvent('stop') 15 | } 16 | } 17 | 18 | step (onError) { 19 | try { 20 | const [newDFA, step] = this.converter.stepForward() 21 | if (newDFA && step) { 22 | this.visualDFA.performStep(step, newDFA) 23 | document.querySelector('#dfa-conversion-step').innerHTML = step.desc 24 | } else { 25 | this.stop() 26 | this.dispatchEvent('complete') 27 | } 28 | } catch (e) { 29 | onError(e) 30 | this.stop() 31 | } 32 | } 33 | 34 | play (onError) { 35 | this.dispatchEvent('start') 36 | this.step() 37 | this.interval = setInterval(() => { 38 | this.step(onError) 39 | }, this.speed) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/js/fsa/fsa.js: -------------------------------------------------------------------------------- 1 | import { UnknownStateError, UnknownSymbolError } from '../util/errors.js' 2 | import { removeDuplicates } from '../util/array.js' 3 | 4 | export default class FSA { 5 | /** 6 | * FSA represents a finite state automaton. This can be either an NFA or a DFA. 7 | * 8 | * @param {Array} states The array of states in this FSA (e.g. ['1', '2', '3']) 9 | * @param {Array} alphabet The array of symbols in this FSA (e.g. ['a', 'b']) 10 | * @param {Object} transitions A map of states and symbols to other states (e.g. transitions['1']['a'] => ['2', '3'] means upon the input of symbol a at state 1, an NFA can transition to state 2 or state 3) 11 | * @param {String} startState The name of the start state for this FSA (e.g. '1') 12 | * @param {Array} acceptStates The array of states that an input can be accepted on (e.g. ['1', '3']) 13 | */ 14 | constructor (states, alphabet, transitions, startState, acceptStates) { 15 | this.states = states 16 | this.alphabet = alphabet 17 | this.transitions = transitions 18 | this.startState = startState 19 | this.acceptStates = acceptStates 20 | } 21 | 22 | /** 23 | * Clone this FSA into a new object instead of copying its reference 24 | * This is useful for freezing the state of the FSA and passing it to a function 25 | */ 26 | clone () { 27 | return new FSA(JSON.parse(JSON.stringify(this.states)), JSON.parse(JSON.stringify(this.alphabet)), JSON.parse(JSON.stringify(this.transitions)), this.startState, JSON.parse(JSON.stringify(this.acceptStates))) 28 | } 29 | 30 | /** 31 | * Remove all of a state's references from the FSA 32 | * 33 | * @param {String} state The name of the state 34 | */ 35 | removeState (state) { 36 | this.states = this.states.filter(s => s !== state) 37 | this.acceptStates = this.acceptStates.filter(s => s !== state) 38 | if (this.startState === state) this.startState = undefined 39 | delete this.transitions[state] 40 | 41 | // Remove all transitions that lead to the state 42 | for (const fromState of Object.keys(this.transitions)) { 43 | for (const symbol of Object.keys(this.transitions[fromState])) { 44 | if (this.transitions[fromState][symbol]) { 45 | this.transitions[fromState][symbol] = this.transitions[fromState][symbol].filter(e => e !== state) 46 | if (this.transitions[fromState][symbol].length === 0) { delete this.transitions[fromState][symbol] } 47 | } 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * Merge two states into a single state 54 | * 55 | * @param {String} s1 The first state 56 | * @param {String} s2 The second state 57 | */ 58 | mergeStates (s1, s2) { 59 | // Create the new state 60 | const newState = `${s1}+${s2}` 61 | this.states.push(newState) 62 | if (this.acceptStates.includes(s1)) { this.acceptStates.push(newState) } 63 | if (this.startState === s1 || this.startState === s2) { this.startState = newState } 64 | 65 | // Add loopback on the new state for every symbol 66 | this.transitions[newState] = {} 67 | for (const symbol of this.alphabet) { 68 | this.transitions[newState][symbol] = [newState] 69 | } 70 | 71 | // Add incoming transitions to the new state using the old states' incoming transitions 72 | for (const state of this.states.filter(e => e !== s1 && e !== s2)) { 73 | for (const symbol of this.alphabet) { 74 | if (this.transitions[state][symbol][0] === s1 || this.transitions[state][symbol][0] === s2) { 75 | this.transitions[state][symbol] = [newState] 76 | } 77 | } 78 | } 79 | 80 | // Remove the old states 81 | this.removeState(s1) 82 | this.removeState(s2) 83 | } 84 | 85 | /** 86 | * Get the array of arrays that describes the powerset of this FSA's states 87 | * 88 | * @example 89 | * console.log(fsa.states) 90 | * // => ['1', '2', '3'] 91 | * 92 | * console.log(fsa.getPowersetOfStates()) 93 | * // => [['Ø'], ['1'], ['2'], ['1', '2'], ['3'], ['1', '3'], ['2', '3'], ['1', '2', '3']] 94 | */ 95 | getPowersetOfStates () { 96 | const result = [] 97 | result.push(['Ø']) 98 | 99 | // https://stackoverflow.com/a/42774138 How to find all subsets of a set in JavaScript? 100 | for (let i = 1; i < (1 << this.states.length); i++) { 101 | const subset = [] 102 | for (let j = 0; j < this.states.length; j++) { if (i & (1 << j)) subset.push(this.states[j]) } 103 | 104 | result.push(subset.sort()) 105 | } 106 | 107 | return result 108 | } 109 | 110 | /** 111 | * This function implements E(R) from the NFA to DFA conversion description: 112 | * 113 | * E(R) = R ∪ { q | there is an r in R with an ε transition to q } 114 | * 115 | * @param {String} fromState The label of the state to find epsilon-reachable states from 116 | * @returns {Array} The array of states that can be reached via an ε-transition 117 | */ 118 | getEpsilonClosureStates (fromState, checkedStates = []) { 119 | if (!this.states.includes(fromState)) throw new UnknownStateError(fromState) 120 | 121 | // Ensure fromState has any epsilon transitions 122 | if (!this.transitions[fromState] || !this.transitions[fromState]['ε']) { 123 | return [fromState] 124 | } 125 | 126 | const toStates = this.transitions[fromState]['ε'] 127 | 128 | // Recursively check for epsilon transitions 129 | // Avoid infinite loop by omitting already-checked states 130 | const allEpsilonClosureStates = toStates 131 | .filter(s => checkedStates.indexOf(s) === -1) 132 | .map(s => this.getEpsilonClosureStates(s, [...checkedStates, ...toStates])) 133 | 134 | return removeDuplicates([fromState, ...allEpsilonClosureStates].flat()) 135 | } 136 | 137 | /** 138 | * Find the array of states that are able to be reached by the given state. This includes via ε-transitions 139 | * 140 | * @param {String} fromState The label of the state to find reachable states from 141 | * @param {String} symbol The symbol on which to search the transitions 142 | * @returns {Array} The list of states that can be reached from the given state 143 | */ 144 | getReachableStates (fromState, symbol, list = []) { 145 | if (!this.states.includes(fromState)) throw new UnknownStateError(fromState) 146 | if (symbol !== 'ε' && !this.alphabet.includes(symbol)) throw new UnknownSymbolError(symbol) 147 | if (list.length > 200) throw new Error('There is an infinite transition loop apparent in the NFA') 148 | 149 | if (!this.transitions[fromState] || !this.transitions[fromState][symbol]) { 150 | return symbol === 'ε' ? [] : ['Ø'] 151 | } 152 | 153 | // Add the state's transitions on the given label to the list 154 | list = list.concat(this.transitions[fromState][symbol]) 155 | 156 | // Check ε-transitions of the states that can be directly reached 157 | for (const s of this.transitions[fromState][symbol]) { 158 | if (this.transitions[s] && this.transitions[s]['ε'] !== undefined) { 159 | // Recursively search for ε-transitions and add them to the list 160 | list = this.getReachableStates(s, 'ε', list) 161 | } 162 | } 163 | 164 | return removeDuplicates(list) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/js/fsa/nfa_converter.js: -------------------------------------------------------------------------------- 1 | import FSA from './fsa.js' 2 | import { removeDuplicates } from '../util/array.js' 3 | 4 | export default class NFAConverter { 5 | /** 6 | * NFAConverter provides the ability to convert the given NFA to a DFA in incremental steps 7 | * 8 | * @param {FSA} nfa The NFA to convert to a DFA 9 | */ 10 | constructor (nfa) { 11 | this.nfa = nfa 12 | 13 | // dfa is the FSA that NFAConverter performs each step upon 14 | this.dfa = undefined 15 | 16 | // steps is the list of steps that have occurred thus far 17 | this.steps = [] 18 | 19 | // state_index holds which state will have a transition generated next 20 | this.state_index = 0 21 | 22 | // alphabet_index holds which symbol will be used to generate the next transition 23 | this.alphabet_index = 0 24 | 25 | // unreachableStates is the array of states that are unreachable 26 | // This is generated after all transitions are generated 27 | this.unreachableStates = undefined 28 | 29 | // redundantStates is the array of states that can be combined into a single state 30 | this.redundantStates = undefined 31 | } 32 | 33 | /** 34 | * Get the ID of the next step in the conversion process 35 | * 36 | * @returns {String} The ID of the next step to be performed 37 | */ 38 | getNextStep () { 39 | if (this.dfa === undefined) return 'initialize' 40 | if (this.state_index < this.dfa.states.length) return 'add_transition' 41 | 42 | if (!this.unreachableStates) { this.unreachableStates = this.getUnreachableStates() } 43 | if (this.unreachableStates.length > 0) return 'delete_state' 44 | 45 | if (!this.redundantStates) { this.redundantStates = this.getRedundantStates() } 46 | if (this.redundantStates.length > 0) return 'merge_states' 47 | } 48 | 49 | /** 50 | * Get all the unreachable states of the converted DFA 51 | * 52 | * @param {FSA} tempDFA A temporary DFA to work off of 53 | * @param {Array} list The accumulating list of unreachable nodes. It is appended to recursively. 54 | * @returns {Array} The list of states without any incoming transitions 55 | */ 56 | getUnreachableStates (tempDFA = undefined, list = []) { 57 | if (!tempDFA) { 58 | tempDFA = this.dfa.clone() 59 | } 60 | 61 | const nodesWithIncomingEdges = [] 62 | 63 | // Iterate through all transitions and add the end nodes to the nodesWithIncomingEdges array 64 | for (const state of tempDFA.states) { 65 | for (const symbol of tempDFA.alphabet) { 66 | const node = tempDFA.transitions[state][symbol].join(',') 67 | 68 | // Don't consider nodes that have a transition back to themselves 69 | if (node !== state) nodesWithIncomingEdges.push(node) 70 | } 71 | } 72 | 73 | // The list of unreachable states are those that don't exist in the nodesWithIncomingEdges array 74 | // Make sure the start state is always in the final DFA by filtering it out of the resulting array 75 | const nodesWithoutIncomingEdges = tempDFA.states.filter(s => !nodesWithIncomingEdges.includes(s) && s !== tempDFA.startState) 76 | 77 | // If there were unreachable nodes, delete them and then recursively search for more 78 | if (nodesWithoutIncomingEdges.length > 0) { 79 | // Remove the nodes from the temporary DFA 80 | nodesWithoutIncomingEdges.forEach(n => tempDFA.removeState(n)) 81 | 82 | // Recursively search for more unreachable nodes after deletion 83 | // Concat the unreachable nodes to the running list 84 | list = this.getUnreachableStates(tempDFA, list.concat(nodesWithoutIncomingEdges)) 85 | } 86 | 87 | return removeDuplicates(list) 88 | } 89 | 90 | /** 91 | * Get all pairs of states that are redundant (i.e. can be combined into a single state with a loopback) 92 | * An example of such a pair is {2,3} and {1,2,3} in the Preset #3 resulting DFA 93 | * 94 | * @param {FSA} tempDFA A temporary DFA to work off of 95 | * @param {Array} list The accumulating list of redundant state pairs. It is appended to recursively. 96 | * @returns {Array} The list of state pairs that are redundant 97 | */ 98 | getRedundantStates (tempDFA = undefined, list = []) { 99 | if (!tempDFA) { 100 | tempDFA = this.dfa.clone() 101 | } 102 | 103 | /** 104 | * To be redundant: 105 | * 106 | * 1. Both states must be accept states or non-accept states 107 | * 2. Every symbol in the alphabet must have a transition within the two states 108 | */ 109 | 110 | for (const s1 of tempDFA.states) { 111 | for (const s2 of tempDFA.states.filter(e => e !== s1)) { 112 | // 1. Both states must be accept states or non-accept states 113 | if ((tempDFA.acceptStates.includes(s1) && tempDFA.acceptStates.includes(s2)) || 114 | (!tempDFA.acceptStates.includes(s1) && !tempDFA.acceptStates.includes(s2))) { 115 | let redundant = true 116 | 117 | // 2. Every symbol in the alphabet must have a transition within the two states 118 | for (const symbol of tempDFA.alphabet) { 119 | if ((tempDFA.transitions[s1][symbol][0] !== s1 && tempDFA.transitions[s1][symbol][0] !== s2) || 120 | (tempDFA.transitions[s2][symbol][0] !== s2 && tempDFA.transitions[s2][symbol][0] !== s1)) { 121 | redundant = false 122 | } 123 | } 124 | 125 | if (redundant) { 126 | tempDFA.mergeStates(s1, s2) 127 | 128 | // Add the pair of redundant states to the list and recursively search for more 129 | list.push([s1, s2]) 130 | return this.getRedundantStates(tempDFA, list) 131 | } 132 | } 133 | } 134 | } 135 | 136 | return list 137 | } 138 | 139 | /** 140 | * The first step in the conversion process is to generate the initial DFA as the powerset 141 | * of states in the NFA 142 | * 143 | * @returns {Array} The DFA after this step and the step that was performed 144 | */ 145 | initializeDFA () { 146 | const powerset = this.nfa.getPowersetOfStates() 147 | 148 | // The new list of states is the powerset of the original states 149 | const states = powerset.map(e => e.join(',')) 150 | 151 | // Build an empty map of transitions 152 | // e.g. {1: {a: undefined, b: undefined}, 2: {a: undefined, b: undefined}} 153 | const transitions = {} 154 | for (const s of states) { 155 | transitions[s] = {} 156 | for (const e of this.nfa.alphabet) { 157 | transitions[s][e] = undefined 158 | } 159 | } 160 | 161 | // The new start state is the states that are reachable from the original start state 162 | // e.g. '1' has an ε-transition to '3'; therefore, the new start state is '1,3' 163 | const startState = removeDuplicates(this.nfa.getEpsilonClosureStates(this.nfa.startState)).join(',') 164 | 165 | // The new list of accept states are any states from the powerset with the original accept state in them 166 | // e.g. '1' is the accept state; therefore, '1', '1,2', '1,3', and '1,2,3' are accept states 167 | const acceptStates = powerset.filter(e => { 168 | for (const s of this.nfa.acceptStates) { if (e.includes(s)) return true } 169 | 170 | return false 171 | }).map(e => e.join(',')) 172 | 173 | // For sanity, let's make sure the new start state is actually a member of the list of states 174 | if (!states.includes(startState)) { throw new Error(`startState ${startState} is not a member of state powerset [${states}]`) } 175 | 176 | this.dfa = new FSA(states, this.nfa.alphabet, transitions, startState, acceptStates) 177 | 178 | const step = [this.dfa.clone(), { 179 | type: 'initialize', 180 | desc: 'Initialize the DFA' 181 | }] 182 | this.steps.push(step) 183 | return step 184 | } 185 | 186 | /** 187 | * Generate the next transition in the DFA by following the state_index and alphabet_index 188 | * 189 | * @param {Number} prevStateIndex The state_index prior to this step 190 | * @param {Number} prevAlphabetIndex The alphabet_index prior to this step 191 | * 192 | * @returns {Array} The DFA after this step and the step that was performed 193 | */ 194 | addNextTransition (prevStateIndex, prevAlphabetIndex) { 195 | const state = this.dfa.states[this.state_index] 196 | const symbol = this.dfa.alphabet[this.alphabet_index] 197 | 198 | if (this.state_index === 0) { 199 | // If we're at state index 0, we're at Ø. We need an infinite loopback on Ø. 200 | this.dfa.transitions['Ø'][symbol] = ['Ø'] 201 | } else { 202 | let reachableStates = [] 203 | 204 | // Get all reachable states for every individual state 205 | // e.g. '1,2' is the current state; therefore, we need to concatenate the reachable 206 | // states from '1' with the reachable states from '2' 207 | state.split(',').forEach(s => { 208 | reachableStates = reachableStates.concat(this.nfa.getReachableStates(s, symbol)) 209 | }) 210 | 211 | reachableStates = removeDuplicates(reachableStates) 212 | 213 | // Remove Ø if the state has other possibilites 214 | if (reachableStates.some(e => e !== 'Ø')) { 215 | reachableStates = reachableStates.filter(e => e !== 'Ø') 216 | } else { 217 | reachableStates = ['Ø'] 218 | } 219 | 220 | // Update the transition 221 | this.dfa.transitions[state][symbol] = [reachableStates.join(',')] 222 | } 223 | 224 | this.alphabet_index++ 225 | 226 | const toState = this.dfa.transitions[state][symbol].join(',') 227 | const step = [this.dfa.clone(), { 228 | type: 'add_transition', 229 | desc: `Add a transition from {${state}} on input ${symbol} to {${toState}}`, 230 | fromState: state, 231 | toState: toState, 232 | symbol: symbol, 233 | prevStateIndex: prevStateIndex, 234 | prevAlphabetIndex: prevAlphabetIndex 235 | }] 236 | this.steps.push(step) 237 | return step 238 | } 239 | 240 | /** 241 | * Delete the next unreachable state at the beginning of the unreachableStates array 242 | * 243 | * @returns {Array} The DFA after this step and the step that was performed 244 | */ 245 | deleteNextUnreachableState () { 246 | // Pop the first state from unreachableStates 247 | const stateToDelete = this.unreachableStates.shift() 248 | 249 | const step = [this.dfa.clone(), { 250 | type: 'delete_state', 251 | desc: `Delete unreachable state {${stateToDelete}}`, 252 | state: stateToDelete, 253 | transitions: this.dfa.transitions[stateToDelete] !== undefined ? Object.assign({}, this.dfa.transitions[stateToDelete]) : undefined 254 | }] 255 | this.steps.push(step) 256 | 257 | this.dfa.removeState(stateToDelete) 258 | return step 259 | } 260 | 261 | /** 262 | * Merge the next redundant states at the beginning of the redundantStates array 263 | * 264 | * @returns {Array} The DFA after this step and the step that was performed 265 | */ 266 | mergeNextRedundantStates () { 267 | // Pop the first state from redundantStates 268 | const pairToMerge = this.redundantStates.shift() 269 | 270 | const step = [this.dfa.clone(), { 271 | type: 'merge_states', 272 | desc: `Merge redundant states {${pairToMerge[0]}} and {${pairToMerge[1]}}`, 273 | states: pairToMerge 274 | }] 275 | this.steps.push(step) 276 | 277 | this.dfa.mergeStates(pairToMerge[0], pairToMerge[1]) 278 | return step 279 | } 280 | 281 | /** 282 | * Perform a single step in the conversion from NFA to DFA 283 | * 284 | * @returns {Array} The new DFA and the step that was performed 285 | */ 286 | stepForward () { 287 | // Adjust alphabet and state indices for adding transitions 288 | const prevStateIndex = this.state_index 289 | const prevAlphabetIndex = this.alphabet_index 290 | if (this.dfa && this.alphabet_index === this.dfa.alphabet.length) { 291 | this.state_index++ 292 | this.alphabet_index = 0 293 | } 294 | 295 | switch (this.getNextStep()) { 296 | case 'initialize': 297 | return this.initializeDFA() 298 | 299 | case 'add_transition': 300 | return this.addNextTransition(prevStateIndex, prevAlphabetIndex) 301 | 302 | case 'delete_state': 303 | return this.deleteNextUnreachableState() 304 | 305 | case 'merge_states': 306 | return this.mergeNextRedundantStates() 307 | } 308 | 309 | return [undefined, undefined] 310 | } 311 | 312 | /** 313 | * Undo the previous step in the conversion process 314 | * 315 | * @returns {Array} The new DFA and the step that was performed 316 | */ 317 | stepBackward () { 318 | if (this.steps.length === 0) { return } 319 | const [prevDFA, prevStep] = this.steps.pop() 320 | 321 | switch (prevStep.type) { 322 | case 'initialize': { 323 | this.dfa = undefined 324 | this.steps = [] 325 | this.state_index = 0 326 | this.alphabet_index = 0 327 | this.unreachableStates = undefined 328 | 329 | return [prevDFA, prevStep] 330 | } 331 | 332 | case 'add_transition': { 333 | this.state_index = prevStep.prevStateIndex 334 | this.alphabet_index = prevStep.prevAlphabetIndex 335 | break 336 | } 337 | 338 | case 'delete_state': { 339 | this.unreachableStates.unshift(prevStep.state) 340 | break 341 | } 342 | 343 | case 'merge_states': { 344 | this.redundantStates.unshift(prevStep.states) 345 | break 346 | } 347 | } 348 | 349 | this.dfa = prevDFA 350 | 351 | return [prevDFA, prevStep] 352 | } 353 | 354 | /** 355 | * Perform a specific number of steps in the conversion from NFA to DFA 356 | * 357 | * @param {number} n The number of steps to perform 358 | * @returns {FSA} The new DFA after all of the steps have been performed 359 | */ 360 | step (n) { 361 | for (let i = 0; i < n; i++) { this.stepForward() } 362 | 363 | return this.dfa 364 | } 365 | 366 | /** 367 | * Complete the entire conversion process 368 | * 369 | * @returns {Array} Every step that was performed 370 | */ 371 | complete () { 372 | const allSteps = [] 373 | 374 | while (true) { 375 | const [newDFA, step] = this.stepForward() 376 | if (newDFA === undefined || step === undefined) break 377 | allSteps.push([Object.assign({}, newDFA), step]) 378 | } 379 | 380 | return allSteps 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/js/fsa/visual_fsa.js: -------------------------------------------------------------------------------- 1 | import EventHandler from '../util/event_handler.js' 2 | import { UnknownStateError } from '../util/errors.js' 3 | import { removeDuplicates } from '../util/array.js' 4 | import FSA from './fsa.js' 5 | import Location from '../canvas/location.js' 6 | import EditNodeMenu from '../elements/edit_node_menu.js' 7 | import AddNodeMenu from '../elements/add_node_menu.js' 8 | import EditTransitionMenu from '../elements/edit_transition_menu.js' 9 | import OverlayMessage from '../elements/overlay_message.js' 10 | import Circle from '../canvas/drawables/circle.js' 11 | import Text from '../canvas/drawables/text.js' 12 | import QuadraticCurvedLine from '../canvas/drawables/quadratic_curved_line.js' 13 | import BezierCurvedLine from '../canvas/drawables/bezier_curved_line.js' 14 | import ArrowedStraightLine from '../canvas/drawables/arrowed_straight_line.js' 15 | 16 | const NODE_RADIUS = 30 17 | const NODE_COLOR = '#34b1eb' 18 | const NODE_LABEL_SIZE = 24 19 | const NODE_OUTLINE_RADIUS = 5 20 | 21 | const START_NODE_ARROW_LENGTH = 100 22 | const START_NODE_ARROW_ANGLE = -135 * (Math.PI / 180) 23 | 24 | const TRANSITION_WIDTH = 3 25 | const TRANSITION_COLOR = 'rgba(0,0,0,1)' 26 | const TRANSITION_ARROW_RADIUS = 10 27 | const TRANSITION_CONTROL_RADIUS = 60 28 | const TRANSITION_TEXT_RADIUS = 25 29 | 30 | const SELF_TRANSITION_CONTROL_RADIUS = 125 31 | const SELF_TRANSITION_START_ANGLE = Math.PI 32 | const SELF_TRANSITION_END_ANGLE = 3 * Math.PI / 2 33 | 34 | const DFA_START_LOCATION = { x: 85, y: 150 } 35 | const DFA_NODE_DISTANCE = 175 36 | 37 | export default class VisualFSA extends EventHandler { 38 | constructor (draggableCanvas, isDFA) { 39 | super() 40 | this.draggableCanvas = draggableCanvas 41 | this.fsa = new FSA([], [], {}, undefined, []) 42 | this.nodes = [] 43 | this.isDFA = isDFA 44 | 45 | if (!isDFA) { 46 | // Listen for mouse moves to draw a transition-in-progress 47 | this.draggableCanvas.addEventListener('mousemove', e => { 48 | if (this.addingTransitionNode) { 49 | this.transitionInProgress = this.getQuadraticLine(this.addingTransitionNode.loc, e.loc) 50 | this.render() 51 | } 52 | }) 53 | 54 | // Listen for mouse down to add a transition 55 | this.draggableCanvas.addEventListener('mousedown', e => { 56 | if (this.addingTransitionNode) { 57 | if (e.obj && e.obj instanceof Circle && e.obj.options.text) { 58 | const fromState = this.addingTransitionNode.label 59 | const endState = e.obj.options.text.options.text 60 | this.addTransitionOverlay(fromState, endState) 61 | } else { 62 | this.addingTransitionNode = undefined 63 | this.transitionInProgress = undefined 64 | this.render() 65 | } 66 | } 67 | }) 68 | 69 | // Listen for right clicks on an empty spot to create new nodes 70 | this.draggableCanvas.addEventListener('rightclick', e => { 71 | // Don't show the menu if the user is currently creating a transition 72 | if (this.transitionInProgress) { return } 73 | 74 | const addMenu = new AddNodeMenu(e.clientX, e.clientY) 75 | addMenu.addEventListener('create', () => { 76 | this.addNode(this.getNextStateNumber().toString(), e.loc) 77 | this.render() 78 | }) 79 | }) 80 | 81 | // Listen for keydown to stop transition-in-progress if the user presses escape 82 | document.addEventListener('keydown', e => { 83 | if (e.key === 'Escape') { 84 | this.addingTransitionNode = undefined 85 | this.transitionInProgress = undefined 86 | if (this.overlay) { this.overlay.deletePrevious() } 87 | this.render() 88 | } 89 | }) 90 | } 91 | } 92 | 93 | /** 94 | * Convert the VisualFSA to a JSON string for storage 95 | * @returns {String} The JSON string blob representing this VisualFSA 96 | */ 97 | toJSON () { 98 | return JSON.stringify({ 99 | nodes: this.nodes, 100 | fsa: this.fsa 101 | }) 102 | } 103 | 104 | /** 105 | * Rebuild the VisualFSA from a saved JSON string and cast its contents to the appropriate classes 106 | * @param {String} str The JSON string 107 | */ 108 | fromJSON (str) { 109 | const obj = JSON.parse(str) 110 | if (!obj.nodes || !obj.fsa) { throw new Error('improperly formatted visual FSA') } 111 | 112 | this.nodes = obj.nodes 113 | 114 | // Cast the given FSA 115 | this.fsa = Object.assign(new FSA(), obj.fsa) 116 | 117 | // Cast node locations to Locations 118 | for (const node of this.nodes) { 119 | node.loc = new Location(node.loc.x, node.loc.y) 120 | } 121 | 122 | this.render() 123 | this.dispatchEvent('change') 124 | } 125 | 126 | /** 127 | * Completely wipe the VisualFSA and start from scratch 128 | */ 129 | reset () { 130 | this.nodes = [] 131 | this.fsa = new FSA([], [], {}, undefined, []) 132 | this.render() 133 | this.dispatchEvent('change') 134 | } 135 | 136 | /** 137 | * Update the VisualFSA start state 138 | * @param {String} label The state label 139 | */ 140 | setStartState (label) { 141 | if (!this.fsa.states.includes(label)) { throw new UnknownStateError(label) } 142 | 143 | this.fsa.startState = label 144 | this.dispatchEvent('change') 145 | } 146 | 147 | /** 148 | * Add an accept state to the VisualFSA 149 | * @param {String} label The state label 150 | */ 151 | addAcceptState (label) { 152 | if (!this.fsa.states.includes(label)) { throw new UnknownStateError(label) } 153 | 154 | this.fsa.acceptStates.push(label) 155 | this.getNode(label).acceptState = true 156 | this.dispatchEvent('change') 157 | } 158 | 159 | /** 160 | * Remove an accept state from the VisualFSA 161 | * @param {String} label The state label 162 | */ 163 | removeAcceptState (label) { 164 | if (!this.fsa.states.includes(label)) { throw new UnknownStateError(label) } 165 | 166 | this.fsa.acceptStates = this.fsa.acceptStates.filter(e => e !== label) 167 | this.getNode(label).acceptState = false 168 | this.dispatchEvent('change') 169 | } 170 | 171 | /** 172 | * Add a new state to the VisualFSA at the given location 173 | * @param {String} label The state label 174 | * @param {Location} loc The location to place the new state 175 | */ 176 | addNode (label, loc) { 177 | this.fsa.states.push(label) 178 | this.nodes.push({ 179 | label: label, 180 | loc: loc, 181 | transitionText: {} 182 | }) 183 | this.dispatchEvent('change') 184 | } 185 | 186 | /** 187 | * Remove a state from the VisualFSA and update the alphabet and all transitions 188 | * @param {String} label The state label 189 | */ 190 | removeNode (label) { 191 | if (!this.fsa.states.includes(label)) { throw new UnknownStateError(label) } 192 | 193 | this.fsa.removeState(label) 194 | this.nodes = this.nodes.filter(e => e.label !== label) 195 | for (const node of this.nodes) { 196 | if (node.transitionText[label]) delete node.transitionText[label] 197 | } 198 | 199 | this.updateAlphabet() 200 | this.dispatchEvent('change') 201 | } 202 | 203 | /** 204 | * Find the node object with the given state label 205 | * @param {String} label The state label 206 | * @returns {Object} The node object for the given state 207 | */ 208 | getNode (label) { 209 | const node = this.nodes.find(e => e.label === label) 210 | if (!node) { throw new UnknownStateError(label) } 211 | 212 | return node 213 | } 214 | 215 | /** 216 | * Get the next state number to use for a new state. 217 | * Incrementally searches starting at 1 for the next available label. 218 | * @returns {Number} The next state number 219 | */ 220 | getNextStateNumber () { 221 | for (let i = 1; i < 100; i++) { 222 | if (!this.fsa.states.includes(i.toString())) { return i } 223 | } 224 | 225 | throw new Error('max state count exceeded') 226 | } 227 | 228 | /** 229 | * Parse the FSA's transition map to infer the alphabet 230 | */ 231 | updateAlphabet () { 232 | const alphabet = [] 233 | for (const fromState of Object.keys(this.fsa.transitions)) { 234 | for (const symbol of Object.keys(this.fsa.transitions[fromState])) { 235 | if (symbol !== 'ε') { alphabet.push(symbol) } 236 | } 237 | } 238 | this.fsa.alphabet = removeDuplicates(alphabet) 239 | } 240 | 241 | /** 242 | * Create a new transition between two states on the given symbol 243 | * @param {String} from The state label for the origin state 244 | * @param {String} to The state label for the destination state 245 | * @param {String} symbol The alphabet symbol for the transition 246 | */ 247 | addTransition (from, to, symbol) { 248 | if (!this.fsa.states.includes(from)) { throw new UnknownStateError(from) } 249 | if (!this.fsa.states.includes(to)) { throw new UnknownStateError(to) } 250 | 251 | const fromNode = this.getNode(from) 252 | 253 | // Set up object structure if it doesn't exist 254 | if (!this.fsa.transitions[from]) this.fsa.transitions[from] = {} 255 | if (!this.fsa.transitions[from][symbol]) this.fsa.transitions[from][symbol] = [] 256 | if (!fromNode.transitionText[to]) fromNode.transitionText[to] = [] 257 | 258 | // Add the transitions to the arrays 259 | this.fsa.transitions[from][symbol].push(to) 260 | fromNode.transitionText[to].push(symbol) 261 | 262 | // Remove duplicates in case the user somehow added two of the same transitions 263 | fromNode.transitionText[to] = removeDuplicates(fromNode.transitionText[to]) 264 | this.fsa.transitions[from][symbol] = removeDuplicates(this.fsa.transitions[from][symbol]) 265 | 266 | this.updateAlphabet() 267 | this.dispatchEvent('change') 268 | } 269 | 270 | /** 271 | * Remove a single transition between the two given states on the given symbol 272 | * @param {String} from The state label for the origin state 273 | * @param {String} to The state label for the destination state 274 | * @param {String} symbol The alphabet symbol for the transition 275 | */ 276 | removeTransition (from, to, symbol) { 277 | if (!this.fsa.states.includes(from)) { throw new UnknownStateError(from) } 278 | if (!this.fsa.states.includes(to)) { throw new UnknownStateError(to) } 279 | 280 | const fromNode = this.getNode(from) 281 | 282 | // Delete transition in the FSA 283 | if (this.fsa.transitions[from][symbol]) { 284 | this.fsa.transitions[from][symbol] = this.fsa.transitions[from][symbol].filter(e => e !== to) 285 | if (this.fsa.transitions[from][symbol].length === 0) { delete this.fsa.transitions[from][symbol] } 286 | } 287 | 288 | // Delete transition in the node 289 | if (fromNode.transitionText[to]) { 290 | console.log('transition text', fromNode.transitionText[to]) 291 | fromNode.transitionText[to] = fromNode.transitionText[to].filter(e => e !== symbol) 292 | if (fromNode.transitionText[to].length === 0) { delete fromNode.transitionText[to] } 293 | } 294 | 295 | this.updateAlphabet() 296 | this.dispatchEvent('change') 297 | } 298 | 299 | /** 300 | * Remove all transitions between the two given states 301 | * @param {String} from The state label for the origin state 302 | * @param {String} to The state label for the destination state 303 | */ 304 | removeTransitions (from, to) { 305 | if (!this.fsa.states.includes(from)) { throw new UnknownStateError(from) } 306 | if (!this.fsa.states.includes(to)) { throw new UnknownStateError(to) } 307 | 308 | const fromNode = this.getNode(from) 309 | 310 | for (const symbol of this.fsa.alphabet.concat('ε')) { 311 | if (this.fsa.transitions[from][symbol] && this.fsa.transitions[from][symbol].includes(to)) { 312 | this.fsa.transitions[from][symbol] = this.fsa.transitions[from][symbol].filter(e => e !== to) 313 | if (this.fsa.transitions[from][symbol].length === 0) { delete this.fsa.transitions[from][symbol] } 314 | } 315 | } 316 | 317 | delete fromNode.transitionText[to] 318 | 319 | this.updateAlphabet() 320 | this.dispatchEvent('change') 321 | } 322 | 323 | /** 324 | * Get the symbol and add a transition between the two given states 325 | * @param {String} from The state label for the origin state 326 | * @param {String} to The state label for the destination state 327 | */ 328 | addTransitionOverlay (from, to) { 329 | this.overlay = new OverlayMessage('#nfa-container', 'Enter the symbol for the transition') 330 | this.overlay.addEventListener('keydown', function (e) { 331 | if (!this.overlay || e.key === 'Shift') return 332 | 333 | let key = e.key 334 | if (e.shiftKey) { key = key.toUpperCase() } 335 | 336 | if (key.length === 1) { 337 | this.addTransition(from, to, key === 'e' ? 'ε' : key) 338 | this.render() 339 | } 340 | 341 | this.overlay.deletePrevious() 342 | }.bind(this)) 343 | 344 | this.overlay.addEventListener('close', () => { 345 | this.addingTransitionNode = undefined 346 | this.transitionInProgress = undefined 347 | this.overlay = undefined 348 | this.draggableCanvas.draggingObject = undefined 349 | document.body.style.cursor = 'auto' 350 | this.render() 351 | }) 352 | } 353 | 354 | /** 355 | * Get a drawable object representing a curved quadratic line between the two states 356 | * @param {String} from The state label for the origin state 357 | * @param {String} to The state label for the destination state 358 | * @param {Object} fromNode The node object for the origin state 359 | * @param {Object} toNode The node object for the destination state 360 | * @returns {QuadraticCurvedLine} The drawable quadratic line to be placed onto the canvas 361 | */ 362 | getQuadraticLine (from, to, fromNode, toNode) { 363 | // Get the angle between the fromNode and the toNode 364 | const angleFromTo = from.angleTo(to) 365 | 366 | // Get the perpendicular angle to the angle between the fromNode and the toNode 367 | const perpendicularAngle = angleFromTo - (Math.PI / 2) 368 | 369 | // Get the midpoint between the fromNode and the toNode 370 | const midpoint = new Location((from.x + to.x) / 2, (from.y + to.y) / 2) 371 | 372 | // Set the control point of the quadratic curve to TRANSITION_CONTROL_RADIUS towards the perpendicular angle 373 | const controlPoint = midpoint.moveToAngle(perpendicularAngle, TRANSITION_CONTROL_RADIUS) 374 | 375 | // Calculate the outermost point of the fromNode so the beginning of the line extends perfectly from outside the circle 376 | const fromOutsideRadius = from.moveToAngle(from.angleTo(controlPoint), NODE_RADIUS + (fromNode && fromNode.acceptState ? NODE_OUTLINE_RADIUS : 0)) 377 | 378 | // Calculate the outermost point of the toNode so the arrowhead perfectly points to the circle 379 | const toOutsideRadius = to.moveFromAngle(controlPoint.angleTo(to), NODE_RADIUS + TRANSITION_ARROW_RADIUS + (toNode && toNode.acceptState ? NODE_OUTLINE_RADIUS : 0)) 380 | 381 | return new QuadraticCurvedLine(fromOutsideRadius, toOutsideRadius, controlPoint, { 382 | width: TRANSITION_WIDTH, 383 | color: TRANSITION_COLOR, 384 | arrowRadius: TRANSITION_ARROW_RADIUS 385 | }) 386 | } 387 | 388 | /** 389 | * Sync the FSA with the DFA following the step of the conversion process 390 | * 391 | * @param {Object} step The step's properties 392 | * @param {FSA} dfa The resulting DFA after this step's conversion 393 | */ 394 | performStep (step, dfa) { 395 | switch (step.type) { 396 | case 'initialize': { 397 | const maxCols = Math.ceil(dfa.states.length / (Math.log2(dfa.states.length) - 1)) 398 | let row = 0 399 | let col = 0 400 | 401 | for (const state of dfa.states) { 402 | const x = DFA_START_LOCATION.x + (col * DFA_NODE_DISTANCE) 403 | const y = DFA_START_LOCATION.y + (row * DFA_NODE_DISTANCE) 404 | 405 | this.addNode(state, new Location(x, y)) 406 | 407 | col++ 408 | if (col >= maxCols) { 409 | row++ 410 | col = 0 411 | } 412 | } 413 | 414 | this.setStartState(dfa.startState) 415 | dfa.acceptStates.forEach(e => this.addAcceptState(e)) 416 | 417 | return this.render() 418 | } 419 | 420 | case 'add_transition': { 421 | this.addTransition(step.fromState, step.toState, step.symbol) 422 | return this.render() 423 | } 424 | 425 | case 'delete_state': { 426 | step.location = this.getNode(step.state).loc 427 | this.removeNode(step.state) 428 | return this.render() 429 | } 430 | 431 | case 'merge_states': { 432 | const s1 = step.states[0] 433 | const n1 = this.getNode(s1) 434 | const s2 = step.states[1] 435 | const n2 = this.getNode(s2) 436 | const newState = `${s1}+${s2}` 437 | step.locations = [n1.loc, n2.loc] 438 | 439 | // Create the new state at the midpoint between the old states 440 | this.addNode(newState, new Location((n1.loc.x + n2.loc.x) / 2, (n1.loc.y + n2.loc.y) / 2)) 441 | if (this.fsa.acceptStates.includes(s1)) { this.addAcceptState(newState) } 442 | if (this.fsa.startState === s1 || this.fsa.startState === s2) { this.setStartState(newState) } 443 | 444 | // Add loopback on the new state for every symbol 445 | for (const symbol of this.fsa.alphabet) { 446 | this.addTransition(newState, newState, symbol) 447 | } 448 | 449 | // Add incoming transitions to the new state using the old states' incoming transitions 450 | for (const state of this.fsa.states.filter(e => e !== s1 && e !== s2)) { 451 | for (const symbol of this.fsa.alphabet) { 452 | if (this.fsa.transitions[state][symbol][0] === s1 || this.fsa.transitions[state][symbol][0] === s2) { 453 | this.addTransition(state, newState, symbol) 454 | } 455 | } 456 | } 457 | 458 | this.removeNode(s1) 459 | this.removeNode(s2) 460 | 461 | return this.render() 462 | } 463 | } 464 | } 465 | 466 | /** 467 | * Undo the given step by performing the opposite action 468 | * 469 | * @param {Object} step The step's properties 470 | * @param {FSA} dfa The previous DFA before this step's conversion 471 | */ 472 | undoStep (step, dfa) { 473 | switch (step.type) { 474 | case 'initialize': { 475 | this.fsa = new FSA([], [], {}, undefined, []) 476 | this.nodes = [] 477 | 478 | this.dispatchEvent('change') 479 | return this.render() 480 | } 481 | 482 | case 'add_transition': { 483 | this.removeTransition(step.fromState, step.toState, step.symbol) 484 | 485 | return this.render() 486 | } 487 | 488 | case 'delete_state': { 489 | this.addNode(step.state, step.location) 490 | if (dfa.startState === step.state) { this.setStartState(step.state) } 491 | if (dfa.acceptStates.includes(step.state)) { this.addAcceptState(step.state) } 492 | 493 | if (step.transitions) { 494 | for (const symbol of Object.keys(step.transitions)) { 495 | for (const endState of step.transitions[symbol]) { 496 | this.addTransition(step.state, endState, symbol) 497 | } 498 | } 499 | } 500 | 501 | return this.render() 502 | } 503 | 504 | case 'merge_states': { 505 | this.removeNode(`${step.states[0]}+${step.states[1]}`) 506 | this.addNode(step.states[0], step.locations[0]) 507 | this.addNode(step.states[1], step.locations[1]) 508 | 509 | if (dfa.startState === step.states[0]) { 510 | this.setStartState(step.states[0]) 511 | } else if (dfa.startState === step.states[1]) { 512 | this.setStartState(step.states[1]) 513 | } 514 | 515 | if (dfa.acceptStates.includes(step.states[0])) { this.addAcceptState(step.states[0]) } 516 | if (dfa.acceptStates.includes(step.states[1])) { this.addAcceptState(step.states[1]) } 517 | 518 | // Add external transitions between the two states 519 | for (const state of dfa.states) { 520 | for (const symbol of dfa.alphabet) { 521 | if (!dfa.transitions[state][symbol]) continue 522 | 523 | if (dfa.transitions[state][symbol].includes(step.states[0])) { 524 | this.addTransition(state, step.states[0], symbol) 525 | } 526 | 527 | if (dfa.transitions[state][symbol].includes(step.states[1])) { 528 | this.addTransition(state, step.states[1], symbol) 529 | } 530 | } 531 | } 532 | 533 | return this.render() 534 | } 535 | } 536 | } 537 | 538 | /** 539 | * Render the FSA onto the canvas 540 | */ 541 | render () { 542 | this.draggableCanvas.clear() 543 | 544 | if (this.transitionInProgress) { this.draggableCanvas.addObject(this.transitionInProgress) } 545 | 546 | // Draw transition lines 547 | for (const fromNode of this.nodes) { 548 | for (const endState of Object.keys(fromNode.transitionText)) { 549 | const toNode = this.getNode(endState) 550 | 551 | let textLocation 552 | let textRotation 553 | 554 | const editFn = e => { 555 | const editMenu = new EditTransitionMenu(e.clientX, e.clientY) 556 | editMenu.addEventListener('delete', () => { 557 | console.log('delete transition') 558 | this.removeTransitions(fromNode.label, toNode.label) 559 | this.render() 560 | }) 561 | editMenu.addEventListener('editTransition', () => { 562 | console.log('edit transition') 563 | this.removeTransitions(fromNode.label, toNode.label) 564 | this.addTransitionOverlay(fromNode.label, toNode.label) 565 | this.render(this.draggableCanvas) 566 | }) 567 | } 568 | 569 | if (fromNode.label !== toNode.label) { 570 | const angleFromTo = fromNode.loc.angleTo(toNode.loc) 571 | 572 | const transitionLine = this.getQuadraticLine(fromNode.loc, toNode.loc, fromNode, toNode) 573 | 574 | if (!this.isDFA) transitionLine.addEventListener('edit', editFn) 575 | this.draggableCanvas.addObject(transitionLine) 576 | 577 | textLocation = transitionLine.midpointPadded(TRANSITION_TEXT_RADIUS) 578 | textRotation = Math.abs(angleFromTo) > (Math.PI / 2) ? angleFromTo + Math.PI : angleFromTo 579 | } else { 580 | // Set the control points towards the start and end angles 581 | const cp1 = fromNode.loc.moveToAngle(-SELF_TRANSITION_START_ANGLE, SELF_TRANSITION_CONTROL_RADIUS) 582 | const cp2 = fromNode.loc.moveToAngle(-SELF_TRANSITION_END_ANGLE, SELF_TRANSITION_CONTROL_RADIUS) 583 | 584 | // Calculate the outermost point of the node so the beginning/end of the line perfectly touches the circle 585 | const fromOutsideRadius = fromNode.loc.moveToAngle(SELF_TRANSITION_START_ANGLE, NODE_RADIUS + (fromNode.acceptState ? NODE_OUTLINE_RADIUS : 0)) 586 | const toOutsideRadius = fromNode.loc.moveFromAngle(SELF_TRANSITION_END_ANGLE, NODE_RADIUS + TRANSITION_ARROW_RADIUS + (fromNode.acceptState ? NODE_OUTLINE_RADIUS : 0)) 587 | 588 | const transitionLine = new BezierCurvedLine(fromOutsideRadius, toOutsideRadius, cp1, cp2, { 589 | width: TRANSITION_WIDTH, 590 | color: TRANSITION_COLOR, 591 | arrowRadius: TRANSITION_ARROW_RADIUS 592 | }) 593 | 594 | if (!this.isDFA) transitionLine.addEventListener('edit', editFn) 595 | this.draggableCanvas.addObject(transitionLine) 596 | 597 | // Add the text to the midpoint of the transition line with the appropriate rotation angle 598 | const midpointAngle = transitionLine.midpointAngle() 599 | textLocation = transitionLine.midpointPadded(TRANSITION_TEXT_RADIUS) 600 | textRotation = Math.abs(midpointAngle) > (Math.PI / 2) ? midpointAngle + Math.PI : midpointAngle 601 | } 602 | 603 | // Add the transition symbols to the line, joined by commas 604 | const text = new Text(textLocation, { 605 | text: fromNode.transitionText[endState].join(', '), 606 | rotation: textRotation, 607 | color: '#000', 608 | size: 24, 609 | font: 'Roboto' 610 | }) 611 | if (!this.isDFA) text.addEventListener('edit', editFn) 612 | this.draggableCanvas.addObject(text) 613 | } 614 | } 615 | 616 | // Draw node circles 617 | for (const node of this.nodes) { 618 | let color = NODE_COLOR 619 | let outline 620 | 621 | if (this.fsa.startState === node.label) { 622 | // Add incoming arrow to the start state 623 | const from = node.loc.moveToAngle(START_NODE_ARROW_ANGLE, START_NODE_ARROW_LENGTH) 624 | const to = node.loc.moveToAngle(START_NODE_ARROW_ANGLE, NODE_RADIUS + TRANSITION_ARROW_RADIUS + (node.acceptState ? NODE_OUTLINE_RADIUS : 0)) 625 | this.draggableCanvas.addObject(new ArrowedStraightLine(from, to, { 626 | width: TRANSITION_WIDTH, 627 | color: TRANSITION_COLOR, 628 | arrowRadius: TRANSITION_ARROW_RADIUS 629 | })) 630 | } 631 | 632 | if (node.acceptState) { 633 | color = 'green' 634 | 635 | // Add a double outline to the accept state 636 | outline = { color: '#000', width: 2, distance: NODE_OUTLINE_RADIUS } 637 | } 638 | 639 | const circle = new Circle(node.loc, { 640 | radius: NODE_RADIUS, 641 | color: color, 642 | text: new Text(null, { 643 | text: node.label, 644 | size: NODE_LABEL_SIZE, 645 | color: '#fff', 646 | font: 'Helvetica' 647 | }), 648 | borderOptions: { color: '#000', width: 2 }, 649 | outlineOptions: outline 650 | }) 651 | 652 | if (!this.isDFA) { 653 | circle.addEventListener('edit', e => { 654 | // Don't show the edit menu if the user is currently creating a transition 655 | if (this.transitionInProgress) { return } 656 | 657 | const editMenu = new EditNodeMenu(e.clientX, e.clientY) 658 | 659 | editMenu.addEventListener('addtransition', () => { 660 | this.addingTransitionNode = node 661 | }) 662 | 663 | editMenu.addEventListener('selectedstart', () => { 664 | this.setStartState(node.label) 665 | this.render() 666 | }) 667 | 668 | editMenu.addEventListener('toggledaccept', () => { 669 | if (!node.acceptState) { 670 | this.addAcceptState(node.label) 671 | } else { 672 | this.removeAcceptState(node.label) 673 | } 674 | this.render() 675 | }) 676 | 677 | editMenu.addEventListener('delete', () => { 678 | this.removeNode(node.label) 679 | this.render() 680 | }) 681 | }) 682 | } 683 | 684 | circle.addEventListener('move', e => { 685 | node.loc = e.newLocation 686 | this.render() 687 | }) 688 | 689 | this.draggableCanvas.addObject(circle) 690 | } 691 | } 692 | } 693 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import NFAConverter from './fsa/nfa_converter.js' 2 | import DraggableCanvas from './canvas/draggable_canvas.js' 3 | import VisualFSA from './fsa/visual_fsa.js' 4 | import * as utils from './util/util.js' 5 | import AnimatedNFAConverter from './fsa/animated_nfa_converter.js' 6 | import FSADescription from './elements/fsa_description.js' 7 | 8 | utils.keepHeightSynced([['#dfa-title', '#nfa-title']]) 9 | 10 | const nfa = { 11 | visual: new VisualFSA(new DraggableCanvas('#nfa'), false), 12 | desc: new FSADescription('#nfa-delta-transitions') 13 | } 14 | 15 | const dfa = { 16 | visual: new VisualFSA(new DraggableCanvas('#dfa'), true), 17 | desc: new FSADescription('#dfa-delta-transitions') 18 | } 19 | 20 | nfa.visual.addEventListener('change', () => { 21 | if (nfa.visual.fsa.states.length > 0) { 22 | setEditButtonsState(true) 23 | nfa.desc.update(nfa.visual.fsa, true) 24 | } else { 25 | setEditButtonsState(false) 26 | nfa.desc.reset() 27 | } 28 | }) 29 | 30 | dfa.visual.addEventListener('change', () => { 31 | if (dfa.visual.fsa.states.length > 0) { 32 | dfa.desc.update(dfa.visual.fsa, false) 33 | document.querySelector('#step-backward').disabled = false 34 | } else { 35 | dfa.desc.reset() 36 | document.querySelector('#step-backward').disabled = true 37 | } 38 | }) 39 | 40 | /** 41 | * Draw the canvas any time there is a change to its elements 42 | */ 43 | draw() 44 | function draw () { 45 | nfa.visual.draggableCanvas.draw() 46 | dfa.visual.draggableCanvas.draw() 47 | window.requestAnimationFrame(draw) 48 | } 49 | 50 | /** 51 | * Update the edit buttons enabled state 52 | * 53 | * @param {Boolean} enabled True to enable the buttons, false to disable the buttons 54 | * @param {Boolean} onlyDFA True to only affect the DFA buttons 55 | */ 56 | function setEditButtonsState (enabled, onlyDFA) { 57 | if (!onlyDFA) { 58 | document.querySelector('#export').disabled = !enabled 59 | document.querySelector('#nfa-reset').disabled = !enabled 60 | document.querySelector('#dfa-reset').disabled = !enabled 61 | } 62 | 63 | document.querySelectorAll('.conversion-button').forEach(e => { 64 | e.disabled = !enabled 65 | }) 66 | document.querySelector('#dfa-conversion-step').innerHTML = '' 67 | } 68 | 69 | /** 70 | * Ensure the NFA has the appropriate values to begin a conversion to a DFA 71 | * @returns {Boolean} True if the NFA is valid, false if not 72 | */ 73 | function validateNFA () { 74 | if (nfa.visual.fsa.states.length === 0) { 75 | utils.showWarning('You must add states to the NFA before performing the conversion.') 76 | return false 77 | } 78 | 79 | if (!nfa.visual.fsa.startState || nfa.visual.fsa.startState === '') { 80 | utils.showWarning('You must set the start state in the NFA before performing the conversion.') 81 | return false 82 | } 83 | 84 | if (nfa.visual.fsa.alphabet.length === 0) { 85 | utils.showWarning('You must add at least one transition to establish an alphabet.') 86 | return false 87 | } 88 | 89 | return true 90 | } 91 | 92 | let converter 93 | let animatedConverter 94 | 95 | /** 96 | * Advance the NFA conversion one-by-one with the step forward button 97 | */ 98 | document.querySelector('#step-forward').addEventListener('click', () => { 99 | if (!validateNFA()) return 100 | 101 | if (animatedConverter) { 102 | animatedConverter.stop() 103 | animatedConverter = undefined 104 | } 105 | 106 | if (!converter || !converter.nfa.startState) { 107 | converter = new NFAConverter(nfa.visual.fsa.clone()) 108 | } 109 | 110 | try { 111 | const [newDFA, step] = converter.stepForward() 112 | if (newDFA && step) { 113 | console.log(step, newDFA) 114 | dfa.visual.performStep(step, newDFA) 115 | document.querySelector('#dfa-conversion-step').innerHTML = step.desc 116 | } else { 117 | setEditButtonsState(false, true) 118 | } 119 | } catch (e) { 120 | utils.showWarning(e.message) 121 | converter = undefined 122 | dfa.visual.reset() 123 | } 124 | }) 125 | 126 | /** 127 | * Undo the NFA conversion one-by-one with the step backward button 128 | */ 129 | document.querySelector('#step-backward').addEventListener('click', () => { 130 | if (!converter) return 131 | 132 | if (animatedConverter) { 133 | animatedConverter.stop() 134 | animatedConverter = undefined 135 | } 136 | 137 | const [prevDFA, prevStep] = converter.stepBackward() 138 | if (prevDFA && prevStep) { 139 | dfa.visual.undoStep(prevStep, prevDFA) 140 | setEditButtonsState(true, true) 141 | document.querySelector('#dfa-conversion-step').innerHTML = `Undo ${prevStep.desc}` 142 | } 143 | }) 144 | 145 | /** 146 | * Begin an automatic conversion animation with the animate button 147 | */ 148 | document.querySelector('#animate').addEventListener('click', () => { 149 | if (!validateNFA()) return 150 | 151 | if (!animatedConverter) { 152 | if (!converter) { 153 | converter = new NFAConverter(nfa.visual.fsa.clone()) 154 | } 155 | 156 | animatedConverter = new AnimatedNFAConverter(converter, dfa.visual, 750) 157 | 158 | animatedConverter.addEventListener('start', () => { 159 | document.querySelector('#animate').innerHTML = 'Pause' 160 | }) 161 | 162 | animatedConverter.addEventListener('stop', () => { 163 | document.querySelector('#animate').innerHTML = 'Animate' 164 | }) 165 | 166 | animatedConverter.addEventListener('complete', () => { 167 | setEditButtonsState(false, true) 168 | }) 169 | 170 | animatedConverter.play(err => { 171 | utils.showWarning(err.message) 172 | converter = undefined 173 | animatedConverter = undefined 174 | dfa.visual.reset() 175 | }) 176 | } else { 177 | animatedConverter.stop() 178 | animatedConverter = undefined 179 | } 180 | }) 181 | 182 | /** 183 | * Fully complete the conversion with the complete button 184 | */ 185 | document.querySelector('#complete').addEventListener('click', () => { 186 | if (!validateNFA()) return 187 | 188 | if (animatedConverter) { 189 | animatedConverter.stop() 190 | animatedConverter = undefined 191 | } 192 | 193 | if (!converter) { 194 | converter = new NFAConverter(nfa.visual.fsa.clone()) 195 | } 196 | 197 | try { 198 | const changes = converter.complete() 199 | if (changes.length > 0) { 200 | for (const change of changes) { 201 | const [newDFA, step] = change 202 | dfa.visual.performStep(step, newDFA) 203 | } 204 | } 205 | 206 | setEditButtonsState(false, true) 207 | } catch (e) { 208 | utils.showWarning(e.message) 209 | converter = undefined 210 | dfa.visual.reset() 211 | } 212 | }) 213 | 214 | /** 215 | * Clear the DFA with the reset button 216 | */ 217 | document.querySelector('#dfa-reset').addEventListener('click', () => { 218 | setEditButtonsState(true, true) 219 | 220 | if (animatedConverter) { 221 | animatedConverter.stop() 222 | animatedConverter = undefined 223 | } 224 | 225 | dfa.visual.reset() 226 | converter = undefined 227 | }) 228 | 229 | /** 230 | * Clear the NFA with the reset button 231 | */ 232 | document.querySelector('#nfa-reset').addEventListener('click', () => { 233 | nfa.visual.reset() 234 | dfa.visual.reset() 235 | converter = undefined 236 | }) 237 | 238 | /** 239 | * Download the NFA to a file with the export button 240 | */ 241 | document.querySelector('#export').addEventListener('click', () => { 242 | utils.downloadFile('nfa.json', nfa.visual.toJSON()) 243 | }) 244 | 245 | /** 246 | * Upload a saved NFA file with the import button 247 | */ 248 | document.querySelector('#import').addEventListener('click', () => { 249 | utils.selectFile().then(contents => { 250 | try { 251 | nfa.visual.fromJSON(contents) 252 | } catch (e) { 253 | utils.showWarning('The given file is improperly formatted.') 254 | } 255 | }) 256 | }) 257 | 258 | /** 259 | * Show dropdowns when the dropdown trigger is clicked 260 | */ 261 | document.querySelectorAll('.dropdown-trigger button').forEach(e => e.addEventListener('click', e => { 262 | e.stopPropagation() 263 | e.target.parentElement.parentElement.classList.toggle('is-active') 264 | })) 265 | 266 | /** 267 | * Remove all dropdowns when the user clicks elsewhere on the page 268 | */ 269 | window.addEventListener('click', () => { 270 | document.querySelectorAll('.dropdown').forEach(e => e.classList.remove('is-active')) 271 | }) 272 | 273 | /** 274 | * Open the NFA help modal on help button click 275 | */ 276 | document.querySelector('#nfa-help-button').addEventListener('click', () => { 277 | document.querySelector('#nfa-help-modal').classList.add('is-active') 278 | document.querySelectorAll('.modal-card-head').forEach(e => { 279 | e.style.display = 'flex' 280 | }) 281 | utils.playVideo('#nfa-help-video') 282 | }) 283 | 284 | /** 285 | * Open the DFA help modal on help button click 286 | */ 287 | document.querySelector('#dfa-help-button').addEventListener('click', () => { 288 | document.querySelector('#dfa-help-modal').classList.add('is-active') 289 | document.querySelectorAll('.modal-card-head').forEach(e => { 290 | e.style.display = 'flex' 291 | }) 292 | utils.playVideo('#dfa-help-video') 293 | }) 294 | 295 | /** 296 | * Close modals when the background is pressed 297 | */ 298 | document.querySelectorAll('.modal-close-background').forEach(e => e.addEventListener('click', e => { 299 | e.target.parentElement.classList.toggle('is-active') 300 | utils.pauseAllVideos() 301 | })) 302 | 303 | /** 304 | * Close modals when the close button is pressed 305 | */ 306 | document.querySelectorAll('.modal-close-button').forEach(e => e.addEventListener('click', e => { 307 | e.preventDefault() 308 | e.target.parentElement.parentElement.parentElement.classList.toggle('is-active') 309 | utils.pauseAllVideos() 310 | })) 311 | 312 | /** 313 | * Set the NFA to a preset configuration with the preset button 314 | */ 315 | document.querySelector('#preset-1').addEventListener('click', () => { 316 | nfa.visual.fromJSON('{"nodes":[{"label":"1","loc":{"x":200,"y":100},"transitionText":{"2":["b"],"3":["ε"]},"acceptState":true},{"label":"2","loc":{"x":600,"y":100},"transitionText":{"2":["a"],"3":["a","b"]}},{"label":"3","loc":{"x":400,"y":400},"transitionText":{"1":["a"]}}],"fsa":{"states":["1","2","3"],"alphabet":["a","b"],"transitions":{"1":{"b":["2"],"ε":["3"]},"2":{"a":["2","3"],"b":["3"]},"3":{"a":["1"]}},"startState":"1","acceptStates":["1"]}}') 317 | }) 318 | 319 | /** 320 | * Set the NFA to a preset configuration with the preset button 321 | */ 322 | document.querySelector('#preset-2').addEventListener('click', () => { 323 | nfa.visual.fromJSON('{"nodes":[{"label":"1","loc":{"x":154,"y":108},"transitionText":{"2":["ε"],"3":["a"]}},{"label":"2","loc":{"x":535,"y":106},"transitionText":{},"acceptState":true},{"label":"3","loc":{"x":334,"y":362},"transitionText":{"2":["a","b"]}}],"fsa":{"states":["1","2","3"],"alphabet":["a","b"],"transitions":{"1":{"ε":["2"],"a":["3"]},"3":{"a":["2"],"b":["2"]}},"startState":"1","acceptStates":["2"]}}') 324 | }) 325 | 326 | /** 327 | * Set the NFA to a preset configuration with the preset button 328 | */ 329 | document.querySelector('#preset-3').addEventListener('click', () => { 330 | nfa.visual.fromJSON('{"nodes":[{"label":"1","loc":{"x":206,"y":119},"transitionText":{"2":["b"],"3":["ε"]}},{"label":"2","loc":{"x":560,"y":119},"transitionText":{"1":["a"],"2":["b"]},"acceptState":true},{"label":"3","loc":{"x":375,"y":388},"transitionText":{"2":["a"],"3":["a","b"]}}],"fsa":{"states":["1","2","3"],"alphabet":["a","b"],"transitions":{"1":{"ε":["3"],"b":["2"]},"2":{"b":["2"],"a":["1"]},"3":{"a":["2","3"],"b":["3"]}},"startState":"1","acceptStates":["2"]}}') 331 | }) 332 | -------------------------------------------------------------------------------- /src/js/test/fsa.test.js: -------------------------------------------------------------------------------- 1 | import should from 'should' // eslint-disable-line no-unused-vars 2 | import FSA from '../fsa/fsa.js' 3 | import { UnknownStateError, UnknownSymbolError } from '../util/errors.js' 4 | 5 | describe('FSA 1', () => { 6 | const fsa = new FSA(['1', '2', '3'], ['a', 'b'], { 7 | '1': { 8 | 'a': undefined, 9 | 'b': ['2'], 10 | 'ε': ['3'] 11 | }, 12 | '2': { 13 | 'a': ['2', '3'], 14 | 'b': ['3'], 15 | 'ε': undefined 16 | }, 17 | '3': { 18 | 'a': ['1'], 19 | 'b': undefined, 20 | 'ε': ['2'] 21 | } 22 | }, '1', ['1']) 23 | 24 | it('should get the power set of states', done => { 25 | const powerset = fsa.getPowersetOfStates() 26 | powerset.length.should.eql(1 << fsa.states.length) 27 | powerset.should.eql([['Ø'], ['1'], ['2'], ['1', '2'], ['3'], ['1', '3'], ['2', '3'], ['1', '2', '3']]) 28 | done() 29 | }) 30 | 31 | it('should get epsilon closure states', done => { 32 | fsa.getEpsilonClosureStates('1').should.eql(['1', '2', '3']) 33 | fsa.getEpsilonClosureStates('2').should.eql(['2']) 34 | fsa.getEpsilonClosureStates('3').should.eql(['2', '3']) 35 | done() 36 | }) 37 | 38 | it('should get reachable states', done => { 39 | fsa.getReachableStates('1', 'a').should.eql(['Ø']) 40 | fsa.getReachableStates('1', 'b').should.eql(['2']) 41 | fsa.getReachableStates('2', 'a').should.eql(['2', '3']) 42 | fsa.getReachableStates('2', 'b').should.eql(['2', '3']) 43 | fsa.getReachableStates('3', 'a').should.eql(['1', '2', '3']) 44 | fsa.getReachableStates('3', 'b').should.eql(['Ø']) 45 | done() 46 | }) 47 | 48 | it('should delete state', done => { 49 | fsa.removeState('1') 50 | 51 | fsa.states.should.eql(['2', '3']) 52 | should.not.exist(fsa.startState) 53 | fsa.acceptStates.length.should.eql(0) 54 | fsa.getReachableStates('3', 'a').should.eql(['Ø']) 55 | done() 56 | }) 57 | 58 | it('should not get epsilon closure states from an invalid state', done => { 59 | should(() => { fsa.getEpsilonClosureStates('4') }).throw(new UnknownStateError('4')) 60 | done() 61 | }) 62 | 63 | it('should not get reachable states from an invalid state', done => { 64 | should(() => { fsa.getReachableStates('4', 'a') }).throw(new UnknownStateError('4')) 65 | should(() => { fsa.getReachableStates('2', 'z') }).throw(new UnknownSymbolError('z')) 66 | done() 67 | }) 68 | }) 69 | 70 | describe('FSA 2', () => { 71 | const fsa = new FSA(['q1', 'q2', 'q3'], ['0', '1'], { 72 | 'q1': { 73 | '0': ['q3'], 74 | '1': ['q2', 'q3'], 75 | 'ε': undefined 76 | }, 77 | 'q2': { 78 | '0': ['q2'], 79 | '1': ['q2'], 80 | 'ε': ['q3'] 81 | }, 82 | 'q3': { 83 | '0': ['q2'], 84 | '1': ['q1', 'q2'], 85 | 'ε': undefined 86 | } 87 | }, 'q1', ['q1', 'q3']) 88 | 89 | it('should get the power set of states', done => { 90 | const powerset = fsa.getPowersetOfStates() 91 | powerset.length.should.eql(1 << fsa.states.length) 92 | powerset.should.eql([['Ø'], ['q1'], ['q2'], ['q1', 'q2'], ['q3'], ['q1', 'q3'], ['q2', 'q3'], ['q1', 'q2', 'q3']]) 93 | done() 94 | }) 95 | 96 | it('should get epsilon closure states', done => { 97 | fsa.getEpsilonClosureStates('q1').should.eql(['q1']) 98 | fsa.getEpsilonClosureStates('q2').should.eql(['q2', 'q3']) 99 | fsa.getEpsilonClosureStates('q3').should.eql(['q3']) 100 | done() 101 | }) 102 | 103 | it('should get reachable states', done => { 104 | fsa.getReachableStates('q1', '0').should.eql(['q3']) 105 | fsa.getReachableStates('q1', '1').should.eql(['q2', 'q3']) 106 | fsa.getReachableStates('q2', '0').should.eql(['q2', 'q3']) 107 | fsa.getReachableStates('q2', '1').should.eql(['q2', 'q3']) 108 | fsa.getReachableStates('q3', '0').should.eql(['q2', 'q3']) 109 | fsa.getReachableStates('q3', '1').should.eql(['q1', 'q2', 'q3']) 110 | done() 111 | }) 112 | 113 | it('should delete state', done => { 114 | fsa.removeState('q1') 115 | 116 | fsa.states.should.eql(['q2', 'q3']) 117 | should.not.exist(fsa.startState) 118 | fsa.acceptStates.should.eql(['q3']) 119 | fsa.getReachableStates('q3', '1').should.eql(['q2', 'q3']) 120 | done() 121 | }) 122 | 123 | it('should not get epsilon closure states from an invalid state', done => { 124 | should(() => { fsa.getEpsilonClosureStates('q4') }).throw(new UnknownStateError('q4')) 125 | done() 126 | }) 127 | 128 | it('should not get reachable states from an invalid state', done => { 129 | should(() => { fsa.getReachableStates('q4', '0') }).throw(new UnknownStateError('q4')) 130 | should(() => { fsa.getReachableStates('q2', '2') }).throw(new UnknownSymbolError('2')) 131 | done() 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /src/js/test/nfa_converter.test.js: -------------------------------------------------------------------------------- 1 | import should from 'should' // eslint-disable-line no-unused-vars 2 | import FSA from '../fsa/fsa.js' 3 | import NFAConverter from '../fsa/nfa_converter.js' 4 | 5 | describe('NFA Conversion 1', () => { 6 | const nfa = new FSA(['1', '2', '3'], ['a', 'b'], { 7 | '1': { 8 | 'a': undefined, 9 | 'b': ['2'], 10 | 'ε': ['3'] 11 | }, 12 | '2': { 13 | 'a': ['2', '3'], 14 | 'b': ['3'], 15 | 'ε': undefined 16 | }, 17 | '3': { 18 | 'a': ['1'], 19 | 'b': undefined, 20 | 'ε': undefined 21 | } 22 | }, '1', ['1']) 23 | 24 | const conversion = new NFAConverter(nfa) 25 | 26 | it('should generate the initial DFA', done => { 27 | const [dfa] = conversion.stepForward() 28 | 29 | dfa.states.should.eql(['Ø', '1', '2', '1,2', '3', '1,3', '2,3', '1,2,3']) 30 | dfa.alphabet.should.eql(['a', 'b']) 31 | dfa.startState.should.eql('1,3') 32 | dfa.acceptStates.should.eql(['1', '1,2', '1,3', '1,2,3']) 33 | dfa.transitions.should.eql({ 34 | '1': {}, 35 | '1,2': {}, 36 | '1,2,3': {}, 37 | '1,3': {}, 38 | '2': {}, 39 | '2,3': {}, 40 | '3': {}, 41 | 'Ø': {} 42 | }) 43 | 44 | done() 45 | }) 46 | 47 | it('should generate transitions', done => { 48 | const dfa = conversion.step(16) 49 | 50 | dfa.transitions.should.eql({ 51 | '1': { a: ['Ø'], b: ['2'] }, 52 | '1,2': { a: ['2,3'], b: ['2,3'] }, 53 | '1,2,3': { a: ['1,2,3'], b: ['2,3'] }, 54 | '1,3': { a: ['1,3'], b: ['2'] }, 55 | '2': { a: ['2,3'], b: ['3'] }, 56 | '2,3': { a: ['1,2,3'], b: ['3'] }, 57 | '3': { a: ['1,3'], b: ['Ø'] }, 58 | 'Ø': { a: ['Ø'], b: ['Ø'] } 59 | }) 60 | 61 | done() 62 | }) 63 | 64 | it('should delete unreachable states', done => { 65 | const dfa = conversion.step(2) 66 | 67 | dfa.states.should.eql(['Ø', '2', '3', '1,3', '2,3', '1,2,3']) 68 | dfa.alphabet.should.eql(['a', 'b']) 69 | dfa.startState.should.eql('1,3') 70 | dfa.acceptStates.should.eql(['1,3', '1,2,3']) 71 | dfa.transitions.should.eql({ 72 | '1,2,3': { a: ['1,2,3'], b: ['2,3'] }, 73 | '1,3': { a: ['1,3'], b: ['2'] }, 74 | '2': { a: ['2,3'], b: ['3'] }, 75 | '2,3': { a: ['1,2,3'], b: ['3'] }, 76 | '3': { a: ['1,3'], b: ['Ø'] }, 77 | 'Ø': { a: ['Ø'], b: ['Ø'] } 78 | }) 79 | 80 | done() 81 | }) 82 | }) 83 | 84 | describe('NFA Conversion 2', () => { 85 | const nfa = new FSA(['q1', 'q2', 'q3'], ['0', '1'], { 86 | 'q1': { 87 | '0': ['q3'], 88 | '1': ['q2', 'q3'], 89 | 'ε': undefined 90 | }, 91 | 'q2': { 92 | '0': ['q2'], 93 | '1': ['q2'], 94 | 'ε': ['q3'] 95 | }, 96 | 'q3': { 97 | '0': ['q2'], 98 | '1': ['q1', 'q2'], 99 | 'ε': undefined 100 | } 101 | }, 'q1', ['q1', 'q3']) 102 | 103 | const conversion = new NFAConverter(nfa) 104 | 105 | it('should generate the initial DFA', done => { 106 | const [dfa] = conversion.stepForward() 107 | 108 | dfa.states.should.eql(['Ø', 'q1', 'q2', 'q1,q2', 'q3', 'q1,q3', 'q2,q3', 'q1,q2,q3']) 109 | dfa.alphabet.should.eql(['0', '1']) 110 | dfa.startState.should.eql('q1') 111 | dfa.acceptStates.should.eql(['q1', 'q1,q2', 'q3', 'q1,q3', 'q2,q3', 'q1,q2,q3']) 112 | dfa.transitions.should.eql({ 113 | 'q1': {}, 114 | 'q1,q2': {}, 115 | 'q1,q2,q3': {}, 116 | 'q1,q3': {}, 117 | 'q2': {}, 118 | 'q2,q3': {}, 119 | 'q3': {}, 120 | 'Ø': {} 121 | }) 122 | 123 | done() 124 | }) 125 | 126 | it('should generate transitions', done => { 127 | const dfa = conversion.step(16) 128 | 129 | dfa.transitions.should.eql({ 130 | 'q1': { 0: ['q3'], 1: ['q2,q3'] }, 131 | 'q1,q2': { 0: ['q2,q3'], 1: ['q2,q3'] }, 132 | 'q1,q2,q3': { 0: ['q2,q3'], 1: ['q1,q2,q3'] }, 133 | 'q1,q3': { 0: ['q2,q3'], 1: ['q1,q2,q3'] }, 134 | 'q2': { 0: ['q2,q3'], 1: ['q2,q3'] }, 135 | 'q2,q3': { 0: ['q2,q3'], 1: ['q1,q2,q3'] }, 136 | 'q3': { 0: ['q2,q3'], 1: ['q1,q2,q3'] }, 137 | 'Ø': { 0: ['Ø'], 1: ['Ø'] } 138 | }) 139 | 140 | done() 141 | }) 142 | 143 | it('should delete unreachable states', done => { 144 | const dfa = conversion.step(4) 145 | 146 | dfa.states.should.eql(['q1', 'q3', 'q2,q3', 'q1,q2,q3']) 147 | dfa.alphabet.should.eql(['0', '1']) 148 | dfa.startState.should.eql('q1') 149 | dfa.acceptStates.should.eql(['q1', 'q3', 'q2,q3', 'q1,q2,q3']) 150 | dfa.transitions.should.eql({ 151 | 'q1': { 0: ['q3'], 1: ['q2,q3'] }, 152 | 'q1,q2,q3': { 0: ['q2,q3'], 1: ['q1,q2,q3'] }, 153 | 'q2,q3': { 0: ['q2,q3'], 1: ['q1,q2,q3'] }, 154 | 'q3': { 0: ['q2,q3'], 1: ['q1,q2,q3'] } 155 | }) 156 | 157 | done() 158 | }) 159 | }) 160 | 161 | describe('NFA Conversion 3', () => { 162 | const nfa = new FSA(['1', '2', '3'], ['a', 'b'], { 163 | '1': { 164 | 'a': undefined, 165 | 'b': ['2'], 166 | 'ε': ['3'] 167 | }, 168 | '2': { 169 | 'a': ['1'], 170 | 'b': ['2'], 171 | 'ε': undefined 172 | }, 173 | '3': { 174 | 'a': ['2', '3'], 175 | 'b': ['3'], 176 | 'ε': undefined 177 | } 178 | }, '1', ['2']) 179 | 180 | const conversion = new NFAConverter(nfa) 181 | 182 | it('should generate the initial DFA', done => { 183 | const [dfa] = conversion.stepForward() 184 | 185 | dfa.states.should.eql(['Ø', '1', '2', '1,2', '3', '1,3', '2,3', '1,2,3']) 186 | dfa.alphabet.should.eql(['a', 'b']) 187 | dfa.startState.should.eql('1,3') 188 | dfa.acceptStates.should.eql(['2', '1,2', '2,3', '1,2,3']) 189 | dfa.transitions.should.eql({ 190 | '1': {}, 191 | '1,2': {}, 192 | '1,2,3': {}, 193 | '1,3': {}, 194 | '2': {}, 195 | '2,3': {}, 196 | '3': {}, 197 | 'Ø': {} 198 | }) 199 | 200 | done() 201 | }) 202 | 203 | it('should generate transitions', done => { 204 | const dfa = conversion.step(16) 205 | 206 | dfa.transitions.should.eql({ 207 | '1': { a: ['Ø'], b: ['2'] }, 208 | '1,2': { a: ['1,3'], b: ['2'] }, 209 | '1,2,3': { a: ['1,2,3'], b: ['2,3'] }, 210 | '1,3': { a: ['2,3'], b: ['2,3'] }, 211 | '2': { a: ['1,3'], b: ['2'] }, 212 | '2,3': { a: ['1,2,3'], b: ['2,3'] }, 213 | '3': { a: ['2,3'], b: ['3'] }, 214 | 'Ø': { a: ['Ø'], b: ['Ø'] } 215 | }) 216 | 217 | done() 218 | }) 219 | 220 | it('should delete unreachable states', done => { 221 | const dfa = conversion.step(5) 222 | 223 | dfa.states.should.eql(['1,3', '2,3', '1,2,3']) 224 | dfa.alphabet.should.eql(['a', 'b']) 225 | dfa.startState.should.eql('1,3') 226 | dfa.acceptStates.should.eql(['2,3', '1,2,3']) 227 | dfa.transitions.should.eql({ 228 | '1,2,3': { a: ['1,2,3'], b: ['2,3'] }, 229 | '1,3': { a: ['2,3'], b: ['2,3'] }, 230 | '2,3': { a: ['1,2,3'], b: ['2,3'] } 231 | }) 232 | 233 | done() 234 | }) 235 | 236 | it('should delete redundant states', done => { 237 | const dfa = conversion.step(1) 238 | 239 | dfa.states.should.eql(['1,3', '2,3+1,2,3']) 240 | dfa.alphabet.should.eql(['a', 'b']) 241 | dfa.startState.should.eql('1,3') 242 | dfa.acceptStates.should.eql(['2,3+1,2,3']) 243 | dfa.transitions.should.eql({ 244 | '1,3': { a: ['2,3+1,2,3'], b: ['2,3+1,2,3'] }, 245 | '2,3+1,2,3': { a: ['2,3+1,2,3'], b: ['2,3+1,2,3'] } 246 | }) 247 | 248 | done() 249 | }) 250 | }) 251 | -------------------------------------------------------------------------------- /src/js/test/visual_fsa.test.js: -------------------------------------------------------------------------------- 1 | import should from 'should' // eslint-disable-line no-unused-vars 2 | import jsdom from 'jsdom' 3 | 4 | import { UnknownStateError } from '../util/errors.js' 5 | import VisualFSA from '../fsa/visual_fsa.js' 6 | import NFAConverter from '../fsa/nfa_converter.js' 7 | import DraggableCanvas from '../canvas/draggable_canvas.js' 8 | import Location from '../canvas/location.js' 9 | import Circle from '../canvas/drawables/circle.js' 10 | import QuadraticCurvedLine from '../canvas/drawables/quadratic_curved_line.js' 11 | import BezierCurvedLine from '../canvas/drawables/bezier_curved_line.js' 12 | import Text from '../canvas/drawables/text.js' 13 | import ArrowedStraightLine from '../canvas/drawables/arrowed_straight_line.js' 14 | import FSA from '../fsa/fsa.js' 15 | const { JSDOM } = jsdom 16 | 17 | function getVisualNFA () { 18 | const visualNFA = new VisualFSA(new DraggableCanvas('#nfa'), false) 19 | 20 | visualNFA.addNode('1', new Location(100, 100)) 21 | visualNFA.addNode('2', new Location(200, 100)) 22 | visualNFA.setStartState('1') 23 | visualNFA.addAcceptState('2') 24 | visualNFA.addTransition('1', '2', 'a') 25 | visualNFA.addTransition('1', '2', 'ε') 26 | visualNFA.addTransition('2', '1', 'b') 27 | visualNFA.addTransition('2', '2', 'b') 28 | 29 | return visualNFA 30 | } 31 | 32 | describe('Visual FSA', () => { 33 | before(done => { 34 | JSDOM.fromFile('./index.html').then(dom => { 35 | global.window = dom.window 36 | global.document = dom.window.document 37 | global.window.HTMLCanvasElement.prototype.getContext = () => { 38 | return { 39 | setTransform: () => { }, 40 | translate: () => { } 41 | } 42 | } 43 | done() 44 | }).catch(err => console.error(err)) 45 | }) 46 | 47 | it('should import a saved json', done => { 48 | const visualNFA = new VisualFSA(new DraggableCanvas('#nfa'), false) 49 | 50 | visualNFA.fromJSON('{"nodes":[{"label":"1","loc":{"x":206,"y":119},"transitionText":{"2":["b"],"3":["ε"]}},{"label":"2","loc":{"x":560,"y":119},"transitionText":{"1":["a"],"2":["b"]},"acceptState":true},{"label":"3","loc":{"x":375,"y":388},"transitionText":{"2":["a"],"3":["a","b"]}}],"fsa":{"states":["1","2","3"],"alphabet":["a","b"],"transitions":{"1":{"ε":["3"],"b":["2"]},"2":{"b":["2"],"a":["1"]},"3":{"a":["2","3"],"b":["3"]}},"startState":"1","acceptStates":["2"]}}') 51 | 52 | visualNFA.fsa.should.be.instanceOf(FSA) 53 | 54 | visualNFA.fsa.states.should.eql(['1', '2', '3']) 55 | visualNFA.fsa.acceptStates.should.eql(['2']) 56 | visualNFA.fsa.startState.should.eql('1') 57 | visualNFA.fsa.alphabet.should.eql(['a', 'b']) 58 | visualNFA.fsa.transitions.should.eql({ 59 | '1': { b: ['2'], ε: ['3'] }, 60 | '2': { a: ['1'], b: ['2'] }, 61 | '3': { a: ['2', '3'], b: ['3'] } 62 | }) 63 | 64 | visualNFA.nodes.find(n => n.label === '1').loc.should.eql(new Location(206, 119)) 65 | visualNFA.nodes.find(n => n.label === '2').loc.should.eql(new Location(560, 119)) 66 | visualNFA.nodes.find(n => n.label === '3').loc.should.eql(new Location(375, 388)) 67 | 68 | done() 69 | }) 70 | 71 | it('should create a valid FSA', done => { 72 | const visualNFA = getVisualNFA() 73 | 74 | visualNFA.fsa.states.should.eql(['1', '2']) 75 | visualNFA.fsa.acceptStates.should.eql(['2']) 76 | visualNFA.fsa.startState.should.eql('1') 77 | visualNFA.fsa.alphabet.should.eql(['a', 'b']) 78 | visualNFA.fsa.transitions.should.eql({ 79 | '1': { a: ['2'], ε: ['2'] }, 80 | '2': { b: ['1', '2'] } 81 | }) 82 | 83 | done() 84 | }) 85 | 86 | it('should add objects to the canvas', done => { 87 | const visualNFA = getVisualNFA() 88 | visualNFA.render() 89 | let i = 0 90 | 91 | visualNFA.draggableCanvas.objects.length.should.be.above(0) 92 | visualNFA.draggableCanvas.objects[i++].should.be.instanceOf(QuadraticCurvedLine) 93 | visualNFA.draggableCanvas.objects[i++].should.be.instanceOf(Text) 94 | visualNFA.draggableCanvas.objects[i++].should.be.instanceOf(QuadraticCurvedLine) 95 | visualNFA.draggableCanvas.objects[i++].should.be.instanceOf(Text) 96 | visualNFA.draggableCanvas.objects[i++].should.be.instanceOf(BezierCurvedLine) 97 | visualNFA.draggableCanvas.objects[i++].should.be.instanceOf(Text) 98 | visualNFA.draggableCanvas.objects[i++].should.be.instanceOf(ArrowedStraightLine) 99 | visualNFA.draggableCanvas.objects[i++].should.be.instanceOf(Circle) 100 | visualNFA.draggableCanvas.objects[i].options.color.should.eql('green') 101 | visualNFA.draggableCanvas.objects[i].options.should.have.property('outlineOptions') 102 | visualNFA.draggableCanvas.objects[i++].should.be.instanceOf(Circle) 103 | 104 | done() 105 | }) 106 | 107 | it('should remove transition and update alphabet', done => { 108 | const visualNFA = getVisualNFA() 109 | 110 | visualNFA.removeTransitions('1', '2') 111 | visualNFA.fsa.alphabet.should.eql(['b']) 112 | visualNFA.fsa.transitions.should.eql({ 113 | '1': {}, 114 | '2': { b: ['1', '2'] } 115 | }) 116 | 117 | done() 118 | }) 119 | 120 | it('should not allow modifying a node that does not exist', done => { 121 | const visualNFA = getVisualNFA() 122 | should(() => { visualNFA.setStartState('3') }).throw(new UnknownStateError('3')) 123 | should(() => { visualNFA.addAcceptState('3') }).throw(new UnknownStateError('3')) 124 | should(() => { visualNFA.removeAcceptState('3') }).throw(new UnknownStateError('3')) 125 | should(() => { visualNFA.removeNode('3') }).throw(new UnknownStateError('3')) 126 | should(() => { visualNFA.addTransition('1', '3', 'a') }).throw(new UnknownStateError('3')) 127 | 128 | done() 129 | }) 130 | 131 | it('should initialize a DFA after the first conversion step', done => { 132 | const visualNFA = getVisualNFA() 133 | const visualDFA = new VisualFSA(new DraggableCanvas('#dfa'), true) 134 | const converter = new NFAConverter(visualNFA.fsa) 135 | 136 | const [newDFA, step] = converter.stepForward() 137 | visualDFA.performStep(step, newDFA) 138 | 139 | visualDFA.fsa.states.should.eql(['Ø', '1', '2', '1,2']) 140 | visualDFA.fsa.startState.should.eql('1,2') 141 | visualDFA.fsa.acceptStates.should.eql(['2', '1,2']) 142 | 143 | done() 144 | }) 145 | 146 | it('should create a valid DFA after completing the conversion', done => { 147 | const visualNFA = getVisualNFA() 148 | const visualDFA = new VisualFSA(new DraggableCanvas('#dfa'), true) 149 | const converter = new NFAConverter(visualNFA.fsa) 150 | 151 | const changes = converter.complete() 152 | for (const change of changes) { 153 | const [newDFA, step] = change 154 | visualDFA.performStep(step, newDFA) 155 | } 156 | 157 | visualDFA.fsa.states.should.eql(['Ø', '2', '1,2']) 158 | visualDFA.fsa.startState.should.eql('1,2') 159 | visualDFA.fsa.acceptStates.should.eql(['2', '1,2']) 160 | visualDFA.fsa.transitions.should.eql({ 161 | 'Ø': { a: ['Ø'], b: ['Ø'] }, 162 | '2': { a: ['Ø'], b: ['1,2'] }, 163 | '1,2': { a: ['2'], b: ['1,2'] } 164 | }) 165 | 166 | done() 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /src/js/util/array.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove duplicate elements from the array and sort it 3 | * 4 | * @param {Array} arr The array to remove duplicate elements from 5 | * @returns {Array} The deduplicated array 6 | */ 7 | export function removeDuplicates (arr) { 8 | return [...new Set(arr)].sort() 9 | } 10 | -------------------------------------------------------------------------------- /src/js/util/errors.js: -------------------------------------------------------------------------------- 1 | export class UnknownStateError extends Error { 2 | constructor (state) { 3 | super() 4 | this.name = 'UnknownState' 5 | this.message = `state ${state} does not exist` 6 | } 7 | } 8 | 9 | export class UnknownSymbolError extends Error { 10 | constructor (symbol) { 11 | super() 12 | this.name = 'UnknownSymbol' 13 | this.message = `symbol ${symbol} does not exist` 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/js/util/event_handler.js: -------------------------------------------------------------------------------- 1 | export default class EventHandler { 2 | /** 3 | * EventHandler provides the ability to listen and dispatch events from a class 4 | */ 5 | constructor () { 6 | this.eventListeners = [] 7 | } 8 | 9 | /** 10 | * Update an event if it exists, otherwise register a new one 11 | * 12 | * @param {String} name The name of the event 13 | * @param {Function} fn The function to execute upon the event occurring 14 | */ 15 | updateEventListener (name, fn) { 16 | const index = this.eventListeners.findIndex(e => e.name === name) 17 | if (index > -1) { this.eventListeners.splice(index, 1) } 18 | this.addEventListener(name, fn) 19 | } 20 | 21 | /** 22 | * Register a new event 23 | * 24 | * @param {String} name The name of the event 25 | * @param {Function} fn The function to execute upon the event occurring 26 | */ 27 | addEventListener (name, fn) { 28 | this.eventListeners.push({ name: name, fn: fn }) 29 | } 30 | 31 | /** 32 | * Dispatch an event to all its listeners 33 | * 34 | * @param {String} name The name of the event 35 | * @param {Object} event The parameters to pass to the function 36 | */ 37 | dispatchEvent (name, event) { 38 | if (this.eventListeners) { this.eventListeners.filter(e => e.name === name).forEach(e => e.fn(event)) } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/js/util/util.js: -------------------------------------------------------------------------------- 1 | let warningTimeout 2 | 3 | // Close a warning when the close button is clicked 4 | document.querySelectorAll('.delete').forEach(e => e.addEventListener('click', e => { 5 | e.target.parentElement.style.display = 'none' 6 | })) 7 | 8 | function syncHeight (selector1, selector2) { 9 | document.querySelector(selector1).style.height = `${document.querySelector(selector2).clientHeight}px` 10 | } 11 | 12 | /** 13 | * Synchronize the height of element pairs upon window resizing 14 | * 15 | * @param {Array} listOfPairs The list of element pairs to keep synced 16 | */ 17 | export function keepHeightSynced (listOfPairs) { 18 | for (const pair of listOfPairs) { 19 | syncHeight(pair[0], pair[1]) 20 | } 21 | 22 | window.addEventListener('resize', () => { 23 | for (const pair of listOfPairs) { 24 | syncHeight(pair[0], pair[1]) 25 | } 26 | }) 27 | } 28 | 29 | /** 30 | * Display the given warning element with a message 31 | * 32 | * @param {String} message The message to put into the warning 33 | */ 34 | export function showWarning (message) { 35 | document.querySelector('#warning').style.display = 'block' 36 | document.querySelector('#warning').querySelector('.notification-body').innerHTML = message 37 | 38 | // Delete the warning after a delay 39 | if (warningTimeout) { clearTimeout(warningTimeout) } 40 | warningTimeout = setTimeout(() => { 41 | document.querySelector('#warning').style.display = 'none' 42 | warningTimeout = undefined 43 | }, 4000) 44 | } 45 | 46 | /** 47 | * Download a file onto the user's computer 48 | * 49 | * @param {String} filename The name of the file to create 50 | * @param {String} content The string contents of the file 51 | */ 52 | export function downloadFile (filename, content) { 53 | const dataString = 'data:text/json;charset=utf-8,' + encodeURIComponent(content) 54 | const downloadNode = document.createElement('a') 55 | downloadNode.setAttribute('href', dataString) 56 | downloadNode.setAttribute('download', filename) 57 | document.body.appendChild(downloadNode) 58 | downloadNode.click() 59 | downloadNode.remove() 60 | } 61 | 62 | /** 63 | * Prompt the user to select a file and return a Promise with the contents 64 | * 65 | * @returns {Promise} The contents of the file 66 | */ 67 | export function selectFile () { 68 | return new Promise(resolve => { 69 | const input = document.createElement('input') 70 | input.type = 'file' 71 | 72 | input.onchange = e => { 73 | const file = e.target.files[0] 74 | const reader = new FileReader() 75 | reader.readAsText(file, 'UTF-8') 76 | 77 | reader.onload = readerEvent => { 78 | const content = readerEvent.target.result 79 | resolve(content) 80 | } 81 | } 82 | 83 | input.click() 84 | }) 85 | } 86 | 87 | export function playVideo (selector) { 88 | const video = document.querySelector(selector) 89 | video.currentTime = 0 90 | video.play() 91 | } 92 | 93 | export function pauseAllVideos () { 94 | document.querySelectorAll('video').forEach(e => { 95 | if (!e.paused) e.pause() 96 | }) 97 | } 98 | --------------------------------------------------------------------------------