├── .gitignore ├── images ├── favicon.ico └── screenshot.png ├── examples └── Hello world.puz ├── wordlists ├── peter-broda-wordlist__scored.txt └── peter-broda-wordlist__unscored.txt ├── xw_worker.js ├── patterns.js ├── README.md ├── input.js ├── LICENSE.txt ├── wordlist.js ├── index.html ├── stats.js ├── fill.js ├── style.css ├── files.js └── cross.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .jshintrc 3 | -------------------------------------------------------------------------------- /images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmviz/Phil/HEAD/images/favicon.ico -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmviz/Phil/HEAD/images/screenshot.png -------------------------------------------------------------------------------- /examples/Hello world.puz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmviz/Phil/HEAD/examples/Hello world.puz -------------------------------------------------------------------------------- /wordlists/peter-broda-wordlist__scored.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmviz/Phil/HEAD/wordlists/peter-broda-wordlist__scored.txt -------------------------------------------------------------------------------- /wordlists/peter-broda-wordlist__unscored.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmviz/Phil/HEAD/wordlists/peter-broda-wordlist__unscored.txt -------------------------------------------------------------------------------- /xw_worker.js: -------------------------------------------------------------------------------- 1 | module = {}; 2 | importScripts('fill.js'); 3 | onmessage = function(e) { 4 | let cmd = e.data; 5 | switch (cmd[0]) { 6 | case 'run': 7 | let words = cmd[1].split(/\n/); 8 | let grid = cmd[2]; 9 | grid = grid.replace(/\./g, "#"); 10 | grid = grid.replace(/ /g, "."); 11 | 12 | console.log("fill:\n" + grid); 13 | let wordlist = new module.exports.wordlist(words); 14 | let filler = new module.exports.filler(grid, wordlist); 15 | let result = filler.fill(); 16 | console.log("result:\n" + result); 17 | if (result.indexOf(".") == -1) { 18 | result = result.replace(/\./g, " "); 19 | result = result.replace(/#/g, "."); 20 | postMessage(["sat", result + "\n"]); 21 | } else { 22 | postMessage(["unsat"]); 23 | } 24 | break; 25 | case 'cancel': 26 | postMessage(["ack_cancel"]); 27 | break; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /patterns.js: -------------------------------------------------------------------------------- 1 | // Phil 2 | // ------------------------------------------------------------------------ 3 | // Copyright 2017 Keiran King 4 | 5 | // Licensed under the Apache License, Version 2.0 (the "License"); 6 | // you may not use this file except in compliance with the License. 7 | // (https://www.apache.org/licenses/LICENSE-2.0) 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // ------------------------------------------------------------------------ 15 | 16 | const patterns = [ 17 | [ 18 | [0,4], [1,4], [2,4], [12,4], [13,4], [14,4], 19 | [4,0], [4,1], [4,2], [4,12], [4,13], [4,14], 20 | [8,3], [7,4], [6,5], [5,6], [4,7], [3,8] 21 | ], 22 | [ 23 | [0,4], [1,4], [2,4], [12,4], [13,4], [14,4], 24 | [6,0], [10,0], [6,1], [10,1], [10,2], [8,4], 25 | [5,3], [9,3], [4,5], [11,5], [6,6], [7,7] 26 | ], 27 | [ 28 | [0,5], [1,5], [2,5], [12,4], [13,4], [14,4], 29 | [5,0], [5,1], [5,2], [4,3], [3,13], [3,14], 30 | [5,6], [4,7], [4,8], [6,9], [7,10], [5,11] 31 | ] 32 | ]; 33 | console.log("Loaded", patterns.length, "patterns."); 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phil, a crossword maker 2 | 3 | 4 | 5 | Phil helps you make crosswords, using client-side JavaScript. 6 | * Import & export .xw ([JSON](https://www.xwordinfo.com/JSON/)) or .puz files. 7 | * Use the built-in dictionary, or any text file you want. 8 | * Print to PDF. 9 | * Create a New York Times submission in seconds. 10 | 11 | New to this fork: 12 | * [Bob Copeland's](https://github.com/bcopeland) JS auto filler 13 | * Custom grid sizes 14 | * NYT Sunday grid (21×21) PDF submissions 15 | * Strict matching mode (matches filtered by crossers' constraints) 16 | * Interactive stats 17 | * Dark mode 18 | * Mobile support 19 | * Rebus 20 | * Circles and shades 21 | * Undo/redo 22 | * Match letter histograms 23 | 24 | TODO: 25 | * copy current word into Onelook/xwordinfo pattern 26 | * allow circles and shades in same puzzle or even square 27 | * grid generation CSP: width, height, number of words, mean word length, block %, open %, sym, at least n words of length m 28 | * larger wordlist, grid bank 29 | * Scored word list 30 | * Show word scores in matches list, sort alphabetically or by score 31 | * Character classes in squares, with vowel and consonant shorthands 32 | * Option move/delete 33 | * Add to and remove from wordlist 34 | * Save edited wordlist 35 | * Autofill region, ui integration, scored word lists, choose among fills 36 | * Better responsive layout 37 | 38 | ## Acknowledgements 39 | 40 | Phil uses [Font Awesome](https://github.com/FortAwesome/Font-Awesome/) and [fonticon](https://github.com/devgg/FontIcon) for icons, [jsPDF](https://github.com/MrRio/jsPDF/) and [jsPDF-AutoTable](https://github.com/simonbengtsson/jsPDF-AutoTable/) for generating PDFs, [Chart.js](https://github.com/chartjs) with the [datalabels](https://github.com/chartjs/chartjs-plugin-datalabels) plugin for charts, and [HyperList](https://github.com/tbranyen/hyperlist) for virtual scrolling of wordlist matches. Thanks to those who documented the .puz file format [here](https://code.google.com/archive/p/puz/wikis/FileFormat.wiki). The default wordlist is [Peter Broda's wordlist](https://peterbroda.me/crosswords/wordlist/). 41 | 42 | ## Getting started 43 | 44 | To use this fork: 45 | 46 | 1. Go to https://jmviz.github.io/Phil/. 47 | 48 | To run this fork locally: 49 | 50 | 1. Clone this repository. 51 | 52 | 2. Enter the Phil directory. 53 | 54 | 3. Run a local webserver. For example, with Python 3: 55 | 56 | ``` 57 | python -m http.server 8000 58 | ``` 59 | 60 | 4. Point your browser to [localhost:8000](http://localhost:8000). 61 | 62 | ## Crossword resources 63 | 64 | * [OneLook](http://onelook.com/) and [Crossword Tracker](http://crosswordtracker.com/) search engines 65 | * [XWord Info](https://www.xwordinfo.com) (some features require membership) 66 | * [Crosshare](https://crosshare.org), a nice site to construct and share crossword puzzles, and play puzzles shared by others 67 | * [Collected wordlists](http://wiki.puzzlers.org/dokuwiki/doku.php?id=solving:wordlists:about:start) of the National Puzzler's League 68 | * [Crossword theme categories](http://www.cruciverb.com/index.php?action=ezportal;sa=page;p=70) 69 | 70 | ## License 71 | Licensed under [the Apache License, v2.0](http://www.apache.org/licenses/LICENSE-2.0) (the 'License'). 72 | 73 | Unless required by law or agreed in writing, software distributed under the License 74 | is distributed on an **'as is' basis, without warranties or conditions**, express or implied. 75 | See the [License](LICENSE.txt) for the specific language governing permissions and limitations. 76 | 77 | Original [Phil](https://github.com/keiranking/Phil) Copyright © 2017 [Keiran King](http://www.keiranking.com/). 78 | -------------------------------------------------------------------------------- /input.js: -------------------------------------------------------------------------------- 1 | const letterKeys = [ 2 | "a","b","c","d","e","f","g","h","i","j","k","l","m", 3 | "n","o","p","q","r","s","t","u","v","w","x","y","z" 4 | ]; 5 | const ARROW_LEFT = "ArrowLeft"; 6 | const ARROW_RIGHT = "ArrowRight"; 7 | const ARROW_UP = "ArrowUp"; 8 | const ARROW_DOWN = "ArrowDown"; 9 | const arrowKeys = [ARROW_LEFT, ARROW_RIGHT, ARROW_UP, ARROW_DOWN]; 10 | const ENTER = "Enter"; 11 | const DELETE = "Backspace"; 12 | const ESCAPE = "Escape"; 13 | const BACKTICK = "`"; 14 | const SPACE = " "; 15 | 16 | function mouseHandler(e) { 17 | const previousCell = getGridSquare(current.row, current.col); 18 | previousCell.classList.remove("active"); 19 | const activeCell = e.currentTarget; 20 | if (activeCell == previousCell) { 21 | current.direction = (current.direction == ACROSS) ? DOWN : ACROSS; 22 | } 23 | current.row = Number(activeCell.parentNode.dataset.row); 24 | current.col = Number(activeCell.dataset.col); 25 | // console.log("[" + current.row + "," + current.col + "]"); 26 | activeCell.classList.add("active"); 27 | 28 | isMutated = false; 29 | updateUI(); 30 | } 31 | 32 | function keyboardHandler(e) { 33 | // console.log(e.key); 34 | if (e.key.toLowerCase() == "z" && (e.ctrlKey || e.metaKey)) { 35 | if (e.shiftKey) { 36 | redo(); 37 | } else { 38 | undo(); 39 | } 40 | return; 41 | } 42 | isMutated = false; 43 | let actionRow = current.row; 44 | let actionCol = current.col; 45 | let actionDirection = current.direction; 46 | let actionOld = xw.fill[current.row][current.col]; 47 | let activeCell = getGridSquare(current.row, current.col); 48 | const symRow = xw.rows - 1 - current.row; 49 | const symCol = xw.cols - 1 - current.col; 50 | let symCell = getGridSquare(symRow, symCol); 51 | let symOld = xw.fill[symRow][symCol]; 52 | 53 | if (letterKeys.includes(e.key.toLowerCase()) || e.key == SPACE) { 54 | let oldContent = xw.fill[current.row][current.col]; 55 | xw.fill[current.row][current.col] = e.key.toUpperCase(); 56 | activeCell.querySelector(".fill").classList.remove("rebus"); 57 | if (oldContent == BLOCK) { 58 | if (isSymmetrical) { 59 | xw.fill[symRow][symCol] = BLANK; 60 | } 61 | } 62 | // move the cursor 63 | if (current.direction == ACROSS) { 64 | e = new KeyboardEvent("keydown", {"key": ARROW_RIGHT}); 65 | } else { 66 | e = new KeyboardEvent("keydown", {"key": ARROW_DOWN}); 67 | } 68 | isMutated = true; 69 | } 70 | if (e.key == BLOCK) { 71 | if (xw.fill[current.row][current.col] == BLOCK) { // if already block... 72 | e = new KeyboardEvent("keydown", {"key": DELETE}); // make it a white square 73 | } else { 74 | xw.fill[current.row][current.col] = BLOCK; 75 | activeCell.querySelector(".fill").classList.remove("rebus"); 76 | if (isSymmetrical) { 77 | xw.fill[symRow][symCol] = BLOCK; 78 | symCell.querySelector(".fill").classList.remove("rebus"); 79 | } 80 | } 81 | isMutated = true; 82 | } 83 | if (e.key == ESCAPE) { 84 | enterRebus(e); 85 | } 86 | if (e.key == BACKTICK) { 87 | toggleCircle(isCircleDefault); 88 | } 89 | if (e.key == ENTER) { 90 | current.direction = (current.direction == ACROSS) ? DOWN : ACROSS; 91 | } 92 | if (e.key == DELETE) { 93 | e.preventDefault(); 94 | let oldContent = xw.fill[current.row][current.col]; 95 | xw.fill[current.row][current.col] = BLANK; 96 | activeCell.querySelector(".fill").classList.remove("rebus"); 97 | if (oldContent == BLOCK) { 98 | if (isSymmetrical) { 99 | xw.fill[symRow][symCol] = BLANK; 100 | } 101 | } else { // move the cursor 102 | if (current.direction == ACROSS) { 103 | e = new KeyboardEvent("keydown", {"key": ARROW_LEFT}); 104 | } else { 105 | e = new KeyboardEvent("keydown", {"key": ARROW_UP}); 106 | } 107 | } 108 | isMutated = true; 109 | } 110 | if (actionOld != xw.fill[actionRow][actionCol]) { 111 | let state = { 112 | "row": actionRow, 113 | "col": actionCol, 114 | "direction": actionDirection, 115 | "old": actionOld, 116 | "new": xw.fill[actionRow][actionCol] 117 | }; 118 | if (isSymmetrical) { 119 | let symState = { 120 | "isSymmetrical": isSymmetrical, 121 | "symRow": symRow, 122 | "symCol": symCol, 123 | "symOld": symOld, 124 | "symNew": xw.fill[symRow][symCol] 125 | }; 126 | Object.assign(state, symState); 127 | } 128 | actionTimeline.record(new Action("editFill", state)); 129 | } 130 | if (arrowKeys.includes(e.key)) { 131 | e.preventDefault(); 132 | const previousCell = getGridSquare(current.row, current.col); 133 | previousCell.classList.remove("active"); 134 | let content = xw.fill[current.row][current.col]; 135 | let move = 1; 136 | switch (e.key) { 137 | case ARROW_LEFT: 138 | if (current.direction == ACROSS || content == BLOCK) { 139 | if (e.altKey && content != BLOCK) { 140 | move = current.col - current.acrossStartIndex || 1; 141 | } else if (e.ctrlKey || e.metaKey){ 142 | move = current.col; 143 | } 144 | current.col -= (current.col == 0) ? 0 : move; 145 | } 146 | current.direction = ACROSS; 147 | break; 148 | case ARROW_UP: 149 | if (current.direction == DOWN || content == BLOCK) { 150 | if (e.altKey && content != BLOCK) { 151 | move = current.row - current.downStartIndex || 1; 152 | } else if (e.ctrlKey || e.metaKey){ 153 | move = current.row; 154 | } 155 | current.row -= (current.row == 0) ? 0 : move; 156 | } 157 | current.direction = DOWN; 158 | break; 159 | case ARROW_RIGHT: 160 | if (current.direction == ACROSS || content == BLOCK) { 161 | if (e.altKey && content != BLOCK) { 162 | move = current.acrossEndIndex - 1 - current.col || 1; 163 | } else if (e.ctrlKey || e.metaKey){ 164 | move = xw.cols - 1 - current.col; 165 | } 166 | current.col += (current.col == xw.cols - 1) ? 0 : move; 167 | } 168 | current.direction = ACROSS; 169 | break; 170 | case ARROW_DOWN: 171 | if (current.direction == DOWN || content == BLOCK) { 172 | if (e.altKey && content != BLOCK) { 173 | move = current.downEndIndex - 1 - current.row || 1; 174 | } else if (e.ctrlKey || e.metaKey){ 175 | move = xw.rows - 1 - current.row; 176 | } 177 | current.row += (current.row == xw.rows - 1) ? 0 : move; 178 | } 179 | current.direction = DOWN; 180 | break; 181 | } 182 | // console.log("[" + current.row + "," + current.col + "]"); 183 | activeCell = getGridSquare(current.row, current.col); 184 | activeCell.classList.add("active"); 185 | } 186 | updateUI(); 187 | } 188 | 189 | function mobileKeyboardHandler(char) { 190 | let e; 191 | switch (char) { 192 | case '⌫': 193 | e = new KeyboardEvent("keydown", {"key": DELETE}); 194 | break; 195 | case '␣': 196 | e = new KeyboardEvent("keydown", {"key": SPACE}); 197 | break; 198 | case '👁️': 199 | e = new KeyboardEvent("keydown", {"key": ESCAPE}); 200 | break; 201 | default: 202 | e = new KeyboardEvent("keydown", {"key": char}); 203 | 204 | } 205 | keyboardHandler(e); 206 | if (char != '👁️') { 207 | grid.focus(); 208 | } 209 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /wordlist.js: -------------------------------------------------------------------------------- 1 | // Modified by jmviz. Original notice follows: 2 | // 3 | // Phil 4 | // ------------------------------------------------------------------------ 5 | // Copyright 2017 Keiran King 6 | 7 | // Licensed under the Apache License, Version 2.0 (the "License"); 8 | // you may not use this file except in compliance with the License. 9 | // (https://www.apache.org/licenses/LICENSE-2.0) 10 | 11 | // Unless required by applicable law or agreed to in writing, software 12 | // distributed under the License is distributed on an "AS IS" BASIS, 13 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | // See the License for the specific language governing permissions and 15 | // limitations under the License. 16 | // ------------------------------------------------------------------------ 17 | 18 | let wordlist = [ 19 | [], [], [], [], [], 20 | [], [], [], [], [], 21 | [], [], [], [], [], 22 | [], [], [], [], [], 23 | [], [], [], [], [], 24 | [], [], [], [], [], 25 | [], [], [], [], [], [] 26 | ]; 27 | 28 | openDefaultWordlist("https://raw.githubusercontent.com/jmviz/Phil/master/wordlists/peter-broda-wordlist__unscored.txt"); 29 | 30 | let acrossMatchList = document.getElementById("across-matches"); 31 | let downMatchList = document.getElementById("down-matches"); 32 | 33 | acrossMatchList.addEventListener('dblclick', fillGridWithMatch); 34 | downMatchList.addEventListener('dblclick', fillGridWithMatch); 35 | 36 | let acrossMatches = []; 37 | let downMatches = []; 38 | 39 | let acrossMatchHyperListConfig = { 40 | height: "84%", 41 | itemHeight: 19, 42 | total: 0, 43 | generate(index) { 44 | const el = document.createElement('li'); 45 | el.innerHTML = acrossMatches[index]; 46 | return el; 47 | } 48 | } 49 | 50 | let downMatchHyperListConfig = { 51 | height: "84%", 52 | itemHeight: 19, 53 | total: 0, 54 | generate(index) { 55 | const el = document.createElement('li'); 56 | el.innerHTML = downMatches[index]; 57 | return el; 58 | } 59 | } 60 | 61 | let acrossMatchHyperList = HyperList.default.create(acrossMatchList, acrossMatchHyperListConfig); 62 | let downMatchHyperList = HyperList.default.create(downMatchList, downMatchHyperListConfig); 63 | 64 | 65 | //____________________ 66 | // F U N C T I O N S 67 | 68 | function addToWordlist(newWords) { 69 | for (let i = 0; i < newWords.length; i++) { 70 | const word = newWords[i].trim().toUpperCase(); 71 | if (word.length < wordlist.length) { // Make sure we don't access outside the wordlist array 72 | wordlist[word.length].push(word); 73 | } 74 | } 75 | } 76 | 77 | function sortWordlist() { 78 | for (let i = 3; i < wordlist.length; i++) { 79 | wordlist[i].sort(); 80 | } 81 | } 82 | 83 | function openWordlist() { 84 | document.getElementById("open-wordlist-input").click(); 85 | } 86 | 87 | function openWordlistFile(e) { 88 | wordlist = [ 89 | [], [], [], [], [], 90 | [], [], [], [], [], 91 | [], [], [], [], [], 92 | [], [], [], [], [], 93 | [], [], [], [], [], 94 | [], [], [], [], [], 95 | [], [], [], [], [], [] 96 | ]; 97 | 98 | const file = e.target.files[0]; 99 | if (!file) { 100 | return; 101 | } 102 | let reader = new FileReader(); 103 | reader.onload = function(e) { 104 | const words = e.target.result.split(/\s/g); 105 | addToWordlist(words); 106 | sortWordlist(); 107 | removeWordlistDuplicates(); 108 | invalidateSolverWordlist(); 109 | }; 110 | reader.readAsText(file); 111 | } 112 | 113 | function openDefaultWordlist(url) { 114 | let textFile = new XMLHttpRequest(); 115 | textFile.open("GET", url, true); 116 | textFile.onreadystatechange = function() { 117 | // Makes sure the document is ready to parse, and it's found the file. 118 | if (textFile.readyState === 4 && textFile.status === 200) { 119 | const words = textFile.responseText.split(/\s/g); 120 | addToWordlist(words); 121 | sortWordlist(); 122 | console.log("Loaded default wordlist from remote."); 123 | } else {} 124 | }; 125 | textFile.send(null); 126 | } 127 | 128 | function removeWordlistDuplicates() { 129 | for (let i = 3; i < wordlist.length; i++) { 130 | if (wordlist[i].length >= 2) { 131 | for (let j = wordlist[i].length - 1; j >0; j--) { 132 | if (wordlist[i][j] == wordlist[i][j - 1]) { 133 | wordlist[i].splice(j, 1); 134 | } 135 | } 136 | } 137 | } 138 | } 139 | 140 | function matchFromWordlist(word) { 141 | const wordLength = word.length; 142 | const actualLettersInWord = word.replace(/-/g, "").length; 143 | if (actualLettersInWord < wordLength) { // Don't search if already filled 144 | word = word.split(DASH).join("."); 145 | const pattern = new RegExp(word); 146 | let matches = []; 147 | for (let i = 0; i < wordlist[wordLength].length; i++) { 148 | if (wordlist[wordLength][i].search(pattern) > -1) { 149 | matches.push(wordlist[wordLength][i]); 150 | } 151 | } 152 | return matches; 153 | } else { 154 | return []; 155 | } 156 | } 157 | 158 | function matchWordStrict(square, direction, isFirstCall) { 159 | let [oppDir, axis, oppAxis] = 160 | (direction == ACROSS) ? [DOWN, "row", "col"] : [ACROSS, "col", "row"]; 161 | let matches = matchFromWordlist(square[direction + "Word"]); 162 | let start = square[direction + "StartIndex"]; 163 | let end = square[direction + "EndIndex"]; 164 | let lineIndex = square[axis]; 165 | let line = getLine(direction, lineIndex); 166 | let array = line.slice(start, end); 167 | for (let k = start; k < end; k++) { 168 | if (line[k] == BLANK) { 169 | let arrayIndex = k - start; 170 | let index = getStringIndex(array, arrayIndex); 171 | let cross = (direction == ACROSS) ? getWordAt(lineIndex, k, oppDir): getWordAt(k, lineIndex, oppDir); 172 | if (!cross.split("").every(c => c == DASH)) { 173 | let crossLine = getLine(oppDir, k); 174 | let [crossStart, crossEnd] = getWordIndices(crossLine, oppDir, lineIndex); 175 | let crossArray = crossLine.slice(crossStart, crossEnd); 176 | let crossArrayIndex = lineIndex - crossStart; 177 | let crossIndex = getStringIndex(crossArray, crossArrayIndex); 178 | let crossSquare = {}; 179 | crossSquare[oppDir + "Word"] = cross; 180 | crossSquare[oppAxis] = k; 181 | crossSquare[oppDir + "StartIndex"] = crossStart; 182 | crossSquare[oppDir + "EndIndex"] = crossEnd; 183 | if (isFirstCall) { 184 | crossMatches = matchWordStrict(crossSquare, oppDir, false); 185 | } else { 186 | crossMatches = matchFromWordlist(cross); 187 | } 188 | let letters = []; 189 | for (let crossMatch of crossMatches) { 190 | if (!letters.includes(crossMatch[crossIndex])) { 191 | letters.push(crossMatch[crossIndex]); 192 | } 193 | } 194 | matches = matches.filter(m => letters.includes(m[index])); 195 | } 196 | } 197 | } 198 | return matches; 199 | } 200 | 201 | function updateMatchesUI() { 202 | acrossMatches = []; 203 | downMatches = []; 204 | if (isStrictMatching) { 205 | acrossMatches = matchWordStrict(current, ACROSS, true); 206 | downMatches = matchWordStrict(current, DOWN, true); 207 | } else { 208 | acrossMatches = matchFromWordlist(current.acrossWord); 209 | downMatches = matchFromWordlist(current.downWord); 210 | } 211 | 212 | acrossMatchHyperListConfig.total = acrossMatches.length; 213 | acrossMatchHyperList.refresh(acrossMatchList, acrossMatchHyperListConfig); 214 | acrossMatchList.scrollTop = 0; 215 | downMatchHyperListConfig.total = downMatches.length; 216 | downMatchHyperList.refresh(downMatchList, downMatchHyperListConfig); 217 | downMatchList.scrollTop = 0; 218 | 219 | let row = getLine(ACROSS, current.row); 220 | let acrossWordUpto = row.slice(current.acrossStartIndex, current.col); 221 | let acrossIndex = acrossWordUpto.reduce((a,b) => a + b.length, 0); 222 | let acrossHist = new Array(alphabet.length).fill(0); 223 | for (let i = 0; i < acrossMatches.length; i++) { 224 | let match = acrossMatches[i]; 225 | let alphabetIndex = alphabet.indexOf(match[acrossIndex]); 226 | if (alphabetIndex >= 0) acrossHist[alphabetIndex]++; 227 | } 228 | acrossChart.data.datasets[0].data = acrossHist; 229 | acrossChart.options.scales.xAxes[0].ticks.max = Math.max(1, Math.max(...acrossHist)); 230 | 231 | let col = getLine(DOWN, current.col); 232 | let downWordUpto = col.slice(current.downStartIndex, current.row); 233 | let downIndex = downWordUpto.reduce((a,b) => a + b.length, 0); 234 | let downHist = new Array(alphabet.length).fill(0); 235 | for (let i = 0; i < downMatches.length; i++) { 236 | let match = downMatches[i]; 237 | let alphabetIndex = alphabet.indexOf(match[downIndex]); 238 | if (alphabetIndex >= 0) downHist[alphabetIndex]++; 239 | } 240 | downChart.data.datasets[0].data = downHist; 241 | downChart.options.scales.xAxes[0].ticks.max = Math.max(1, Math.max(...downHist)); 242 | 243 | let primary = getComputedStyle(document.body).getPropertyValue('--primary-color'); 244 | let zero = getComputedStyle(document.body).getPropertyValue('--chart-label-zero-color'); 245 | let labelColors = acrossHist.map((x, i) => x && downHist[i] ? primary : zero); 246 | acrossChart.options.plugins.datalabels.color = labelColors; 247 | downChart.options.plugins.datalabels.color = labelColors; 248 | acrossChart.update(); 249 | downChart.update(); 250 | } 251 | 252 | function fillGridWithMatch(e) { 253 | if (e.target && e.target.matches("li")) { 254 | const li = e.target; 255 | const match = li.innerHTML.toUpperCase(); 256 | let k = 0; 257 | let oldWord = []; 258 | let newWord = []; 259 | let start = -1; 260 | let end = -1; 261 | const dir = (li.parentNode.id == "across-matches") ? ACROSS : DOWN; 262 | if (dir == ACROSS) { 263 | start = current.acrossStartIndex; 264 | end = current.acrossEndIndex; 265 | for (let j = start; j < end; j++) { 266 | oldWord.push(xw.fill[current.row][j]); 267 | xw.fill[current.row][j] = match.slice(k, k + xw.fill[current.row][j].length); 268 | newWord.push(xw.fill[current.row][j]); 269 | k += xw.fill[current.row][j].length; 270 | } 271 | } else { 272 | start = current.downStartIndex; 273 | end = current.downEndIndex; 274 | for (let i = start; i < end; i++) { 275 | oldWord.push(xw.fill[i][current.col]); 276 | xw.fill[i][current.col] = match.slice(k, k + xw.fill[i][current.col].length); 277 | newWord.push(xw.fill[i][current.col]); 278 | k += xw.fill[i][current.col].length; 279 | } 280 | } 281 | isMutated = true; 282 | let state = { 283 | "row": current.row, 284 | "col": current.col, 285 | "direction": dir, 286 | "start": start, 287 | "end": end, 288 | "old": oldWord, 289 | "new": newWord 290 | }; 291 | actionTimeline.record(new Action("fillMatch", state)); 292 | console.log("Filled '" + li.innerHTML + "' going " + dir); 293 | updateUI(); 294 | grid.focus(); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Phil - a free crossword maker 9 | 10 | 11 | 17 | 18 |
19 | 20 |
21 | 38 | 41 | 44 | 45 | 60 | 63 | 64 |
65 | 66 |
67 | 68 |
69 | 72 | 75 |
76 | 77 |
78 | 79 |
80 | 86 | 89 | 97 | 100 |
101 | 102 |
103 | 104 |
105 | 108 | 111 | 114 | 117 |
118 | 119 |
120 | 121 |
122 | 125 | 128 | 129 | 132 |
133 | 134 |
135 | 136 |
137 | 140 | 143 |
144 | 145 |
146 | 147 |
148 | 149 | 171 | 172 |
173 | 174 | 175 |
176 | 177 |
178 |
179 |
180 |
Letters
181 | 182 |
183 |
184 |
Word lengths
185 | 186 |
187 |
188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 |
Words:
Mean word length:
Blocks:
Open squares:
Letters:
Mean Scrabble points:
197 |
198 | 199 | 200 | 201 | 202 | 220 | 221 |
222 |
223 |
q
224 |
w
225 |
e
226 |
r
227 |
t
228 |
y
229 |
u
230 |
i
231 |
o
232 |
p
233 |
234 |
235 |
a
236 |
s
237 |
d
238 |
f
239 |
g
240 |
h
241 |
j
242 |
k
243 |
l
244 |
245 |
246 |
247 |
248 |
z
249 |
x
250 |
c
251 |
v
252 |
b
253 |
n
254 |
m
255 |
.
256 |
👁️
257 |
258 |
259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | -------------------------------------------------------------------------------- /stats.js: -------------------------------------------------------------------------------- 1 | Chart.defaults.global.defaultFontFamily = 2 | getComputedStyle(document.body).getPropertyValue('--font-family-mono'); 3 | Chart.defaults.global.defaultFontColor = 4 | getComputedStyle(document.body).getPropertyValue('--secondary-color'); 5 | 6 | var letterChart; 7 | var wordChart; 8 | 9 | alphabet = [ 10 | 'A','B','C','D','E','F','G','H','I','J','K','L','M', 11 | 'N','O','P','Q','R','S','T','U','V','W','X','Y','Z' 12 | ]; 13 | 14 | class StatChartSpec { 15 | constructor(id, label, labels, data, hoverFunction) { 16 | this.ctx = document.getElementById(id); 17 | this.config = { 18 | type: 'horizontalBar', 19 | // type: 'bar', 20 | data: { 21 | labels: labels, 22 | datasets: [{ 23 | label: label, 24 | data: data, 25 | backgroundColor: getComputedStyle(document.body).getPropertyValue('--quaternary-color'), 26 | borderColor: getComputedStyle(document.body).getPropertyValue('--shade-color'), 27 | borderWidth: 1, 28 | hoverBackgroundColor: getComputedStyle(document.body).getPropertyValue('--shade-color'), 29 | hoverBorderColor: getComputedStyle(document.body).getPropertyValue('--tertiary-color') 30 | }] 31 | }, 32 | options: { 33 | plugins: { 34 | datalabels: { 35 | // anchor: 'end', 36 | // align: 'end', 37 | // offset: -2, 38 | align: 'bottom', 39 | offset: -8.75, 40 | font: { 41 | size: 9 42 | }, 43 | color: getComputedStyle(document.body).getPropertyValue('--primary-color'), 44 | display: function(ctx) {return ctx.dataset.data[ctx.dataIndex] != 0 ;} 45 | } 46 | }, 47 | onHover: hoverFunction, 48 | tooltips: { 49 | "enabled": false 50 | }, 51 | legend: { 52 | display: false 53 | // labels: { 54 | // fontFamily: getComputedStyle(document.body).getPropertyValue('--font-family-sans') 55 | // } 56 | }, 57 | scales: { 58 | xAxes: [{ 59 | display: false, 60 | gridLines: { 61 | display: false 62 | }, 63 | ticks: { 64 | max: Math.max(1, Math.max(...data)), //+ 10 65 | display: false, 66 | beginAtZero: true, 67 | sampleSize: 0 68 | } 69 | }], 70 | yAxes: [{ 71 | gridLines: { 72 | display: false 73 | }, 74 | ticks: { 75 | sampleSize: 0 76 | } 77 | }] 78 | }, 79 | responsive: true, 80 | maintainAspectRatio: true 81 | } 82 | }; 83 | } 84 | } 85 | 86 | class MatchesChartSpec { 87 | constructor(id, label, labels, data) { 88 | this.ctx = document.getElementById(id); 89 | this.config = { 90 | type: 'horizontalBar', 91 | // type: 'bar', 92 | data: { 93 | labels: labels, 94 | datasets: [{ 95 | label: label, 96 | data: data, 97 | backgroundColor: getComputedStyle(document.body).getPropertyValue('--quaternary-color'), 98 | borderColor: getComputedStyle(document.body).getPropertyValue('--shade-color'), 99 | borderWidth: 1, 100 | hoverBackgroundColor: getComputedStyle(document.body).getPropertyValue('--quaternary-color'), 101 | hoverBorderColor: getComputedStyle(document.body).getPropertyValue('--shade-color') 102 | }] 103 | }, 104 | options: { 105 | animation: { 106 | duration: 0 // general animation time 107 | }, 108 | hover: { 109 | animationDuration: 0 // duration of animations when hovering an item 110 | }, 111 | responsiveAnimationDuration: 0, // animation duration after a resize 112 | plugins: { 113 | datalabels: { 114 | align: 'bottom', 115 | offset: -8.5, 116 | font: { 117 | size: 8, 118 | }, 119 | color: function(ctx) { 120 | let count = ctx.dataset.data[ctx.dataIndex]; 121 | if (count > 0) { 122 | // if (10 * count < Math.max(...ctx.dataset.data)) { 123 | // return getComputedStyle(document.body).getPropertyValue('--primary-color'); 124 | // } else { 125 | return getComputedStyle(document.body).getPropertyValue('--primary-color'); 126 | // } 127 | } 128 | else { 129 | return getComputedStyle(document.body).getPropertyValue('--chart-label-zero-color'); 130 | } 131 | } 132 | // display: function(ctx) {return ctx.dataset.data[ctx.dataIndex] != 0 ;} 133 | } 134 | }, 135 | tooltips: { 136 | "enabled": false 137 | }, 138 | legend: { 139 | display: false 140 | }, 141 | scales: { 142 | xAxes: [{ 143 | display: false, 144 | gridLines: { 145 | display: false 146 | }, 147 | ticks: { 148 | max: Math.max(1, Math.max(...data)), 149 | display: false, 150 | beginAtZero: true, 151 | sampleSize: 0 152 | } 153 | }], 154 | yAxes: [{ 155 | gridLines: { 156 | display: false 157 | }, 158 | ticks: { 159 | fontSize: 10, 160 | sampleSize: 0 161 | }, 162 | categoryPercentage: 0.95, 163 | barPercentage: 1 164 | }] 165 | }, 166 | responsive: true, 167 | maintainAspectRatio: true 168 | } 169 | }; 170 | } 171 | } 172 | 173 | let acrossChartSpec = new MatchesChartSpec( 174 | 'across-chart', 175 | 'Across matches', 176 | alphabet, 177 | new Array(this.alphabet.length).fill(0) 178 | ); 179 | acrossChart = new Chart(acrossChartSpec.ctx, acrossChartSpec.config); 180 | let downChartSpec = new MatchesChartSpec( 181 | 'down-chart', 182 | 'Down matches', 183 | alphabet, 184 | new Array(this.alphabet.length).fill(0) 185 | ); 186 | downChart = new Chart(downChartSpec.ctx, downChartSpec.config); 187 | 188 | class Stats { 189 | constructor() { 190 | this.alphabet = alphabet; 191 | this.scrabblePoints = [1,3,3,2,1,4,2,4,1,8,5,1,3,1,1,3,10,1,1,1,1,4,4,8,4,10]; 192 | this.letterCounts = new Array(this.alphabet.length).fill(0); 193 | this.letters = 0; 194 | this.wordLengths = [...Array(Math.max(xw.rows, xw.cols)).keys()].map(x => ++x); 195 | this.wordCounts = new Array(this.wordLengths.length).fill(0); 196 | this.wordCounts[xw.rows-1] += xw.cols; 197 | this.wordCounts[xw.cols-1] += xw.rows; 198 | this.squares = xw.rows * xw.cols; 199 | this.openSquares = this.fullyConnectedSquares = this.squares; 200 | this.openSquaresPct = this.fullyConnectedSquaresPct = 1; 201 | this.blocks = 0; 202 | this.blocksPct = 0; 203 | this.words = xw.rows + xw.cols; 204 | this.avgWordLength = 2 * this.squares / this.words; 205 | this.scrabbleTotal = 0; 206 | this.avgScrabblePoints = 0; 207 | } 208 | update() { 209 | this.wordCounts.fill(0); 210 | this.letterCounts.fill(0); 211 | this.letters = 0; 212 | this.openSquares = 0; 213 | this.fullyConnectedSquares = 0; 214 | this.blocks = 0; 215 | this.words = 0; 216 | this.scrabbleTotal = 0; 217 | let wordLengthTotal = 0; 218 | let onWord = false; 219 | let length = 0; 220 | for (let i = 0; i < xw.rows; i++) { 221 | for (let j = 0; j < xw.cols; j++) { 222 | let fill = xw.fill[i][j]; 223 | for (let f = 0; f < fill.length; f++) { 224 | for (let k = 0; k < this.alphabet.length; k++) { 225 | if (fill[f] == this.alphabet[k]) { 226 | this.letters++; 227 | this.letterCounts[k]++; 228 | this.scrabbleTotal += this.scrabblePoints[k]; 229 | } 230 | } 231 | } 232 | if (fill == BLOCK) { 233 | this.blocks++; 234 | } 235 | if (fill != BLOCK) { 236 | if ( 237 | (xw.fill[i-1] === undefined || xw.fill[i-1][j] != BLOCK) && 238 | (xw.fill[i+1] === undefined || xw.fill[i+1][j] != BLOCK) && 239 | (xw.fill[i][j-1] != BLOCK) && 240 | (xw.fill[i][j+1] != BLOCK) 241 | ) { 242 | this.fullyConnectedSquares++; 243 | if ( 244 | (xw.fill[i-1] === undefined || xw.fill[i-1][j-1] != BLOCK) && 245 | (xw.fill[i-1] === undefined || xw.fill[i-1][j+1] != BLOCK) && 246 | (xw.fill[i+1] === undefined || xw.fill[i+1][j-1] != BLOCK) && 247 | (xw.fill[i+1] === undefined || xw.fill[i+1][j+1] != BLOCK) 248 | ) { 249 | this.openSquares++; 250 | } 251 | } 252 | if (!onWord) { 253 | onWord = true; 254 | } 255 | length++; 256 | } 257 | if (fill == BLOCK || j == xw.cols-1) { 258 | if (onWord) { 259 | onWord = false; 260 | this.wordCounts[length-1]++; 261 | this.words++; 262 | wordLengthTotal += length; 263 | length = 0; 264 | } 265 | } 266 | } 267 | } 268 | for (let j = 0; j < xw.cols; j++) { 269 | for (let i = 0; i < xw.rows; i++) { 270 | let fill = xw.fill[i][j]; 271 | if (fill != BLOCK) { 272 | if (!onWord) { 273 | onWord = true; 274 | } 275 | length++; 276 | } 277 | if (fill == BLOCK || i == xw.rows-1) { 278 | if (onWord) { 279 | onWord = false; 280 | this.wordCounts[length-1]++; 281 | this.words++; 282 | wordLengthTotal += length; 283 | length = 0; 284 | } 285 | } 286 | } 287 | } 288 | this.openSquaresPct = this.openSquares / this.squares; 289 | this.fullyConnectedSquaresPct = this.fullyConnectedSquares / this.squares; 290 | this.blocksPct = this.blocks / this.squares; 291 | this.avgWordLength = wordLengthTotal / this.words; 292 | this.avgScrabblePoints = (this.letters == 0) ? 0 : this.scrabbleTotal / this.letters; 293 | } 294 | } 295 | 296 | function updateStatsUI(init) { 297 | if (init) { 298 | if(letterChart) { 299 | letterChart.destroy(); 300 | wordChart.destroy(); 301 | } 302 | stats = new Stats(); 303 | let letterChartSpec = new StatChartSpec( 304 | 'letter-chart', 305 | 'Letters', 306 | // stats.alphabet.map(a => a.toLowerCase()), 307 | stats.alphabet, 308 | stats.letterCounts, 309 | hoverLetterChart 310 | ); 311 | letterChart = new Chart(letterChartSpec.ctx, letterChartSpec.config); 312 | let wordChartSpec = new StatChartSpec( 313 | 'word-chart', 314 | 'Words', 315 | stats.wordLengths, 316 | stats.wordCounts, 317 | hoverWordChart 318 | ); 319 | wordChart = new Chart(wordChartSpec.ctx, wordChartSpec.config); 320 | } else { 321 | stats.update(); 322 | letterChart.data.datasets[0].data = stats.letterCounts; 323 | letterChart.options.scales.xAxes[0].ticks.max = Math.max(...stats.letterCounts); //+ 10 324 | letterChart.update(); 325 | wordChart.data.datasets[0].data = stats.wordCounts; 326 | wordChart.options.scales.xAxes[0].ticks.max = Math.max(...stats.wordCounts); //+ 10 327 | wordChart.update(); 328 | } 329 | updateStatsTable(); 330 | } 331 | 332 | function updateStatsTable() { 333 | document.getElementById("stats-words").innerHTML = 334 | stats.words.toString(); 335 | document.getElementById("stats-avg-word-length").innerHTML = 336 | preciseRound(stats.avgWordLength, 2); 337 | document.getElementById("stats-blocks").innerHTML = 338 | stats.blocks.toString().concat(" (", preciseRound(100 * stats.blocksPct, 2), "%)"); 339 | document.getElementById("stats-fully-connected-squares").innerHTML = 340 | stats.fullyConnectedSquares.toString().concat(" (", preciseRound(100 * stats.fullyConnectedSquaresPct, 2), "%)"); 341 | document.getElementById("stats-open-squares").innerHTML = 342 | stats.openSquares.toString().concat(" (", preciseRound(100 * stats.openSquaresPct, 2), "%)"); 343 | document.getElementById("stats-letters").innerHTML = 344 | stats.letters.toString(); 345 | document.getElementById("stats-avg-scrabble-points").innerHTML = 346 | preciseRound(stats.avgScrabblePoints, 2); 347 | } 348 | 349 | function preciseRound(num, decimals) { 350 | let sign = num >= 0 ? 1 : -1; 351 | return (Math.round((num*Math.pow(10,decimals)) + (sign*0.001)) / Math.pow(10,decimals)).toFixed(decimals); 352 | } 353 | 354 | function updateStatsUIColors() { 355 | let fontColor = getComputedStyle(document.body).getPropertyValue('--secondary-color'); 356 | let backgroundColor = getComputedStyle(document.body).getPropertyValue('--quaternary-color'); 357 | let borderColor = getComputedStyle(document.body).getPropertyValue('--shade-color'); 358 | let labelColor = getComputedStyle(document.body).getPropertyValue('--primary-color'); 359 | let hoverBackgroundColor = getComputedStyle(document.body).getPropertyValue('--shade-color'); 360 | let hoverBorderColor = getComputedStyle(document.body).getPropertyValue('--tertiary-color'); 361 | Chart.defaults.global.defaultFontColor = fontColor; 362 | letterChart.data.datasets[0].backgroundColor = backgroundColor; 363 | letterChart.data.datasets[0].borderColor = borderColor; 364 | letterChart.data.datasets[0].hoverBackgroundColor = hoverBackgroundColor; 365 | letterChart.data.datasets[0].hoverBorderColor = hoverBorderColor; 366 | letterChart.options.plugins.datalabels.color = labelColor; 367 | wordChart.data.datasets[0].backgroundColor = backgroundColor; 368 | wordChart.data.datasets[0].borderColor = borderColor; 369 | wordChart.data.datasets[0].hoverBackgroundColor = hoverBackgroundColor; 370 | wordChart.data.datasets[0].hoverBorderColor = hoverBorderColor; 371 | wordChart.options.plugins.datalabels.color = labelColor; 372 | acrossChart.data.datasets[0].backgroundColor = backgroundColor; 373 | acrossChart.data.datasets[0].borderColor = borderColor; 374 | acrossChart.data.datasets[0].hoverBackgroundColor = backgroundColor; 375 | acrossChart.data.datasets[0].hoverBorderColor = borderColor; 376 | downChart.data.datasets[0].backgroundColor = backgroundColor; 377 | downChart.data.datasets[0].borderColor = borderColor; 378 | downChart.data.datasets[0].hoverBackgroundColor = backgroundColor; 379 | downChart.data.datasets[0].hoverBorderColor = borderColor; 380 | letterChart.update(); 381 | wordChart.update(); 382 | acrossChart.update(); 383 | downChart.update(); 384 | updateMatchesUI(); 385 | } 386 | 387 | hoverLetterIndex = null; 388 | hoverWordIndex = null; 389 | 390 | function hoverLetterChart(e) { 391 | let item = letterChart.getElementAtEvent(e); 392 | if (item.length && hoverLetterIndex != item[0]._index) { 393 | hoverLetterIndex = item[0]._index; 394 | toggleHighlightLetters(stats.alphabet[hoverLetterIndex], true); 395 | } else if (!item.length && hoverLetterIndex !== null) { 396 | toggleHighlightLetters(stats.alphabet[hoverLetterIndex], false); 397 | hoverLetterIndex = null; 398 | } 399 | } 400 | 401 | function hoverWordChart(e) { 402 | let item = wordChart.getElementAtEvent(e); 403 | if (item.length && hoverWordIndex != item[0]._index) { 404 | hoverWordIndex = item[0]._index; 405 | toggleHighlightWords(stats.wordLengths[hoverWordIndex], true); 406 | } else if (!item.length && hoverWordIndex !== null) { 407 | toggleHighlightWords(stats.wordLengths[hoverWordIndex], false); 408 | hoverWordIndex = null; 409 | } 410 | } 411 | 412 | function toggleHighlightLetters(letter, on = true) { 413 | for (let i = 0; i < xw.rows; i++) { 414 | for (let j = 0; j < xw.cols; j++) { 415 | let square = getGridSquare(i, j); 416 | square.classList.remove("highlight-chart-hover"); 417 | if (xw.fill[i][j].includes(letter) && on) { 418 | square.classList.add("highlight-chart-hover"); 419 | } 420 | } 421 | } 422 | } 423 | 424 | function toggleHighlightWords(wordLength, on = true) { 425 | for (let i = 0; i < xw.rows; i++) { 426 | for (let j = 0; j < xw.cols; j++) { 427 | let square = getGridSquare(i, j); 428 | square.classList.remove("highlight-chart-hover"); 429 | let acrossWordIndices = getWordIndices(getRow(i), ACROSS, j); 430 | let acrossWordLength = acrossWordIndices[1] - acrossWordIndices[0]; 431 | let downWordIndices = getWordIndices(getCol(j), DOWN, i); 432 | let downWordLength = downWordIndices[1] - downWordIndices[0]; 433 | if ((acrossWordLength == wordLength || downWordLength == wordLength) && on) { 434 | square.classList.add("highlight-chart-hover"); 435 | } 436 | } 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /fill.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Crossword puzzle filler. 3 | */ 4 | const UNFILLED_CHAR = '.'; 5 | const BLOCK_CHAR = '#'; 6 | 7 | const DIR_ACROSS = 0; 8 | const DIR_DOWN = 1; 9 | 10 | /* 11 | * Bitmap helpers. We store viable characters as a bit-per-letter; since 12 | * we have 26 possible letters we can fit it all in a u32. 13 | */ 14 | const ALL_CHARS = "abcdefghijklmnopqrstuvwxyz"; 15 | const ALL_CHARS_BITMAP = (1 << (ALL_CHARS.length + 1)) - 1; 16 | const ALL_CHARS_BASE = ALL_CHARS.charCodeAt(0); 17 | 18 | function char_to_bitmap(x) { 19 | if (x === BLOCK_CHAR) 20 | return 0; 21 | return x.toLowerCase().charCodeAt(0) - ALL_CHARS_BASE; 22 | } 23 | 24 | function bitmap_to_char(x) { 25 | return String.fromCharCode(x + ALL_CHARS_BASE); 26 | } 27 | 28 | class Wordlist { 29 | 30 | constructor(word_array) { 31 | this.words = []; 32 | this.word_bitmaps = []; 33 | this.scores = {}; 34 | this.len_idx = new Map(); 35 | let word; 36 | let score; 37 | 38 | for (let i=0; i < word_array.length; i++) { 39 | var w = word_array[i]; 40 | var split = w.split(/;|\s/); 41 | if (split.length >= 2) { 42 | [word, score] = [split[0], parseInt(split[1], 10)]; 43 | } else { 44 | [word, score] = [split[0], 1]; 45 | } 46 | this.scores[word] = score; 47 | this.words.push(word); 48 | } 49 | 50 | // Sort by length, then by score descending, then alphabetically. 51 | // This means we can easily find the highest ranked words of a given 52 | // length. 53 | var self = this; 54 | this.words.sort(function(a, b) { 55 | 56 | if (a.length != b.length) 57 | return a.length - b.length; 58 | 59 | if (self.scores[b] != self.scores[a]) 60 | return self.scores[b] - self.scores[a]; 61 | 62 | return a.localeCompare(b); 63 | }); 64 | 65 | for (let i = 0; i < this.words.length; i++) { 66 | var wlen = this.words[i].length; 67 | if (!this.len_idx.has(wlen)) { 68 | this.len_idx.set(wlen, i); 69 | } 70 | var bitmap = []; 71 | word = this.words[i]; 72 | for (let j=0; j < word.length; j++) { 73 | bitmap.push(1 << char_to_bitmap(word[j])); 74 | } 75 | this.word_bitmaps.push(bitmap); 76 | } 77 | console.log(this.words.length + " words loaded."); 78 | } 79 | 80 | at(index) { 81 | return this.words[index]; 82 | } 83 | 84 | bitmap_at(index) { 85 | return this.word_bitmaps[index]; 86 | } 87 | 88 | score(value) { 89 | return this.scores[value]; 90 | } 91 | 92 | index(length) { 93 | var val = this.len_idx.get(length); 94 | if (val === undefined) { 95 | val = this.words.length; 96 | } 97 | return val; 98 | } 99 | } 100 | 101 | class Cell { 102 | constructor(cell_id, value) { 103 | this.value = value; 104 | this.cell_id = cell_id; 105 | this.resetValid(); 106 | } 107 | 108 | resetValid() { 109 | if (this.value != UNFILLED_CHAR) { 110 | this.valid_letters = 1 << char_to_bitmap(this.value); 111 | } else { 112 | this.valid_letters = ALL_CHARS_BITMAP; 113 | } 114 | } 115 | 116 | set(value) { 117 | this.value = value; 118 | this.resetValid(); 119 | } 120 | 121 | validLettersString() { 122 | var valid = []; 123 | for (var i=0; i < ALL_CHARS.length; i++) { 124 | var ch = ALL_CHARS[i]; 125 | if ((1 << char_to_bitmap(ch)) & this.valid_letters) { 126 | valid.push(ch); 127 | } 128 | } 129 | return valid.join(""); 130 | } 131 | 132 | checkpoint() { 133 | return [this.value, this.valid_letters]; 134 | } 135 | 136 | restore(state) { 137 | this.value = state[0]; 138 | this.valid_letters = state[1]; 139 | } 140 | 141 | cross(e) { 142 | if (e == this.across_entry) { 143 | return this.down_entry; 144 | } 145 | return this.across_entry; 146 | } 147 | 148 | setEntry(e, direction) { 149 | if (direction === DIR_ACROSS) { 150 | this.across_entry = e; 151 | } else { 152 | this.down_entry = e; 153 | } 154 | } 155 | 156 | getValidLetters() { 157 | return this.valid_letters; 158 | } 159 | 160 | entry(direction) { 161 | return (direction === DIR_DOWN) ? this.down_entry : this.across_entry; 162 | } 163 | 164 | applyMask(valid_bitmap) { 165 | this.valid_letters &= valid_bitmap; 166 | } 167 | 168 | getValue() { 169 | return this.value; 170 | } 171 | 172 | getId() { 173 | return this.cell_id; 174 | } 175 | } 176 | 177 | class Entry { 178 | constructor(cells, direction, wordlist, grid) { 179 | this.cells = cells; 180 | this.direction = direction; 181 | this.wordlist = wordlist; 182 | this.grid = grid; 183 | this.fill_index = 0; 184 | 185 | this.resetDict(); 186 | this.satisfy(); 187 | } 188 | 189 | resetDict() { 190 | var start = this.wordlist.index(this.cells.length); 191 | var end = this.wordlist.index(this.cells.length + 1); 192 | this.valid_words = []; 193 | for (var i = 0; i < end-start; i++) { 194 | this.valid_words.push(start + i); 195 | } 196 | } 197 | 198 | randomize(amt) { 199 | if (amt <= 0.0 || amt > 1.0) 200 | return; 201 | 202 | for (let i = 0; i < amt * this.valid_words.length; i++) { 203 | var x = Math.floor(Math.random() * this.valid_words.length); 204 | var y = Math.floor(Math.random() * this.valid_words.length); 205 | var tmp = this.valid_words[x]; 206 | this.valid_words[x] = this.valid_words[y]; 207 | this.valid_words[y] = tmp; 208 | } 209 | } 210 | 211 | checkpoint() { 212 | return [this.valid_words.slice(), this.fill_index]; 213 | } 214 | 215 | restore(state) { 216 | this.valid_words = state[0]; 217 | this.fill_index = state[1]; 218 | } 219 | 220 | cellPattern() { 221 | var pattern = ""; 222 | for (var i = 0; i < this.cells.length; i++) { 223 | pattern += this.cells[i].getValue().toLowerCase(); 224 | } 225 | return pattern; 226 | } 227 | 228 | bitPattern() { 229 | var pattern = []; 230 | for (var i = 0; i < this.cells.length; i++) { 231 | pattern.push(this.cells[i].getValidLetters()); 232 | } 233 | return pattern; 234 | } 235 | 236 | completed() { 237 | var pattern = this.cellPattern(); 238 | return pattern.indexOf(UNFILLED_CHAR) === -1; 239 | } 240 | 241 | cellIndex(cell_id) { 242 | for (var i = 0; i < this.cells.length; i++) { 243 | if (this.cells[i].getId() == cell_id) 244 | return i; 245 | } 246 | return -1; 247 | } 248 | 249 | recomputeValidLetters() { 250 | var fills; 251 | if (this.completed()) { 252 | fills = [this.bitPattern()]; 253 | } else { 254 | fills = []; 255 | for (var i = 0; i < this.valid_words.length; i++) { 256 | fills.push(this.wordlist.bitmap_at(this.valid_words[i])); 257 | } 258 | for (i = 0; i < this.cells.length; i++) { 259 | var valid_letters = 0; 260 | for (var j = 0; j < fills.length; j++) { 261 | valid_letters |= fills[j][i]; 262 | } 263 | this.cells[i].applyMask(valid_letters); 264 | } 265 | } 266 | } 267 | 268 | fill() { 269 | if (this.fill_index >= this.valid_words.length) { 270 | return null; 271 | } 272 | 273 | var fill = this.wordlist.at(this.valid_words[this.fill_index]); 274 | for (var i = 0; i < fill.length; i++) { 275 | this.cells[i].set(fill.charAt(i)); 276 | } 277 | return fill; 278 | } 279 | 280 | satisfy() { 281 | var pattern = this.bitPattern(); 282 | 283 | var orig_len = this.valid_words.length; 284 | var new_valid = []; 285 | for (var i = 0; i < orig_len; i++) { 286 | var word = this.wordlist.at(this.valid_words[i]); 287 | 288 | if (this.grid.isUsed(word)) { 289 | continue; 290 | } 291 | 292 | if (word.length != pattern.length) { 293 | continue; 294 | } 295 | 296 | var skip = false; 297 | var bitmap = this.wordlist.bitmap_at(this.valid_words[i]); 298 | for (var j = 0; j < pattern.length; j++) { 299 | if (!(pattern[j] & bitmap[j])) { 300 | skip = true; 301 | break; 302 | } 303 | } 304 | if (skip) { 305 | continue; 306 | } 307 | 308 | new_valid.push(this.valid_words[i]); 309 | } 310 | this.valid_words = new_valid; 311 | this.recomputeValidLetters(); 312 | return orig_len != this.valid_words.length; 313 | } 314 | 315 | nextWord() { 316 | this.fill_index += 1; 317 | } 318 | 319 | numFills() { 320 | if (this.completed()) { 321 | return 1; 322 | } 323 | return this.valid_words.length; 324 | } 325 | 326 | fills() { 327 | var self = this; 328 | return this.valid_words.map(function(i) { 329 | var word = self.wordlist.at(i); 330 | var score = self.wordlist.score(word); 331 | return [word, '' + score]; 332 | }); 333 | } 334 | } 335 | 336 | class StackLevel { 337 | } 338 | 339 | class Grid { 340 | constructor(template, wordlist) { 341 | var rows = template.trim().split("\n"); 342 | this.height = rows.length; 343 | this.width = rows[0].length; 344 | this.cells = []; 345 | this.entries = []; 346 | this.used_words = new Set(); 347 | 348 | for (var y = 0; y < this.height; y++) { 349 | for (var x = 0; x < this.width; x++) { 350 | var cell_id = y * this.width + x; 351 | 352 | this.cells.push(new Cell(cell_id, rows[y][x])); 353 | } 354 | } 355 | 356 | for (var dir = DIR_ACROSS; dir <= DIR_DOWN; dir++) { 357 | 358 | var xincr = (dir == DIR_ACROSS) ? 1 : 0; 359 | var yincr = (dir == DIR_DOWN) ? 1 : 0; 360 | 361 | for (let y = 0; y < this.height; y++) { 362 | for (let x = 0; x < this.width; x++) { 363 | 364 | var is_block = rows[y][x] === BLOCK_CHAR; 365 | var start_of_row = (dir == DIR_ACROSS && x == 0) || 366 | (dir == DIR_DOWN && y == 0); 367 | 368 | var start_of_entry = ((!is_block) && 369 | // previous character was '#' or start of line? 370 | (start_of_row || rows[y - yincr][x - xincr] == BLOCK_CHAR) && 371 | // next character not '#'?, i.e. exclude unchecked squares 372 | (x + xincr < this.width && y + yincr < this.height && 373 | rows[y + yincr][x + xincr] != BLOCK_CHAR)); 374 | 375 | if (!start_of_entry) 376 | continue; 377 | 378 | var cell_list = []; 379 | var [xt, yt] = [x, y]; 380 | for (; xt < this.width && yt < this.height; xt += xincr, yt += yincr) { 381 | if (rows[yt][xt] == BLOCK_CHAR) 382 | break; 383 | 384 | cell_list.push(this.cells[yt * this.width + xt]); 385 | } 386 | var entry = new Entry(cell_list, dir, wordlist, this); 387 | this.entries.push(entry); 388 | 389 | for (var i=0; i < cell_list.length; i++) { 390 | cell_list[i].setEntry(entry, dir); 391 | } 392 | } 393 | } 394 | } 395 | this.satisfyAll(); 396 | } 397 | 398 | satisfyAll() { 399 | var changed = true; 400 | while (changed) { 401 | changed = false; 402 | for (var i = 0; i < this.entries.length; i++) { 403 | var this_changed = this.entries[i].satisfy(); 404 | changed = changed || this_changed; 405 | } 406 | } 407 | } 408 | 409 | randomize(amt) { 410 | for (var i = 0; i < this.entries.length; i++) { 411 | this.entries[i].randomize(amt); 412 | } 413 | } 414 | 415 | isUsed(word) { 416 | return this.used_words.has(word); 417 | } 418 | 419 | getNextFillVictim() { 420 | var nfills = -1; 421 | var best = this.entries[0]; 422 | for (var i = 0; i < this.entries.length; i++) { 423 | var entry = this.entries[i]; 424 | if (entry.completed()) 425 | continue; 426 | 427 | var this_fills = this.entries[i].numFills(); 428 | if (nfills == -1 || this_fills < nfills) { 429 | best = entry; 430 | nfills = this_fills; 431 | } 432 | } 433 | return best; 434 | } 435 | 436 | numFills() { 437 | var fills = this.entries.map(function(x) { 438 | return x.numFills(); 439 | }); 440 | var sum = 0; 441 | for (var i = 0; i < fills.length; i++) { 442 | if (!fills[i]) 443 | return 0; 444 | sum += fills[i]; 445 | } 446 | if (sum == fills.length) 447 | return 1; 448 | 449 | return sum; 450 | } 451 | 452 | getFills(x, y, direction) { 453 | // get entry from position 454 | if (x < 0 || x >= this.width || y < 0 || y >= this.height || 455 | direction < DIR_ACROSS || direction > DIR_DOWN) { 456 | throw "out of range"; 457 | } 458 | 459 | var cell = this.cells[this.width * y + x]; 460 | var entry = cell.entry(direction); 461 | 462 | if (!entry) 463 | return []; 464 | 465 | return entry.fills(); 466 | } 467 | 468 | getCellLetters(x, y) { 469 | if (x < 0 || x >= this.width || y < 0 || y >= this.height) { 470 | throw "out of range"; 471 | } 472 | 473 | var cell_id = this.width * y + x; 474 | var cell = this.cells[cell_id]; 475 | var across_entry = cell.entry(0); 476 | var down_entry = cell.entry(1); 477 | var result = new Map(); 478 | 479 | for (const entry of [across_entry, down_entry]) { 480 | if (!entry) 481 | continue; 482 | 483 | var alphact = new Map(); 484 | var offset = entry.cellIndex(cell_id); 485 | for (const f of entry.fills()) { 486 | var alpha = f[0].charAt(offset); 487 | var ct = alphact.get(alpha) || 0; 488 | alphact.set(alpha, ct + 1); 489 | } 490 | for (var [k, v] of alphact.entries()) { 491 | // $FlowFixMe 492 | if (!result.has(k) || result.get(k) > v) { 493 | result.set(k, v); 494 | } 495 | } 496 | } 497 | return result; 498 | } 499 | 500 | fillStep(stack) { 501 | var stackLevel = stack.pop(); 502 | 503 | // if we already filled at this level, restore pre-fill state 504 | // and advance to next word 505 | if (stackLevel.saved_entries) { 506 | stackLevel.saved_cells.map(function(saved) { 507 | saved[0].restore(saved[1]); 508 | }); 509 | stackLevel.saved_entries.map(function(saved) { 510 | saved[0].restore(saved[1]); 511 | }); 512 | this.used_words.delete(stackLevel.filled_word); 513 | stackLevel.entry.nextWord(); 514 | this.satisfyAll(); 515 | } 516 | 517 | // finished? 518 | var num_fills = this.numFills(); 519 | if (num_fills == 1) { 520 | this.entries.forEach(function(e) { 521 | if (!e.completed()) { 522 | e.fill(); 523 | } 524 | }); 525 | return true; 526 | } 527 | 528 | // backtrack? 529 | if (num_fills == 0) { 530 | return false; 531 | } 532 | 533 | stackLevel.saved_entries = this.entries.map(function(e) { 534 | return [e, e.checkpoint()]; 535 | }); 536 | stackLevel.saved_cells = this.cells.map(function(c) { 537 | return [c, c.checkpoint()]; 538 | }); 539 | 540 | // fill next best word 541 | var fill = stackLevel.entry.fill(); 542 | if (!fill) { 543 | return false; 544 | } 545 | 546 | stackLevel.filled_word = fill; 547 | this.used_words.add(fill); 548 | 549 | // fill next level down 550 | stack.push(stackLevel); 551 | stackLevel = new StackLevel(); 552 | this.satisfyAll(); 553 | stackLevel.entry = this.getNextFillVictim(); 554 | stack.push(stackLevel); 555 | return false; 556 | } 557 | 558 | // return [stack, done] 559 | fillOne(stack) { 560 | 561 | if (!stack) { 562 | stack = []; 563 | 564 | var stackLevel = new StackLevel(); 565 | this.satisfyAll(); 566 | stackLevel.entry = this.getNextFillVictim(); 567 | stack.push(stackLevel); 568 | } 569 | 570 | if (!stack.length) 571 | return [stack, true]; 572 | 573 | var result = this.fillStep(stack); 574 | return [stack, result]; 575 | } 576 | 577 | fillAsync(callback, state) { 578 | var newstate, done; 579 | var self = this; 580 | [newstate, done] = this.fillOne(state); 581 | callback(this.toString(), function() { 582 | if (!done) { 583 | self.fillAsync(callback, newstate); 584 | } 585 | }); 586 | } 587 | 588 | fill() { 589 | 590 | var stack = []; 591 | 592 | var stackLevel = new StackLevel(); 593 | this.satisfyAll(); 594 | stackLevel.entry = this.getNextFillVictim(); 595 | stack.push(stackLevel); 596 | 597 | while (stack.length) { 598 | if (this.fillStep(stack)) 599 | return 1; 600 | console.log("=>\n" + this.toString()); 601 | } 602 | return 0; 603 | } 604 | 605 | toString() { 606 | var self = this; 607 | return this.cells.map(function(c) { 608 | var str = ''; 609 | if ((c.getId() % self.width) == 0) 610 | str += "\n"; 611 | str += c.getValue(); 612 | return str; 613 | }).join("").trim(); 614 | } 615 | } 616 | 617 | class Filler { 618 | constructor(template, wordlist) { 619 | this.grid = new Grid(template, wordlist); 620 | this.wordlist = wordlist; 621 | } 622 | 623 | updateGrid(template) { 624 | this.grid = new Grid(template, this.wordlist); 625 | } 626 | 627 | fillAsync(randomize, callback) { 628 | this.grid.randomize(randomize); 629 | this.grid.fillAsync(callback); 630 | } 631 | 632 | fill() { 633 | this.grid.fill(); 634 | return this.grid.toString(); 635 | } 636 | 637 | getFills(x, y, direction) { 638 | return this.grid.getFills(x, y, direction); 639 | } 640 | 641 | getCellLetters(x, y) { 642 | return this.grid.getCellLetters(x, y); 643 | } 644 | 645 | estimatedFills() { 646 | return this.grid.numFills(); 647 | } 648 | } 649 | 650 | module.exports = { 651 | filler: Filler, 652 | wordlist: Wordlist 653 | }; 654 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background-color: white; 3 | --primary-color: black; 4 | --secondary-color: rgb(68, 68, 68); 5 | --tertiary-color: rgb(153, 153, 153); 6 | --quaternary-color: rgb(248, 248, 248); 7 | --shade-color: rgb(190, 190, 190); 8 | --highlight-color: rgba(222, 241, 255, 0.7); 9 | --highlight-active-color: rgba(222, 241, 255, 0.5); 10 | --highlight-border-color: rgb(85, 184, 254); 11 | --highlight-heavy-color: rgb(1, 129, 220); 12 | --highlight-mono-color: rgba(237, 237, 237, 0.7); 13 | --highlight-active-mono-color: rgba(237, 237, 237, 0.5); 14 | --highlight-heavy-mono-color: rgb(101, 101, 101); 15 | --highlight-chart-hover-color: rgb(100, 183, 245); 16 | --toolbar-shadow-color: rgba(0, 0, 0, 0.15); 17 | --border-trans-color: rgba(255, 255, 255, 0.1); 18 | --grid-block-color: black; 19 | --grid-border-color: black; 20 | --grid-sat-color: rgb(0, 255, 0); 21 | --grid-unsat-color: rgb(255, 51, 51); 22 | --chart-label-zero-color: rgb(145, 17, 10); 23 | --font-family-sans: -apple-system, BlinkMacSystemFont, "Helvetica", "Roboto", "Droid Sans", "Arial", sans-serif; 24 | --font-family-mono: "SFMono-Regular", "Consolas", "DejaVuSansMono", "Menlo", monospace; 25 | } 26 | 27 | [data-theme="dark"] { 28 | --background-color: rgb(40, 44, 52); 29 | --primary-color: rgba(171, 178, 191); 30 | --secondary-color: rgb(157, 165, 179); 31 | --tertiary-color: rgb(116, 123, 133); 32 | --quaternary-color: rgb(27, 29, 35); 33 | --shade-color: rgb(96, 103, 116); 34 | --highlight-color: rgba(89, 147, 255, 0.6); 35 | --highlight-active-color: rgba(90, 135, 220, 0.4); 36 | --highlight-border-color: rgb(61, 61, 227); 37 | --highlight-heavy-color: rgb(50, 50, 255); 38 | --highlight-mono-color: rgba(62, 68, 80, 0.7); 39 | --highlight-active-mono-color: rgba(62, 68, 80, 0.5); 40 | --highlight-heavy-mono-color: rgb(58, 63, 74); 41 | --highlight-chart-hover-color: rgb(22, 70, 158); 42 | --toolbar-shadow-color: rgba(0, 0, 0, 0.15); 43 | --border-trans-color: rgba(255, 255, 255, 0.1); 44 | --grid-block-color: rgb(27, 29, 35); 45 | --grid-border-color: rgb(27, 29, 35); 46 | } 47 | 48 | body { 49 | font-family: var(--font-family-sans); 50 | /* width: auto; */ 51 | margin: 5px; 52 | background: var(--background-color); 53 | color: var(--primary-color); 54 | } 55 | 56 | h1 { 57 | font-size: 20pt; 58 | font-weight: 300; 59 | color: var(--tertiary-color); 60 | } 61 | 62 | #credits { 63 | font-size: 9.5pt; 64 | font-weight: 400; 65 | color: var(--tertiary-color); 66 | margin-top: 20; 67 | text-align: center; 68 | } 69 | 70 | #credits a { 71 | font-weight: 700; 72 | color: var(--tertiary-color); 73 | text-decoration: none; 74 | } 75 | 76 | #grid { 77 | table-layout: fixed; 78 | border: 2px solid var(--grid-border-color); 79 | border-collapse: collapse; 80 | /*border-spacing: 0;*/ 81 | text-align: center; 82 | font-size: 15pt; 83 | font-weight: 300; 84 | -webkit-user-select: none; 85 | -moz-user-select: none; 86 | user-select: none; 87 | } 88 | 89 | #grid:focus { 90 | outline: 2px solid var(--highlight-border-color); 91 | } 92 | 93 | #grid.sat:focus { 94 | /*outline: 2px solid var(--grid-sat-color);*/ 95 | } 96 | 97 | #grid.unsat:focus { 98 | outline: 3px solid var(--grid-unsat-color); 99 | } 100 | 101 | /* #grid td { 102 | width: 35px; 103 | height: 36px; 104 | position: relative; 105 | border-left: 1px solid var(--grid-border-color); 106 | border-top: 1px solid var(--grid-border-color); 107 | padding-top: 6px; 108 | } */ 109 | 110 | #grid td.active { 111 | box-shadow: 0 0 12px 0px var(--highlight-heavy-mono-color); 112 | -moz-box-shadow: 0 0 12px 0px var(--highlight-heavy-mono-color); 113 | -webkit-box-shadow: 0 0 12px 0px var(--highlight-heavy-mono-color); 114 | opacity: 0.8; 115 | font-weight: 400; 116 | z-index: 300; 117 | background: var(--highlight-active-mono-color); 118 | } 119 | 120 | #grid:focus td.active { 121 | box-shadow: 0 0 12px 0px var(--highlight-heavy-color); 122 | -moz-box-shadow: 0 0 12px 0px var(--highlight-heavy-color); 123 | -webkit-box-shadow: 0 0 12px 0px var(--highlight-heavy-color); 124 | border-style: double; 125 | border: 1px solid var(--highlight-border-color); 126 | background: var(--highlight-active-color); 127 | } 128 | 129 | #grid:focus td.active .circle { 130 | border: 1px solid var(--highlight-border-color); 131 | } 132 | 133 | #grid td.pencil .fill { 134 | /*opacity: 0.3;*/ 135 | color: var(--tertiary-color); 136 | } 137 | 138 | #grid td.block { 139 | background-color: var(--grid-block-color) !important; 140 | color: var(--grid-block-color); 141 | } 142 | 143 | #grid td.highlight { 144 | background-color: var(--highlight-mono-color); 145 | font-weight: 400; 146 | } 147 | 148 | #grid:focus td.highlight { 149 | border-style: double; 150 | border: 1px solid var(--highlight-border-color); 151 | background-color: var(--highlight-color); 152 | } 153 | 154 | #grid:focus td.highlight .circle { 155 | border: 1px solid var(--highlight-border-color); 156 | } 157 | 158 | #grid td.lowlight { 159 | /*background-color: var(--quaternary-color);*/ 160 | } 161 | 162 | #grid:focus td.lowlight { 163 | /*background-color: var(--quaternary-color);*/ 164 | } 165 | 166 | #grid td.highlight-chart-hover { 167 | border: 1px solid var(--highlight-heavy-color) !important; 168 | background: var(--highlight-chart-hover-color) !important; 169 | } 170 | 171 | #header { 172 | /*width: 885px;*/ 173 | margin-left: 38px; 174 | min-height: 36px; 175 | } 176 | 177 | #header h1 { 178 | margin: 0; 179 | margin-bottom: 2px; 180 | } 181 | 182 | .hidden { 183 | display: none; 184 | } 185 | 186 | kbd { 187 | text-align: center; 188 | font-family: inherit; 189 | font-size: 9pt; 190 | font-weight: 600; 191 | color: var(--quaternary-color); 192 | border: 1px solid var(--tertiary-color); 193 | border-radius: 2px; 194 | padding: 2px 8px; 195 | margin: -2px 2px; 196 | } 197 | 198 | /* #grid-container { 199 | width: auto; 200 | float: left; 201 | margin-bottom: 10px; 202 | } */ 203 | 204 | .notification { 205 | font-size: 9.5pt; 206 | line-height: 11pt; 207 | padding: 12px; 208 | border-radius: 6px; 209 | background-color: var(--secondary-color); 210 | color: var(--quaternary-color); 211 | position: absolute; 212 | top: 50%; 213 | left: 50%; 214 | transform: translate(-50%, -50%); 215 | } 216 | 217 | .notification h3 { 218 | font-size: 9pt; 219 | margin: 0; 220 | padding: 0; 221 | font-weight: 600; 222 | color: var(--tertiary-color); 223 | text-transform: uppercase; 224 | text-align: center; 225 | } 226 | 227 | .notification table { 228 | border-collapse: collapse; 229 | } 230 | 231 | .notification tr { 232 | padding: 0; 233 | margin: 0; 234 | color: var(--quaternary-color); 235 | font-size: 9.5pt; 236 | } 237 | 238 | .notification td { 239 | padding: 8px 5px 8px 0; 240 | border-bottom: 1px solid var(--border-trans-color); 241 | } 242 | 243 | /*.notification:after { 244 | content: '\f00d'; 245 | margin-left: 10px; 246 | font-family: FontAwesome; 247 | font-weight: 600; 248 | color: var(--tertiary-color); 249 | cursor: pointer; 250 | }*/ 251 | 252 | .notification:hover:after { 253 | color: var(--highlight-color); 254 | content: '\f00d'; 255 | /* float: right; */ 256 | position: absolute; 257 | top: 10px; 258 | right: 10px; 259 | /* margin-left: 10px; */ 260 | 261 | font-family: FontAwesome; 262 | font-weight: 600; 263 | cursor: pointer; 264 | } 265 | 266 | #puzzle-author { 267 | margin: 0; 268 | width: auto; 269 | } 270 | 271 | #puzzle-title { 272 | margin: 0; 273 | width: auto; 274 | max-width: 400px; 275 | overflow: hidden; 276 | font-weight: 500; 277 | color: var(--primary-color); 278 | } 279 | 280 | #sidebar { 281 | width: 220px; 282 | height: 550px; 283 | float: left; 284 | margin-left: 8px; 285 | } 286 | 287 | #sidebar .current-word { 288 | font-family: var(--font-family-mono); 289 | margin: 0; 290 | /* font-size: 21pt; */ 291 | font-weight: 300; 292 | } 293 | 294 | #sidebar .direction-heading { 295 | padding: 3px 0 2px 0; 296 | border-top: 1px solid var(--background-color); 297 | border-bottom: 1px solid var(--background-color); 298 | 299 | } 300 | 301 | #sidebar .direction-heading.highlight { 302 | border-top: 1px solid var(--highlight-border-color); 303 | border-bottom: 1px solid var(--highlight-border-color); 304 | } 305 | 306 | #sidebar ul.matches { 307 | font-family: var(--font-family-mono); 308 | margin: 0; 309 | height: 84%; 310 | /*width: 160px;*/ 311 | list-style-type: none; 312 | padding: 0; 313 | color: var(--tertiary-color); 314 | font-weight: 400; 315 | overflow-x: auto; 316 | overflow-y: auto; 317 | } 318 | 319 | #sidebar ul.matches li { 320 | margin: 2px 0; 321 | } 322 | 323 | #sidebar ul.matches li:hover { 324 | background: var(--quaternary-color); /* For browsers that do not support gradients */ 325 | color: var(--primary-color); 326 | font-weight: 500; 327 | } 328 | 329 | #toolbar { 330 | clear: both; 331 | width: 32px; 332 | margin: 0; 333 | padding: 0; 334 | margin-right: 6px; 335 | float: left; 336 | } 337 | 338 | #toolbar .section { 339 | cursor: pointer; 340 | } 341 | 342 | #toolbar button { 343 | width: 32px; 344 | height: 32px; 345 | position: relative; 346 | border-radius: 4px; 347 | background-color: var(--background-color); 348 | border: none; 349 | color: var(--secondary-color); 350 | text-align: center; 351 | font-size: 14pt; 352 | padding: 6px 4px; 353 | cursor: pointer; 354 | /*transition: all 0.1s;*/ 355 | /* margin: 2px 0; */ 356 | } 357 | 358 | #toolbar button:focus { 359 | outline: none; 360 | } 361 | 362 | #toolbar button.button-on { 363 | color: var(--highlight-heavy-color); 364 | } 365 | 366 | #toolbar button.default { 367 | /*border: 1px solid var(--tertiary-color);*/ 368 | color: var(--highlight-heavy-color); 369 | } 370 | 371 | #toolbar button.default:after { 372 | color: var(--highlight-heavy-color); 373 | font-weight: 600; 374 | } 375 | 376 | #toolbar button:hover { 377 | background-color: var(--highlight-color); 378 | } 379 | 380 | #toolbar button:hover:after { 381 | content: attr(data-tooltip); 382 | margin: 4px 0; 383 | padding: 4px; 384 | border-radius: 2px; 385 | background: var(--secondary-color); 386 | color: var(--background-color); 387 | font-size: 9pt; 388 | /*font-weight: 400;*/ 389 | position: absolute; 390 | left: 110%; 391 | top: 0; 392 | white-space: nowrap; 393 | z-index: 20; 394 | } 395 | 396 | #toolbar button.disabled { 397 | color: var(--tertiary-color); 398 | } 399 | #toolbar button.disabled:hover { 400 | background-color: var(--background-color); 401 | } 402 | 403 | #toolbar .divider { 404 | width: 32px; 405 | border-bottom: 1px solid var(--tertiary-color); 406 | margin: 0; 407 | padding: 0; 408 | margin-bottom: 2px; 409 | padding-bottom: 2px; 410 | } 411 | 412 | #toolbar .menu { 413 | color: var(--secondary-color); 414 | margin: -6px; 415 | padding: 6px; 416 | min-width: 200px; 417 | background: var(--background-color); 418 | position: absolute; 419 | left: 45px; 420 | z-index: 400; 421 | border-top: 3px solid var(--secondary-color); 422 | box-shadow: 3px 3px 6px 0px var(--toolbar-shadow-color); 423 | -moz-box-shadow: 3px 3px 6px 0px var(--toolbar-shadow-color); 424 | -webkit-box-shadow: 3px 3px 6px 0px var(--toolbar-shadow-color); 425 | } 426 | 427 | #toolbar #toggle-special-menu { 428 | min-width: 130px; 429 | } 430 | 431 | #toolbar .menu h4 { 432 | margin: 0; 433 | padding: 0; 434 | font-size: 9pt; 435 | font-weight: 500; 436 | /*color: var(--secondary-color);*/ 437 | } 438 | 439 | #toolbar .menu button { 440 | display: block; 441 | clear: both; 442 | } 443 | 444 | #new-grid-custom { 445 | display:inline !important; 446 | } 447 | 448 | #new-grid-custom:after { 449 | min-width: 50px !important; 450 | } 451 | 452 | #custom-grid-form { 453 | margin: 0px 0px 0px 60px; 454 | display: inline; 455 | } 456 | 457 | #custom-grid-form input { 458 | width: 35px; 459 | } 460 | 461 | #toolbar .menu button:after { 462 | content: attr(data-tooltip); 463 | margin: 0px 0 4px -8px; 464 | padding: 8px 4px 9px 8px; 465 | min-width: 160px; 466 | text-align: left; 467 | border-radius: 4px; 468 | /*color: var(--secondary-color);*/ 469 | font-size: 9pt; 470 | position: absolute; 471 | left: 110%; 472 | top: 0; 473 | white-space: nowrap; 474 | } 475 | 476 | #toolbar #toggle-special-menu button:after { 477 | min-width: 90px; 478 | } 479 | 480 | #toolbar .menu button:hover:after { 481 | color: inherit; 482 | background: var(--highlight-color); 483 | } 484 | 485 | .clue { 486 | margin: 0; 487 | padding: 0; 488 | font-size: 12pt; 489 | font-weight: 600; 490 | letter-spacing: -0.03em; 491 | } 492 | 493 | .clue-number { 494 | } 495 | 496 | .editable:hover { 497 | background-color: var(--highlight-color); 498 | } 499 | 500 | .editable:focus { 501 | outline: none; 502 | border-bottom: 2px solid var(--highlight-border-color); 503 | } 504 | 505 | .half-sidebar { 506 | height: 50%; 507 | } 508 | 509 | .label { 510 | font-size: 8pt; 511 | font-weight: 500; 512 | position: absolute; 513 | left: 2px; 514 | top: 0px; 515 | } 516 | 517 | #matches-charts { 518 | float: left; 519 | height: 550px; 520 | } 521 | 522 | #matches-charts .chart { 523 | display: block; 524 | border-top: 1px solid var(--background-color); 525 | } 526 | 527 | #matches-charts .chart.highlight { 528 | border-top: 1px solid var(--highlight-border-color); 529 | } 530 | 531 | #stats { 532 | float: left; 533 | margin-left: 8px; 534 | } 535 | 536 | #stats .chart { 537 | float: left; 538 | } 539 | 540 | #stats .chart-title { 541 | text-align: left; 542 | color: var(--secondary-color); 543 | font-weight: 300; 544 | margin-left: 6px; 545 | } 546 | 547 | #stats table { 548 | color: var(--secondary-color); 549 | font-weight: 300; 550 | font-size: 12px; 551 | line-height: 1.42; 552 | } 553 | 554 | #stats .stats-number { 555 | font-family: var(--font-family-mono); 556 | font-size: 11; 557 | font-weight: 100; 558 | vertical-align: bottom; 559 | } 560 | 561 | #mobile-keyboard { 562 | width: 100vw; 563 | display: flex; 564 | flex-direction: column; 565 | background-color: var(--highlight-mono-color); 566 | flex-shrink: 0; 567 | height: 30vw; 568 | position: absolute; 569 | bottom: 0px; 570 | border-radius: 0px; 571 | } 572 | 573 | #mobile-keyboard .row { 574 | display: flex; 575 | justify-content: center; 576 | } 577 | 578 | #mobile-keyboard .key { 579 | height: 10vw; 580 | width: 10vw; 581 | line-height: 10vw; 582 | text-align: center; 583 | vertical-align: middle; 584 | background-color: var(--background-color); 585 | border-radius: 20px; 586 | } 587 | 588 | .rebus { 589 | font-size: 6px; 590 | transform: scale(1.2, 3); 591 | font-weight: 500; 592 | } 593 | 594 | #enter-rebus-menu h4 { 595 | display: inline; 596 | } 597 | 598 | #enter-rebus-form { 599 | margin-left: 10px; 600 | display: inline; 601 | } 602 | 603 | .rebus-input { 604 | text-transform: uppercase; 605 | } 606 | 607 | .circle { 608 | background-color: rgba(0, 0, 0, 0); 609 | border-radius: 50%; 610 | border: 1px solid var(--primary-color);; 611 | position: absolute; 612 | top: -1px; 613 | left: -1px; 614 | } 615 | 616 | .shade { 617 | background-color: var(--shade-color); 618 | position: absolute; 619 | top: 0px; 620 | left: 0px; 621 | z-index: -1; 622 | } 623 | 624 | @media (hover: hover) and (pointer: fine) { 625 | body { 626 | width: auto; 627 | } 628 | #mobile-keyboard { 629 | display: none; 630 | } 631 | #grid-container { 632 | width: auto; 633 | float: left; 634 | margin-bottom: 10px; 635 | } 636 | #grid td { 637 | width: 35px; 638 | height: 36px; 639 | position: relative; 640 | border-left: 1px solid var(--grid-border-color); 641 | border-top: 1px solid var(--grid-border-color); 642 | padding-top: 6px; 643 | } 644 | .circle { 645 | width: 37px; 646 | height: 35px; 647 | } 648 | .shade { 649 | width: 37px; 650 | height: 35px; 651 | } 652 | } 653 | 654 | @media not all and (hover: hover) and (pointer: fine) { 655 | #stats { 656 | display: none; 657 | } 658 | #matches-charts { 659 | display: none; 660 | } 661 | @media screen and (orientation: portrait) { 662 | body { 663 | margin: 0px; 664 | } 665 | #header { 666 | display: none; 667 | } 668 | 669 | /* Test/implement this for beginning of responsive mobile version: */ 670 | /* #grid { 671 | font-size: 7vw; 672 | 673 | background-color: var(--highlight-mono-color); 674 | border-style: solid; 675 | border-color: black; 676 | border-width: 1px 0px 0px 1px; 677 | border-collapse: collapse; 678 | } 679 | 680 | #grid tr { 681 | display: flex; 682 | justify-content: center; 683 | } 684 | 685 | #grid td { 686 | height: 10vw; 687 | width: 10vw; 688 | position: relative; 689 | line-height: 12vw; 690 | text-align: center; 691 | background-color: var(--background-color); 692 | border-style: solid; 693 | border-color: black; 694 | border-width: 0px 1px 1px 0px; 695 | overflow: hidden; 696 | } */ 697 | 698 | #grid td { 699 | width: 15px; 700 | height: 17px; 701 | position: relative; 702 | border-left: 1px solid var(--grid-border-color); 703 | border-top: 1px solid var(--grid-border-color); 704 | padding-top: 4px; 705 | font-size: 8px; 706 | } 707 | .circle { 708 | width: 17px; 709 | height: 16px; 710 | } 711 | .shade { 712 | width: 17px; 713 | height: 16px; 714 | } 715 | .label { 716 | font-size: 5px; 717 | left: 1px; 718 | } 719 | .rebus { 720 | font-size: 3px; 721 | transform: scale(1.2, 2); 722 | font-weight: 500; 723 | } 724 | #toolbar button { 725 | width: 27px; 726 | height: 27px; 727 | } 728 | .clue { 729 | margin: 0; 730 | padding: 0; 731 | font-size: 12px; 732 | font-weight: 600; 733 | letter-spacing: -0.03em; 734 | } 735 | #sidebar { 736 | width: 85vw; 737 | height: 30vh; 738 | font-size: 10px; 739 | /* float: none; */ 740 | margin-left: 0px; 741 | } 742 | .half-sidebar { 743 | height: 100%; 744 | display: inline-block; 745 | width: 48%; 746 | vertical-align: top; 747 | } 748 | } 749 | } 750 | -------------------------------------------------------------------------------- /files.js: -------------------------------------------------------------------------------- 1 | // Modified by jmviz. Original notice follows: 2 | // 3 | // Phil 4 | // ------------------------------------------------------------------------ 5 | // Copyright 2017 Keiran King 6 | 7 | // Licensed under the Apache License, Version 2.0 (the "License"); 8 | // you may not use this file except in compliance with the License. 9 | // (https://www.apache.org/licenses/LICENSE-2.0) 10 | 11 | // Unless required by applicable law or agreed to in writing, software 12 | // distributed under the License is distributed on an "AS IS" BASIS, 13 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | // See the License for the specific language governing permissions and 15 | // limitations under the License. 16 | // ------------------------------------------------------------------------ 17 | 18 | class ScrambledError extends Error { 19 | constructor(...params) { 20 | super(...params); 21 | if (Error.captureStackTrace) { 22 | Error.captureStackTrace(this, ScrambledError); 23 | } 24 | this.name = "ScrambledError"; 25 | this.message = "Cannot open scrambled Across Lite files"; 26 | } 27 | } 28 | 29 | class PuzReader { 30 | constructor(buf) { 31 | this.buf = buf; 32 | } 33 | 34 | readShort(ix) { 35 | return this.buf[ix] | (this.buf[ix + 1] << 8); 36 | } 37 | 38 | readString() { 39 | let result = []; 40 | while (true) { 41 | let c = this.buf[this.ix++]; 42 | if (c == 0) break; 43 | result.push(String.fromCodePoint(c)); 44 | } 45 | return result.join(''); 46 | } 47 | 48 | isExtra() { 49 | return this.ix + 8 <= this.buf.length; 50 | } 51 | 52 | readExtra() { 53 | let title = String.fromCharCode.apply(null, this.buf.slice(this.ix, this.ix + 4)); 54 | this.ix += 4; 55 | let length = this.readShort(this.ix); 56 | this.ix += 4; // skip over cksum 57 | let data = this.buf.slice(this.ix, this.ix + length); 58 | this.ix += length + 1; 59 | if (title == "RTBL") { 60 | return {[title]: String.fromCharCode.apply(null, data)}; 61 | } else { 62 | return {[title]: data}; 63 | } 64 | } 65 | 66 | toJson() { 67 | let json = {}; 68 | let w = this.buf[0x2c]; 69 | let h = this.buf[0x2d]; 70 | let scrambled = this.readShort(0x32); 71 | if (scrambled & 0x0004) { 72 | throw new ScrambledError(); 73 | } 74 | json.size = {cols: w, rows: h}; 75 | let grid = []; 76 | for (let i = 0; i < w * h; i++) { 77 | grid.push(String.fromCodePoint(this.buf[0x34 + i])); 78 | } 79 | this.ix = 0x34 + 2 * w * h; 80 | json.title = this.readString(); 81 | json.author = this.readString(); 82 | json.copyright = this.readString(); 83 | var across = []; 84 | var down = []; 85 | var label = 1; 86 | for (let i = 0; i < w * h; i++) { 87 | if (grid[i] == '.') continue; 88 | var inc = 0; 89 | if (i % w == 0 || grid[i - 1] == '.') { 90 | across.push(label + ". " + this.readString()); 91 | inc = 1; 92 | } 93 | if (i < w || grid[i - w] == '.') { 94 | down.push(label + ". " + this.readString()); 95 | inc = 1; 96 | } 97 | label += inc; 98 | } 99 | json.clues = {across: across, down: down}; 100 | json.notepad = this.readString(); 101 | let extras = {}; 102 | while (this.isExtra()){ 103 | Object.assign(extras, this.readExtra()); 104 | } 105 | // console.log(extras); 106 | if ("GEXT" in extras) { 107 | json.circles = extras.GEXT.map(c => c == 128); 108 | } 109 | if ("RTBL" in extras && "GRBS" in extras) { 110 | let rebusTable = {}; 111 | for (let entry of extras.RTBL.replace(/\s+/g, '').slice(0, -1).split(';')) { 112 | entry = entry.split(':'); 113 | rebusTable[entry[0]] = entry[1]; 114 | } 115 | for (let i = 0; i < extras.GRBS.length; i++) { 116 | if (extras.GRBS[i] != 0) { 117 | grid[i] = rebusTable[extras.GRBS[i] - 1]; 118 | } 119 | } 120 | } 121 | json.grid = grid; 122 | // console.log(json); 123 | return json; 124 | } 125 | } 126 | 127 | class PuzWriter { 128 | constructor() { 129 | this.buf = []; 130 | } 131 | 132 | pad(n) { 133 | for (var i = 0; i < n; i++) { 134 | this.buf.push(0); 135 | } 136 | } 137 | 138 | writeShort(x) { 139 | this.buf.push(x & 0xff, (x >> 8) & 0xff); 140 | } 141 | 142 | setShort(ix, x) { 143 | this.buf[ix] = x & 0xff; 144 | this.buf[ix + 1] = (x >> 8) & 0xff; 145 | } 146 | 147 | writeString(s) { 148 | if (s === undefined) s = ''; 149 | for (var i = 0; i < s.length; i++) { 150 | var cp = s.codePointAt(i); 151 | if (cp < 0x100 && cp > 0) { 152 | this.buf.push(cp); 153 | } else { 154 | // TODO: expose this warning through the UI 155 | console.log('string "' + s + '" has non-ISO-8859-1 codepoint at offset ' + i); 156 | this.buf.push('?'.codePointAt(0)); 157 | } 158 | if (cp >= 0x10000) i++; // advance by one codepoint 159 | } 160 | this.buf.push(0); 161 | } 162 | 163 | writeHeader(json) { 164 | this.pad(2); // placeholder for checksum 165 | this.writeString('ACROSS&DOWN'); 166 | this.pad(2); // placeholder for cib checksum 167 | this.pad(8); // placeholder for masked checksum 168 | this.version = '1.3'; 169 | this.writeString(this.version); 170 | this.pad(2); // probably extra space for version string 171 | this.writeShort(0); // scrambled checksum 172 | this.pad(12); // reserved 173 | this.w = json.size.cols; 174 | this.h = json.size.rows; 175 | this.buf.push(this.w); 176 | this.buf.push(this.h); 177 | this.numClues = json.clues.across.length + json.clues.down.length; 178 | this.writeShort(this.numClues); 179 | this.writeShort(1); // puzzle type 180 | this.writeShort(0); // scrambled tag 181 | } 182 | 183 | writeFill(json) { 184 | const grid = json.grid; 185 | const BLOCK_CP = '.'.codePointAt(0); 186 | this.solution = this.buf.length; 187 | for (var i = 0; i < grid.length; i++) { 188 | this.buf.push(grid[i][0].codePointAt(0)); // Note: assumes grid is ISO-8859-1 189 | } 190 | this.grid = this.buf.length; 191 | for (let i = 0; i < grid.length; i++) { 192 | var cp = grid[i].codePointAt(0); 193 | if (cp != BLOCK_CP) cp = '-'.codePointAt(0); 194 | this.buf.push(cp); 195 | } 196 | } 197 | 198 | writeStrings(json) { 199 | this.stringStart = this.buf.length; 200 | this.writeString(json.title); 201 | this.writeString(json.author); 202 | this.writeString(json.copyright); 203 | const across = json.clues.across; 204 | const down = json.clues.down; 205 | var clues = []; 206 | for (var i = 0; i < across.length; i++) { 207 | const numEnd = across[i].indexOf(". "); 208 | const textStart = numEnd + 2; 209 | const num = across[i].slice(0, numEnd); 210 | const text = across[i].slice(textStart); 211 | clues.push([2 * parseInt(num), text]); 212 | } 213 | for (let i = 0; i < down.length; i++) { 214 | const numEnd = down[i].indexOf(". "); 215 | const textStart = numEnd + 2; 216 | const num = down[i].slice(0, numEnd); 217 | const text = down[i].slice(textStart); 218 | clues.push([2 * parseInt(num), text]); 219 | } 220 | clues.sort((a, b) => a[0] - b[0]); 221 | for (let i = 0; i < clues.length; i++) { 222 | this.writeString(clues[i][1]); 223 | } 224 | this.writeString(json.notepad); 225 | } 226 | 227 | checksumRegion(base, len, cksum) { 228 | for (var i = 0; i < len; i++) { 229 | cksum = (cksum >> 1) | ((cksum & 1) << 15); 230 | cksum = (cksum + this.buf[base + i]) & 0xffff; 231 | } 232 | return cksum; 233 | } 234 | 235 | strlen(ix) { 236 | var i = 0; 237 | while (this.buf[ix + i]) i++; 238 | return i; 239 | } 240 | 241 | checksumStrings(cksum) { 242 | let ix = this.stringStart; 243 | for (var i = 0; i < 3; i++) { 244 | const len = this.strlen(ix); 245 | if (len) { 246 | cksum = this.checksumRegion(ix, len + 1, cksum); 247 | } 248 | ix += len + 1; 249 | } 250 | for (let i = 0; i < this.numClues; i++) { 251 | const len = this.strlen(ix); 252 | cksum = this.checksumRegion(ix, len, cksum); 253 | ix += len + 1; 254 | } 255 | if (this.version == '1.3') { 256 | const len = this.strlen(ix); 257 | if (len) { 258 | cksum = this.checksumRegion(ix, len + 1, cksum); 259 | } 260 | ix += len + 1; 261 | } 262 | return cksum; 263 | } 264 | 265 | setMaskedChecksum(i, maskLow, maskHigh, cksum) { 266 | this.buf[0x10 + i] = maskLow ^ (cksum & 0xff); 267 | this.buf[0x14 + i] = maskHigh ^ (cksum >> 8); 268 | } 269 | 270 | computeChecksums() { 271 | var c_cib = this.checksumRegion(0x2c, 8, 0); 272 | this.setShort(0xe, c_cib); 273 | var cksum = this.checksumRegion(this.solution, this.w * this.h, c_cib); 274 | cksum = this.checksumRegion(this.grid, this.w * this.h, cksum); 275 | cksum = this.checksumStrings(cksum); 276 | this.setShort(0x0, cksum); 277 | this.setMaskedChecksum(0, 0x49, 0x41, c_cib); 278 | var c_sol = this.checksumRegion(this.solution, this.w * this.h, 0); 279 | this.setMaskedChecksum(1, 0x43, 0x54, c_sol); 280 | var c_grid = this.checksumRegion(this.grid, this.w * this.h, 0); 281 | this.setMaskedChecksum(2, 0x48, 0x45, c_grid); 282 | var c_part = this.checksumStrings(0); 283 | this.setMaskedChecksum(3, 0x45, 0x44, c_part); 284 | } 285 | 286 | writeExtras(json) { 287 | let grid = json.grid; 288 | if (grid.some(g => g.length > 1)) { 289 | this.writeExtraTitle("GRBS"); 290 | this.pad(4); 291 | let grbsStart = this.buf.length; 292 | let rebusList = []; 293 | for (let g of grid) { 294 | if (g.length > 1) { 295 | if (!rebusList.includes(g)) { 296 | rebusList.push(g); 297 | } 298 | this.buf.push(rebusList.indexOf(g) + 2); 299 | } else { 300 | this.buf.push(0); 301 | } 302 | } 303 | let grbsLength = this.buf.length - grbsStart; 304 | this.setShort(grbsStart - 4, grbsLength); 305 | let grbsCksum = this.checksumRegion(grbsStart, grbsLength, 0); 306 | this.setShort(grbsStart - 2, grbsCksum); 307 | this.buf.push(0); 308 | this.writeExtraTitle("RTBL"); 309 | this.pad(4); 310 | let rtblStart = this.buf.length; 311 | let rtbl = this.generateRTBL(rebusList); 312 | this.writeString(rtbl); 313 | let rtblLength = this.buf.length - 1 - rtblStart; 314 | this.setShort(rtblStart - 4, rtblLength); 315 | let rtblCksum = this.checksumRegion(rtblStart, rtblLength, 0); 316 | this.setShort(rtblStart - 2, rtblCksum); 317 | } 318 | if (json.circles) { 319 | this.writeExtraTitle("GEXT"); 320 | this.pad(4); 321 | let gextStart = this.buf.length; 322 | for (let c of json.circles) { 323 | this.buf.push(c ? 128 : 0); 324 | } 325 | let gextLength = this.buf.length - gextStart; 326 | this.setShort(gextStart - 4, gextLength); 327 | let gextCksum = this.checksumRegion(gextStart, gextLength, 0); 328 | this.setShort(gextStart - 2, gextCksum); 329 | this.buf.push(0); 330 | } 331 | } 332 | 333 | generateRTBL(rebusList) { 334 | let rtbl = ""; 335 | let key = ""; 336 | for (let i = 0; i < rebusList.length; i++) { 337 | key = (i + 1).toString(); 338 | if (key.length == 1) { 339 | key = " " + key; 340 | } 341 | rtbl = rtbl.concat(key + ":" + rebusList[i] + ";"); 342 | } 343 | return rtbl; 344 | } 345 | 346 | writeExtraTitle(title) { 347 | for (let char of title) { 348 | this.buf.push(char.codePointAt(0)); 349 | } 350 | } 351 | 352 | toPuz(json) { 353 | this.writeHeader(json); 354 | this.writeFill(json); 355 | this.writeStrings(json); 356 | this.computeChecksums(); 357 | this.writeExtras(json); 358 | return new Uint8Array(this.buf); 359 | } 360 | } 361 | 362 | function openPuzzle() { 363 | document.getElementById("open-puzzle-input").click(); 364 | } 365 | 366 | function isPuz(bytes) { 367 | const magic = 'ACROSS&DOWN'; 368 | for (var i = 0; i < magic.length; i++) { 369 | if (bytes[2 + i] != magic.charCodeAt(i)) return false; 370 | } 371 | return bytes[2 + magic.length] == 0; 372 | } 373 | 374 | function openFile(e) { 375 | const file = e.target.files[0]; 376 | if (!file) { 377 | return; 378 | } 379 | let reader = new FileReader(); 380 | try { 381 | switch (file.name.slice(file.name.lastIndexOf("."))) { 382 | case ".json": 383 | case ".xw": 384 | case ".txt": 385 | reader.onload = function(e) { 386 | convertJSONToPuzzle(JSON.parse(e.target.result)); 387 | }; 388 | reader.readAsText(file); // removing this line breaks the JSON import 389 | break; 390 | case ".puz": 391 | reader.onload = function(e) { 392 | const bytes = new Uint8Array(e.target.result); 393 | let puz; 394 | if (isPuz(bytes)) { 395 | puz = new PuzReader(bytes).toJson(); 396 | } else { 397 | puz = JSON.parse(new TextDecoder().decode(bytes)); // TextDecoder doesn't work in Edge 16 398 | } 399 | convertJSONToPuzzle(puz); 400 | }; 401 | reader.readAsArrayBuffer(file); 402 | break; 403 | default: 404 | break; 405 | } 406 | console.log("Loaded", file.name); 407 | } 408 | catch (err) { 409 | switch (err.name) { 410 | case "SyntaxError": 411 | new Notification("Invalid file. PUZ and JSON puzzle files only.", 10); 412 | break; 413 | case "ScrambledError": 414 | new Notification("Cannot open scrambled PUZ file.", 10); 415 | break; 416 | default: 417 | console.log("Error:", err); 418 | } 419 | } 420 | } 421 | 422 | // in case imported json clues use html entities, as with xwordinfo 423 | function decodeHtml(html) { 424 | let txt = document.createElement("textarea"); 425 | txt.innerHTML = html; 426 | return txt.value; 427 | } 428 | 429 | function convertJSONToPuzzle(puz) { 430 | createNewPuzzle(puz.size.rows, puz.size.cols); 431 | // Update puzzle title, author 432 | xw.title = puz.title || DEFAULT_TITLE; 433 | if (puz.title.slice(0,8).toUpperCase() == "NY TIMES") { 434 | xw.title = "NYT Crossword"; 435 | } 436 | xw.author = puz.author || DEFAULT_AUTHOR; 437 | // Update fill 438 | let fill = []; 439 | for (let i = 0; i < xw.rows; i++) { 440 | fill.push([]); 441 | for (let j = 0; j < xw.cols; j++) { 442 | const k = (i * xw.cols) + j; 443 | fill[i].push(puz.grid[k].toUpperCase()); 444 | let td = getGridSquare(i, j); 445 | let fillDiv = td.querySelector(".fill"); 446 | if (fill[i][j].length > 1) { 447 | fillDiv.classList.add("rebus"); 448 | } 449 | if (puz.circles && puz.circles[k] == 1) { 450 | let div = document.createElement("div"); 451 | div.setAttribute("class", puz.shadecircles ? "shade" : "circle"); 452 | td.appendChild(div); 453 | } 454 | } 455 | } 456 | xw.fill = fill; 457 | isMutated = true; 458 | 459 | updateGridUI(); 460 | updateLabelsAndClues(); 461 | // Load in clues and display current clues 462 | for (let i = 0; i < xw.rows; i++) { 463 | for (let j = 0; j < xw.cols; j++) { 464 | const activeCell = getGridSquare(i, j); 465 | if (activeCell.querySelector(".label").innerHTML) { 466 | const label = activeCell.querySelector(".label").innerHTML + "."; 467 | for (let k = 0; k < puz.clues.across.length; k++) { 468 | if (label == puz.clues.across[k].slice(0, label.length)) { 469 | xw.clues[[i, j, ACROSS]] = decodeHtml(puz.clues.across[k].slice(label.length).trim()); 470 | } 471 | } 472 | for (let l = 0; l < puz.clues.down.length; l++) { 473 | if (label == puz.clues.down[l].slice(0, label.length)) { 474 | xw.clues[[i, j, DOWN]] = decodeHtml(puz.clues.down[l].slice(label.length).trim()); 475 | } 476 | } 477 | } 478 | } 479 | } 480 | updateUI(); 481 | } 482 | 483 | function writeFile(format) { 484 | let filename = xw.title + "." + format; 485 | let serialized = convertPuzzleToJSON(); 486 | let fileContents; 487 | switch (format) { 488 | case "puz": 489 | fileContents = new PuzWriter().toPuz(serialized); 490 | break; 491 | case "xw": 492 | case "json": 493 | default: 494 | fileContents = JSON.stringify(serialized); // Convert JS object to JSON text 495 | break; 496 | } 497 | let file = new File([fileContents], filename); 498 | let puzzleURL = window.URL.createObjectURL(file); 499 | 500 | let puzzleLink = document.getElementById("download-puzzle-link"); 501 | puzzleLink.setAttribute("href", puzzleURL); 502 | puzzleLink.setAttribute("download", filename); 503 | puzzleLink.click(); 504 | } 505 | 506 | function convertPuzzleToJSON() { 507 | let puz = {}; 508 | puz.author = xw.author; 509 | puz.title = xw.title; 510 | puz.size = { 511 | "rows": xw.rows, 512 | "cols": xw.cols 513 | }; 514 | // Translate clues to standard JSON puzzle format 515 | puz.clues = { 516 | "across": [], 517 | "down": [] 518 | }; 519 | for (const key in xw.clues) { 520 | const location = key.split(","); 521 | const label = getGridSquare(location[0], location[1]).querySelector(".label").innerHTML; 522 | if (label) { 523 | if (location[2] == ACROSS) { 524 | puz.clues.across.push(label + ". " + xw.clues[location]); 525 | } else { 526 | puz.clues.down.push(label + ". " + xw.clues[location]); 527 | } 528 | } 529 | } 530 | // Read grid 531 | puz.grid = []; 532 | let circles = []; 533 | for (let i = 0; i < xw.rows; i++) { 534 | for (let j = 0; j < xw.cols; j++) { 535 | puz.grid.push(xw.fill[i][j]); 536 | let square = getGridSquare(i, j); 537 | if (square.querySelector(".circle")) { 538 | circles.push(1); 539 | //xwordinfo json key, true if shades instead of circles 540 | puz.shadecircles = false; 541 | } else if (square.querySelector(".shade")) { 542 | circles.push(1); 543 | puz.shadecircles = true; 544 | } else { 545 | circles.push(0); 546 | } 547 | } 548 | } 549 | if ("shadecircles" in puz) puz.circles = circles; 550 | return puz; 551 | } 552 | 553 | function printPDF(style) { 554 | let doc = new jsPDF('p', 'pt'); 555 | style = style.toUpperCase(); 556 | let gridFormat = { 557 | "squareSize": 24, 558 | // "pageOrigin": { "x": 50, "y": 50 }, 559 | "gridOrigin": { "x": 50, "y": 80 }, 560 | "labelOffset": { "x": 1, "y": 6 }, 561 | "fillOffset": { "x": 12, "y": 17 }, 562 | "labelFontSize": 7, 563 | "fillFontSize": 14, 564 | "innerLineWidth": 0.5, 565 | "outerLineWidth": 2 566 | }; 567 | let clueFormat = { 568 | "font": "helvetica", 569 | "fontSize": 9, 570 | "labelWidth": 13, 571 | "clueWidth": 94, 572 | "columnSeparator": 18, 573 | "marginTop": [465, 465, 465, 85], 574 | "marginBottom": doc.internal.pageSize.height - 50, 575 | "marginLeft": 50, 576 | "marginRight": 0 577 | }; 578 | let infoFormat = { 579 | "marginX": 50, 580 | "marginY": 50 581 | }; 582 | switch (style) { 583 | case "NYT": 584 | if (xw.isSundaySize()){ 585 | gridFormat.gridOrigin.x = 45; 586 | gridFormat.gridOrigin.y = 150; 587 | gridFormat.fillFontSize = 12; 588 | } else { 589 | gridFormat.gridOrigin.x = 117; 590 | gridFormat.gridOrigin.y = 210; 591 | } 592 | layoutPDFGrid(doc, gridFormat, true); // filled 593 | doc.addPage(); 594 | layoutPDFGrid(doc, gridFormat); // unfilled 595 | doc.addPage(); 596 | layoutPDFClues(doc, style); 597 | if (!layoutPDFInfo(doc, style)) { 598 | return; 599 | } 600 | break; 601 | default: 602 | if (xw.isSundaySize()){ 603 | gridFormat.squareSize = 16; 604 | gridFormat.labelFontSize = 5; 605 | gridFormat.labelOffset.y = 5; 606 | gridFormat.gridOrigin.x = 20; 607 | gridFormat.gridOrigin.y = 43; 608 | clueFormat.labelWidth = 17; 609 | clueFormat.clueWidth = 77; 610 | clueFormat.columnSeparator = 20; 611 | clueFormat.marginTop = [395, 395, 395, 50, 50]; 612 | clueFormat.marginBottom = doc.internal.pageSize.height - 20; 613 | clueFormat.marginLeft = 20; 614 | infoFormat.marginX = 20; 615 | infoFormat.marginY = 30; 616 | } 617 | layoutPDFGrid(doc, gridFormat); 618 | layoutPDFClues(doc, style, clueFormat); 619 | layoutPDFInfo(doc, style, infoFormat); 620 | break; 621 | } 622 | if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) { 623 | window.open(doc.output('bloburl')); 624 | } else { 625 | doc.save(xw.title + ".pdf"); // Generate PDF and automatically download it 626 | } 627 | } 628 | 629 | function generatePDFClues() { 630 | let acrossClues = [], downClues = []; 631 | let byLabel = // this variable is a whole function... 632 | function (a, b) { // that is called when sort() compares values 633 | if (a.label > b.label) { 634 | return 1; 635 | } else if (a.label < b.label) { 636 | return -1; 637 | } else { 638 | return 0; 639 | } 640 | }; 641 | 642 | for (const key in xw.clues) { 643 | let [i, j, direction] = key.split(","); 644 | const cell = getGridSquare(i, j); 645 | let label = Number(cell.querySelector(".label").innerHTML); 646 | if (direction == ACROSS) { 647 | acrossClues.push({ "label": label, "clue": xw.clues[key], "answer": getWordAt(i, j, direction)}); 648 | acrossClues.sort(byLabel); 649 | } else { 650 | downClues.push({ "label": label, "clue": xw.clues[key], "answer": getWordAt(i, j, direction)}); 651 | downClues.sort(byLabel); 652 | } 653 | } 654 | return [acrossClues, downClues]; 655 | } 656 | 657 | function layoutPDFGrid(doc, format, isFilled) { 658 | // Draw grid 659 | doc.setDrawColor(0); 660 | doc.setLineWidth(format.outerLineWidth); 661 | doc.rect(format.gridOrigin.x, format.gridOrigin.y, 662 | xw.cols * format.squareSize, xw.rows * format.squareSize, 'D'); 663 | doc.setLineWidth(format.innerLineWidth); 664 | for (let i = 0; i < xw.rows; i++) { 665 | for (let j = 0; j < xw.cols; j++) { 666 | const cell = getGridSquare(i, j); 667 | if (cell.querySelector(".shade")) { 668 | doc.setFillColor(190); 669 | } else { 670 | doc.setFillColor(xw.fill[i][j] == BLOCK ? 0 : 255); 671 | } 672 | doc.rect(format.gridOrigin.x + (j * format.squareSize), 673 | format.gridOrigin.y + (i * format.squareSize), format.squareSize, format.squareSize, 'FD'); 674 | if (cell.querySelector(".circle")) { 675 | doc.circle(format.gridOrigin.x + ((j + 0.5) * format.squareSize), 676 | format.gridOrigin.y + ((i + 0.5) * format.squareSize), format.squareSize/2); 677 | } 678 | } 679 | } 680 | // Label grid 681 | doc.setFont("helvetica"); 682 | doc.setFontType("normal"); 683 | doc.setFontSize(format.labelFontSize); 684 | for (let i = 0; i < xw.rows; i++) { 685 | for (let j = 0; j < xw.cols; j++) { 686 | const square = getGridSquare(i, j); 687 | const label = square.querySelector(".label").innerHTML; 688 | if (label) { 689 | doc.text(format.gridOrigin.x + (j * format.squareSize) + format.labelOffset.x, 690 | format.gridOrigin.y + (i * format.squareSize) + format.labelOffset.y, label); 691 | } 692 | } 693 | } 694 | // Fill grid 695 | if (isFilled) { 696 | for (let i = 0; i < xw.rows; i++) { 697 | for (let j = 0; j < xw.cols; j++) { 698 | if (xw.fill[i][j].length == 1) { 699 | doc.setFontSize(format.fillFontSize); 700 | } else if (xw.fill[i][j].length < 5) { 701 | doc.setFontSize(format.fillFontSize/2); 702 | } else { 703 | doc.setFontSize(format.fillFontSize/3); 704 | } 705 | doc.text(format.gridOrigin.x + (j * format.squareSize) + format.fillOffset.x, 706 | format.gridOrigin.y + (i * format.squareSize) + format.fillOffset.y, 707 | xw.fill[i][j], null, null, "center"); 708 | } 709 | } 710 | } 711 | } 712 | 713 | function layoutPDFInfo(doc, style, format) { 714 | doc.setFont("helvetica"); 715 | switch (style) { 716 | case "NYT": 717 | let email = prompt("NYT submissions require an email address. \nLeave blank to omit."); 718 | if (email == null) { 719 | return null; 720 | } 721 | let address = prompt("NYT submissions also require a mailing address. \nLeave blank to omit."); 722 | if (address == null) { 723 | return null; 724 | } 725 | doc.setFontSize(9); 726 | for (let i = 1; i <= 5; i++) { 727 | doc.setPage(i); 728 | doc.text(doc.internal.pageSize.width / 2, 40, 729 | (xw.author + "\n\n" + email + (email ? " " : "") + address), 730 | null, null, "center"); 731 | doc.setLineWidth(0.5); 732 | doc.line(0 + 150, 48, doc.internal.pageSize.width - 150, 48); 733 | } 734 | break; 735 | default: 736 | doc.setFontSize(12); 737 | doc.setFontType("normal"); 738 | doc.text(format.marginX, format.marginY, xw.title); 739 | doc.setFontSize(9); 740 | doc.setFontType("bold"); 741 | let x = doc.internal.pageSize.width - format.marginX; 742 | doc.text(x, format.marginY, xw.author.toUpperCase(), null, null, "right"); 743 | doc.setLineWidth(0.5); 744 | doc.line(format.marginX, format.marginY + 5, x, format.marginY + 5); 745 | break; 746 | } 747 | return 1; 748 | } 749 | 750 | function layoutPDFClues(doc, style, format) { 751 | const [acrossClues, downClues] = generatePDFClues(); 752 | 753 | switch (style) { 754 | case "NYT": 755 | let clueFormat = 756 | { columnStyles: { label: { columnWidth: 20, halign: "right", overflow: "visible" }, 757 | clue: { columnWidth: 320, overflow: "linebreak" }, 758 | answer: { columnWidth: 120, font: "courier", overflow: "visible", fontSize: 11 } 759 | }, 760 | margin: { top: 75, left: 75 } 761 | }; 762 | doc.autoTableSetDefaults({ 763 | headerStyles: {fillColor: false, textColor: 0, fontSize: 16, fontStyle: "normal", overflow: "visible"}, 764 | bodyStyles: { fillColor: false, textColor: 0, fontSize: 10, cellPadding: 6 }, 765 | alternateRowStyles: { fillColor: false } 766 | }); 767 | // Print across clues 768 | doc.autoTable([ { title: "Across", dataKey: "label"}, 769 | { title: "", dataKey: "clue"}, 770 | { title: "", dataKey: "answer"} 771 | ], acrossClues, clueFormat); 772 | // Print down clues 773 | clueFormat.startY = doc.autoTable.previous.finalY + 10; 774 | doc.autoTable([ { title: "Down", dataKey: "label"}, 775 | { title: "", dataKey: "clue"}, 776 | { title: "", dataKey: "answer"} 777 | ], downClues, clueFormat); 778 | break; 779 | default: 780 | doc.setFont(format.font); 781 | doc.setFontSize(format.fontSize); 782 | let currentColumn = 0; 783 | let x = format.marginLeft; 784 | let y = format.marginTop[currentColumn]; 785 | const acrossTitle = [{ "label": "ACROSS", "clue": " " }]; 786 | const downTitle = [{ "label": " ", "clue": " "}, {"label": "DOWN", "clue": " " }]; 787 | let allClues = acrossTitle.concat(acrossClues).concat(downTitle).concat(downClues); 788 | for (let i = 0; i < allClues.length; i++) { // Position clue on page 789 | const clueText = doc.splitTextToSize(allClues[i].clue, format.clueWidth); 790 | let adjustY = clueText.length * (format.fontSize + 2); 791 | if (y + adjustY > format.marginBottom) { 792 | currentColumn++; 793 | x += format.labelWidth + format.clueWidth + format.columnSeparator; 794 | y = format.marginTop[currentColumn]; 795 | } 796 | if (["across", "down"].includes(String(allClues[i].label).toLowerCase())) { // Make Across, Down headings bold 797 | doc.setFontType("bold"); 798 | } else { 799 | doc.setFontType("normal"); 800 | } 801 | doc.text(x, y, String(allClues[i].label)); // Print clue on page 802 | doc.text(x + format.labelWidth, y, clueText); 803 | y += adjustY; 804 | } 805 | break; 806 | } 807 | } 808 | 809 | let openPuzzleInput = document.getElementById('open-puzzle-input'); 810 | let openWordlistInput = document.getElementById('open-wordlist-input'); 811 | openPuzzleInput.addEventListener('change', openFile, false); 812 | openWordlistInput.addEventListener('change', openWordlistFile, false); 813 | openPuzzleInput.onclick = function () { 814 | this.value = null; 815 | }; 816 | -------------------------------------------------------------------------------- /cross.js: -------------------------------------------------------------------------------- 1 | // Modified by jmviz. Original notice follows: 2 | // 3 | // Phil 4 | // ------------------------------------------------------------------------ 5 | // Copyright 2017 Keiran King 6 | 7 | // Licensed under the Apache License, Version 2.0 (the "License"); 8 | // you may not use this file except in compliance with the License. 9 | // (https://www.apache.org/licenses/LICENSE-2.0) 10 | 11 | // Unless required by applicable law or agreed to in writing, software 12 | // distributed under the License is distributed on an "AS IS" BASIS, 13 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | // See the License for the specific language governing permissions and 15 | // limitations under the License. 16 | // ------------------------------------------------------------------------ 17 | 18 | const BLOCK = "."; 19 | const DASH = "-"; 20 | const BLANK = " "; 21 | const ACROSS = "across"; 22 | const DOWN = "down"; 23 | const DEFAULT_SIZE = 15; 24 | const DEFAULT_SUNDAY_SIZE = 21; 25 | const DEFAULT_TITLE = "Untitled"; 26 | const DEFAULT_AUTHOR = "Anonymous"; 27 | const DEFAULT_CLUE = "(blank clue)"; 28 | const DEFAULT_NOTIFICATION_LIFETIME = 10; // in seconds 29 | 30 | let history = []; 31 | let isSymmetrical = true; 32 | let isCircleDefault = true; 33 | let isStrictMatching = false; 34 | let isDarkMode = false; 35 | let grid; 36 | let squares; 37 | let isMutated = false; 38 | let forced = null; 39 | // createNewPuzzle(); 40 | let solveWorker = null; 41 | let solveWorkerState = null; 42 | let solveTimeout = null; 43 | let solveWordlist = null; 44 | let solvePending = []; 45 | 46 | //____________________ 47 | // C L A S S E S 48 | class Crossword { 49 | constructor(rows = DEFAULT_SIZE, cols = DEFAULT_SIZE) { 50 | this.clues = {}; 51 | this.title = DEFAULT_TITLE; 52 | this.author = DEFAULT_AUTHOR; 53 | this.rows = rows; 54 | this.cols = cols; 55 | this.fill = []; 56 | // 57 | for (let i = 0; i < this.rows; i++) { 58 | this.fill.push([]); 59 | for (let j = 0; j < this.cols; j++) { 60 | this.fill[i].push(BLANK); 61 | } 62 | } 63 | } 64 | isDailySize() { 65 | return this.rows == DEFAULT_SIZE && this.cols == DEFAULT_SIZE; 66 | } 67 | isSundaySize() { 68 | return this.rows == DEFAULT_SUNDAY_SIZE && this.cols == DEFAULT_SUNDAY_SIZE; 69 | } 70 | isStandardSize() { 71 | return this.isDailySize() || this.isSundaySize(); 72 | } 73 | } 74 | 75 | class Grid { 76 | constructor(rows, cols) { 77 | document.getElementById("grid-container").innerHTML = ""; 78 | grid = document.createElement("table"); 79 | grid.setAttribute("id", "grid"); 80 | grid.setAttribute("tabindex", "1"); 81 | document.getElementById("grid-container").appendChild(grid); 82 | 83 | for (let i = 0; i < rows; i++) { 84 | let row = document.createElement("tr"); 85 | row.setAttribute("data-row", i); 86 | document.getElementById("grid").appendChild(row); 87 | 88 | for (let j = 0; j < cols; j++) { 89 | let col = document.createElement("td"); 90 | col.setAttribute("data-col", j); 91 | 92 | let label = document.createElement("div"); 93 | label.setAttribute("class", "label"); 94 | let labelContent = document.createTextNode(""); 95 | 96 | let fill = document.createElement("div"); 97 | fill.setAttribute("class", "fill"); 98 | let fillContent = document.createTextNode(xw.fill[i][j]); 99 | 100 | label.appendChild(labelContent); 101 | fill.appendChild(fillContent); 102 | col.appendChild(label); 103 | col.appendChild(fill); 104 | row.appendChild(col); 105 | } 106 | } 107 | squares = grid.querySelectorAll('td'); 108 | for (const square of squares) { 109 | square.addEventListener('click', mouseHandler); 110 | } 111 | grid.addEventListener('keydown', keyboardHandler); 112 | } 113 | 114 | update() { 115 | for (let i = 0; i < xw.rows; i++) { 116 | for (let j = 0; j < xw.cols; j++) { 117 | const activeCell = getGridSquare(i, j); 118 | let fill = xw.fill[i][j]; 119 | if (fill == BLANK && forced != null) { 120 | fill = forced[i][j]; 121 | activeCell.classList.add("pencil"); 122 | } else { 123 | activeCell.classList.remove("pencil"); 124 | } 125 | activeCell.querySelector(".fill").innerHTML = fill; 126 | if (fill == BLOCK) { 127 | activeCell.classList.add("block"); 128 | } else { 129 | activeCell.classList.remove("block"); 130 | } 131 | } 132 | } 133 | } 134 | } 135 | 136 | class Button { 137 | constructor(id) { 138 | this.id = id; 139 | this.dom = document.getElementById(id); 140 | this.tooltip = this.dom.getAttribute("data-tooltip"); 141 | // this.type = type; // "normal", "toggle", "menu", "submenu" 142 | this.state = this.dom.className; // "normal", "on", "open", "disabled" 143 | } 144 | 145 | setState(state) { 146 | this.state = state; 147 | this.dom.className = (this.state == "normal") ? "" : this.state; 148 | } 149 | 150 | addEvent(e, func) { 151 | this.dom.addEventListener(e, func); 152 | if (this.state == "disabled") { 153 | this.setState("normal"); 154 | } 155 | } 156 | 157 | press() { 158 | // switch (this.type) { 159 | // case "toggle": 160 | // case "submenu": 161 | // this.setState((this.state == "on") ? "normal" : "on"); 162 | // break; 163 | // case "menu": 164 | // this.setState((this.state == "open") ? "normal" : "open"); 165 | // break; 166 | // default: 167 | // break; 168 | } 169 | } 170 | 171 | class Menu { // in dev 172 | constructor(id, buttons) { 173 | this.id = id; 174 | this.buttons = buttons; 175 | 176 | let div = document.createElement("div"); 177 | div.setAttribute("id", this.id); 178 | for (var button in buttons) { 179 | div.appendChild(button); 180 | } 181 | document.getElementById("toolbar").appendChild(div); 182 | } 183 | } 184 | 185 | class Toolbar { 186 | constructor(id) { 187 | this.id = id; 188 | this.buttons = { // rewrite this programmatically 189 | "newPuzzle15": new Button("new-grid-15"), 190 | "newPuzzle21": new Button("new-grid-21"), 191 | "newPuzzleCustom": new Button("new-grid-custom"), 192 | "newPuzzle": new Button("new-grid"), 193 | "openPuzzle": new Button("open-JSON"), 194 | "exportJSON": new Button("export-JSON"), 195 | "exportPUZ": new Button("export-PUZ"), 196 | "exportPDF": new Button("print-puzzle"), 197 | "exportNYT": new Button("print-NYT-submission"), 198 | "export": new Button("export"), 199 | "enter-rebus": new Button("enter-rebus"), 200 | "quickLayout": new Button("quick-layout"), 201 | "freezeLayout": new Button("toggle-freeze-layout"), 202 | "clearFill": new Button("clear-fill"), 203 | "toggleSymmetry": new Button("toggle-symmetry"), 204 | "strictMatching": new Button("toggle-strict-matching"), 205 | "openWordlist": new Button("open-wordlist"), 206 | "autoFill": new Button("auto-fill"), 207 | "toggleDarkMode": new Button("toggle-dark-mode") 208 | }; 209 | } 210 | } 211 | 212 | class Notification { 213 | constructor(message, lifetime = undefined) { 214 | this.message = message; 215 | this.id = String(randomNumber(1,10000)); 216 | this.post(); 217 | if (lifetime) { 218 | this.dismiss(lifetime); 219 | } 220 | } 221 | 222 | post() { 223 | let div = document.createElement("div"); 224 | div.setAttribute("id", this.id); 225 | div.setAttribute("class", "notification"); 226 | div.innerHTML = this.message; 227 | div.addEventListener('click', this.dismiss); 228 | // document.getElementById("footer").appendChild(div); 229 | document.body.appendChild(div); 230 | 231 | } 232 | 233 | update(message) { 234 | document.getElementById(this.id).innerHTML = message; 235 | } 236 | 237 | dismiss(seconds = 0) { 238 | let div = document.getElementById(this.id); 239 | // seconds = (seconds === true) ? 10 : seconds; 240 | setTimeout(function() { div.remove(); }, seconds * 1000); 241 | } 242 | } 243 | 244 | class Interface { 245 | constructor(rows, cols) { 246 | this.grid = new Grid(rows, cols); 247 | // this.sidebar; 248 | this.toolbar = new Toolbar("toolbar"); 249 | 250 | this.isSymmetrical = true; 251 | this.row = 0; 252 | this.col = 0; 253 | this.acrossWord = ''; 254 | this.downWord = ''; 255 | this.acrossStartIndex = 0; 256 | this.acrossEndIndex = cols; 257 | this.downStartIndex = 0; 258 | this.downEndIndex = rows; 259 | this.direction = ACROSS; 260 | 261 | console.log("Grid UI created."); 262 | } 263 | 264 | toggleDirection() { 265 | this.direction = (this.direction == ACROSS) ? DOWN : ACROSS; 266 | } 267 | 268 | update() { 269 | updateInfoUI(); 270 | updateLabelsAndClues(); 271 | updateActiveWords(); 272 | updateGridHighlights(); 273 | updateSidebarHighlights(); 274 | updateCluesUI(); 275 | updateStatsUI(true); 276 | } 277 | } 278 | 279 | class ActionTimeline { 280 | constructor(pastStack = [], futureStack = []) { 281 | this.pastStack = pastStack; 282 | this.futureStack = futureStack; 283 | } 284 | undo() { 285 | let action = this.pastStack.pop(); 286 | if (action) { 287 | isMutated = true; 288 | this.futureStack.push(action); 289 | action.undo(); 290 | } 291 | } 292 | redo() { 293 | let action = this.futureStack.pop(); 294 | if (action) { 295 | isMutated = true; 296 | this.pastStack.push(action); 297 | action.redo(); 298 | } 299 | } 300 | record(action) { 301 | this.pastStack.push(action); 302 | this.futureStack = []; 303 | } 304 | clear() { 305 | this.pastStack = []; 306 | this.futureStack = []; 307 | } 308 | } 309 | 310 | class Action { 311 | constructor(type, state) { 312 | this.type = type; 313 | this.state = state; 314 | } 315 | undo(isRedo = false) { 316 | let state = this.state; 317 | current.row = state.row; 318 | current.col = state.col; 319 | current.direction = state.direction; 320 | switch (this.type) { 321 | case "editFill": 322 | let activeCell = getGridSquare(state.row, state.col); 323 | let fill = isRedo ? state.new : state.old; 324 | xw.fill[state.row][state.col] = fill; 325 | activeCell.querySelector(".fill").classList.remove("rebus"); 326 | if (fill.length > 1) { 327 | activeCell.querySelector(".fill").classList.add("rebus"); 328 | } 329 | if (state.isSymmetrical) { 330 | let symFill = isRedo ? state.symNew : state.symOld; 331 | xw.fill[state.symRow][state.symCol] = symFill; 332 | let symCell = getGridSquare(state.symRow, state.symCol); 333 | symCell.querySelector(".fill").classList.remove("rebus"); 334 | if (symFill.length > 1) { 335 | symCell.querySelector(".fill").classList.add("rebus"); 336 | } 337 | } 338 | break; 339 | case "fillMatch": 340 | let word = isRedo ? state.new : state.old; 341 | let k = 0; 342 | if (state.direction == ACROSS) { 343 | for (let j = state.start; j < state.end; j++) { 344 | xw.fill[state.row][j] = word[k++]; 345 | } 346 | } else { 347 | for (let i = state.start; i < state.end; i++) { 348 | xw.fill[i][state.col] = word[k++]; 349 | } 350 | } 351 | break; 352 | case "toggleCircle": 353 | let cell = getGridSquare(current.row, current.col); 354 | let type = isCircleDefault ? "circle" : "shade"; 355 | if (cell.querySelector("." + type)) { 356 | cell.removeChild(cell.querySelector("." + type)); 357 | } else { 358 | let div = document.createElement("div"); 359 | div.setAttribute("class", type); 360 | cell.appendChild(div); 361 | } 362 | break; 363 | case "switchCirclesShades": 364 | switchCirclesShades(); 365 | break; 366 | case "autoFill": 367 | for (let i = 0; i < xw.rows; i++) { 368 | for (let j = 0; j < xw.cols; j++) { 369 | xw.fill[i][j] = isRedo ? state.new[i][j] : state.old[i][j]; 370 | } 371 | } 372 | break; 373 | // case "newPuzzle": 374 | // break; 375 | } 376 | grid.focus(); 377 | if (grid.querySelector(".active")) { 378 | grid.querySelector(".active").classList.remove("active"); 379 | getGridSquare(state.row, state.col).classList.add("active"); 380 | } 381 | updateUI(); 382 | } 383 | redo() { 384 | this.undo(true); 385 | } 386 | } 387 | 388 | let xw = new Crossword(); // model 389 | let current = new Interface(xw.rows, xw.cols); // view-controller 390 | current.update(); 391 | if (localStorage.getItem("theme") == "dark") toggleDarkMode(); 392 | let actionTimeline = new ActionTimeline(); 393 | 394 | //____________________ 395 | // F U N C T I O N S 396 | 397 | function createNewPuzzle(rows, cols) { 398 | xw.clues = {}; 399 | xw.title = DEFAULT_TITLE; 400 | xw.author = DEFAULT_AUTHOR; 401 | xw.rows = rows || DEFAULT_SIZE; 402 | xw.cols = cols || xw.rows; 403 | xw.fill = []; 404 | for (let i = 0; i < xw.rows; i++) { 405 | xw.fill.push([]); 406 | for (let j = 0; j < xw.cols; j++) { 407 | xw.fill[i].push(BLANK); 408 | } 409 | } 410 | updateInfoUI(); 411 | document.getElementById("grid-container").innerHTML = ""; 412 | createGrid(xw.rows, xw.cols); 413 | 414 | isSymmetrical = true; 415 | current = { 416 | "row": 0, 417 | "col": 0, 418 | "acrossWord": '', 419 | "downWord": '', 420 | "acrossStartIndex":0, 421 | "acrossEndIndex": xw.rows, 422 | "downStartIndex": 0, 423 | "downEndIndex": xw.cols, 424 | "direction": ACROSS 425 | }; 426 | 427 | squares = grid.querySelectorAll('td'); 428 | 429 | updateActiveWords(); 430 | updateGridHighlights(); 431 | updateSidebarHighlights(); 432 | updateCluesUI(); 433 | updateMatchesUI(); 434 | updateStatsUI(true); 435 | 436 | for (const square of squares) { 437 | square.addEventListener('click', mouseHandler); 438 | } 439 | grid.addEventListener('keydown', keyboardHandler); 440 | console.log(`New ${xw.rows}×${xw.cols} puzzle created.`); 441 | 442 | actionTimeline.clear(); 443 | 444 | if (!xw.isStandardSize()){ 445 | new Notification("Warning, PDF exporting is not optimized for non-standard grid sizes.", 5); 446 | } 447 | } 448 | 449 | function createNewCustomPuzzle() { 450 | let rows = parseInt(document.getElementById("custom-rows").value); 451 | let cols = parseInt(document.getElementById("custom-cols").value); 452 | createNewPuzzle(rows, cols); 453 | document.getElementById("new-grid-menu").querySelector(".default").classList.remove("default"); 454 | document.getElementById("new-grid-custom").classList.add("default"); 455 | } 456 | 457 | function updateUI() { 458 | updateGridUI(); 459 | updateLabelsAndClues(); 460 | updateActiveWords(); 461 | updateGridHighlights(); 462 | updateSidebarHighlights(); 463 | updateMatchesUI(); 464 | updateCluesUI(); 465 | updateInfoUI(); 466 | if (isMutated) { 467 | updateStatsUI(); 468 | // autoFill(true); // quick fill 469 | } 470 | } 471 | 472 | function updateGridUI() { 473 | for (let i = 0; i < xw.rows; i++) { 474 | for (let j = 0; j < xw.cols; j++) { 475 | const activeCell = getGridSquare(i, j); 476 | let fill = xw.fill[i][j]; 477 | if (fill == BLANK && forced != null) { 478 | fill = forced[i][j]; 479 | activeCell.classList.add("pencil"); 480 | } else { 481 | activeCell.classList.remove("pencil"); 482 | } 483 | activeCell.querySelector(".fill").innerHTML = fill; 484 | if (fill == BLOCK) { 485 | activeCell.classList.add("block"); 486 | } else { 487 | activeCell.classList.remove("block"); 488 | } 489 | } 490 | } 491 | } 492 | 493 | function updateCluesUI() { 494 | let acrossClueNumber = document.getElementById("across-clue-number"); 495 | let downClueNumber = document.getElementById("down-clue-number"); 496 | let acrossClueText = document.getElementById("across-clue-text"); 497 | let downClueText = document.getElementById("down-clue-text"); 498 | 499 | // If the current cell is block, empty interface and get out 500 | if (xw.fill[current.row][current.col] == BLOCK) { 501 | acrossClueNumber.innerHTML = ""; 502 | downClueNumber.innerHTML = ""; 503 | acrossClueText.innerHTML = ""; 504 | downClueText.innerHTML = ""; 505 | return; 506 | } 507 | // Otherwise, assign values 508 | const acrossCell = getGridSquare(current.row, current.acrossStartIndex); 509 | const downCell = getGridSquare(current.downStartIndex, current.col); 510 | acrossClueNumber.innerHTML = acrossCell.querySelector(".label").innerHTML + "a."; 511 | downClueNumber.innerHTML = downCell.querySelector(".label").innerHTML + "d."; 512 | acrossClueText.innerHTML = xw.clues[[current.row, current.acrossStartIndex, ACROSS]]; 513 | downClueText.innerHTML = xw.clues[[current.downStartIndex, current.col, DOWN]]; 514 | } 515 | 516 | function updateInfoUI() { 517 | document.getElementById("puzzle-title").innerHTML = xw.title; 518 | document.getElementById("puzzle-author").innerHTML = xw.author; 519 | } 520 | 521 | function createGrid(rows, cols) { 522 | grid = document.createElement("table"); 523 | grid.setAttribute("id", "grid"); 524 | grid.setAttribute("tabindex", "1"); 525 | // grid.setAttribute("tabindex", "0"); 526 | document.getElementById("grid-container").appendChild(grid); 527 | 528 | for (let i = 0; i < rows; i++) { 529 | let row = document.createElement("tr"); 530 | row.setAttribute("data-row", i); 531 | document.getElementById("grid").appendChild(row); 532 | 533 | for (let j = 0; j < cols; j++) { 534 | let col = document.createElement("td"); 535 | col.setAttribute("data-col", j); 536 | 537 | let label = document.createElement("div"); 538 | label.setAttribute("class", "label"); 539 | let labelContent = document.createTextNode(""); 540 | 541 | let fill = document.createElement("div"); 542 | fill.setAttribute("class", "fill"); 543 | let fillContent = document.createTextNode(xw.fill[i][j]); 544 | 545 | // let t = document.createTextNode("[" + i + "," + j + "]"); 546 | label.appendChild(labelContent); 547 | fill.appendChild(fillContent); 548 | col.appendChild(label); 549 | col.appendChild(fill); 550 | row.appendChild(col); 551 | } 552 | } 553 | updateLabelsAndClues(); 554 | } 555 | 556 | function updateLabelsAndClues() { 557 | let count = 1; 558 | for (let i = 0; i < xw.rows; i++) { 559 | for (let j = 0; j < xw.cols; j++) { 560 | let isAcross = false; 561 | let isDown = false; 562 | if (xw.fill[i][j] != BLOCK) { 563 | isDown = i == 0 || xw.fill[i - 1][j] == BLOCK; 564 | isAcross = j == 0 || xw.fill[i][j - 1] == BLOCK; 565 | } 566 | let currentCell = getGridSquare(i, j); 567 | if (isAcross || isDown) { 568 | currentCell.querySelector(".label").innerHTML = count; // Set square's label to the count 569 | count++; 570 | } else { 571 | currentCell.querySelector(".label").innerHTML = ""; 572 | } 573 | 574 | if (isAcross) { 575 | xw.clues[[i, j, ACROSS]] = xw.clues[[i, j, ACROSS]] || DEFAULT_CLUE; 576 | } else { 577 | delete xw.clues[[i, j, ACROSS]]; 578 | } 579 | if (isDown) { 580 | xw.clues[[i, j, DOWN]] = xw.clues[[i, j, DOWN]] || DEFAULT_CLUE; 581 | } else { 582 | delete xw.clues[[i, j, DOWN]]; 583 | } 584 | } 585 | } 586 | } 587 | 588 | function updateActiveWords() { 589 | if (xw.fill[current.row][current.col] == BLOCK) { 590 | current.acrossWord = ''; 591 | current.downWord = ''; 592 | current.acrossStartIndex = null; 593 | current.acrossEndIndex = null; 594 | current.downStartIndex = null; 595 | current.downEndIndex = null; 596 | } else { 597 | current.acrossWord = getWordAt(current.row, current.col, ACROSS, true); 598 | current.downWord = getWordAt(current.row, current.col, DOWN, true); 599 | } 600 | document.getElementById("across-word").innerHTML = current.acrossWord; 601 | document.getElementById("down-word").innerHTML = current.downWord; 602 | // console.log("Across:", current.acrossWord, "Down:", current.downWord); 603 | // console.log(current.acrossWord.split(DASH).join("*")); 604 | } 605 | 606 | function getGridSquare(row, col) { 607 | return grid.querySelector('[data-row="' + row + '"]').querySelector('[data-col="' + col + '"]'); 608 | } 609 | 610 | function getRow(i) { 611 | return xw.fill[i]; 612 | } 613 | 614 | function getCol(j) { 615 | let col = []; 616 | for (let i = 0; i < xw.rows; i++) { 617 | col.push(xw.fill[i][j]); 618 | } 619 | return col; 620 | } 621 | 622 | function getLine(direction, index) { 623 | return (direction == ACROSS) ? getRow(index) : getCol(index); 624 | } 625 | 626 | function getWordAt(row, col, direction, setCurrentWordIndices) { 627 | let line = []; 628 | let [start, end] = [0, 0]; 629 | let position = 0; 630 | if (direction == ACROSS) { 631 | line = getRow(row); 632 | position = col; 633 | } else { 634 | line = getCol(col); 635 | position = row; 636 | } 637 | [start, end] = getWordIndices(line, direction, position); 638 | // Set global word indices if needed 639 | if (setCurrentWordIndices) { 640 | if (direction == ACROSS) { 641 | [current.acrossStartIndex, current.acrossEndIndex] = [start, end]; 642 | } else { 643 | [current.downStartIndex, current.downEndIndex] = [start, end]; 644 | } 645 | } 646 | return line.slice(start, end).map(t => (t == BLANK) ? DASH : t).join(""); 647 | } 648 | 649 | function getWordIndices(line, direction, position) { 650 | if (line[position] == BLOCK) { 651 | return [position, position]; 652 | } 653 | let start = line.slice(0, position).lastIndexOf(BLOCK); 654 | start = (start == -1) ? 0 : start + 1; 655 | let limit = (direction == ACROSS) ? xw.cols : xw.rows; 656 | let end = line.slice(position, limit).indexOf(BLOCK); 657 | end = (end == -1) ? limit : Number(position) + end; 658 | return [start, end]; 659 | } 660 | 661 | function getStringIndex(array, arrayIndex) { 662 | return array.slice(0, arrayIndex).reduce((accum, elem) => accum + elem.length, 0); 663 | } 664 | 665 | function updateGridHighlights() { 666 | // Clear the grid of any highlights 667 | for (let i = 0; i < xw.rows; i++) { 668 | for (let j = 0; j < xw.cols; j++) { 669 | const square = getGridSquare(i, j); 670 | square.classList.remove("highlight", "lowlight", "highlight-chart-hover"); 671 | } 672 | } 673 | // Highlight across squares 674 | for (let j = current.acrossStartIndex; j < current.acrossEndIndex; j++) { 675 | const square = getGridSquare(current.row, j); 676 | if (j != current.col) { 677 | square.classList.add((current.direction == ACROSS) ? "highlight" : "lowlight"); 678 | } 679 | } 680 | // Highlight down squares 681 | for (let i = current.downStartIndex; i < current.downEndIndex; i++) { 682 | const square = getGridSquare(i, current.col); 683 | if (i != current.row) { 684 | square.classList.add((current.direction == DOWN) ? "highlight" : "lowlight"); 685 | } 686 | } 687 | } 688 | 689 | function updateSidebarHighlights() { 690 | let acrossHeading = document.getElementById("across-heading"); 691 | let downHeading = document.getElementById("down-heading"); 692 | let acrossChart = document.getElementById("across-chart"); 693 | let downChart = document.getElementById("down-chart"); 694 | const currentCell = getGridSquare(current.row, current.col); 695 | 696 | acrossHeading.classList.remove("highlight"); 697 | downHeading.classList.remove("highlight"); 698 | acrossChart.classList.remove("highlight"); 699 | downChart.classList.remove("highlight"); 700 | 701 | if (!currentCell.classList.contains("block")) { 702 | if (current.direction == ACROSS) { 703 | acrossHeading.classList.add("highlight"); 704 | acrossChart.classList.add("highlight"); 705 | } else { 706 | downHeading.classList.add("highlight"); 707 | downChart.classList.add("highlight"); 708 | } 709 | } 710 | } 711 | 712 | function setClues() { 713 | xw.clues[[current.row, current.acrossStartIndex, ACROSS]] = document.getElementById("across-clue-text").innerHTML; 714 | xw.clues[[current.downStartIndex, current.col, DOWN]] = document.getElementById("down-clue-text").innerHTML; 715 | // console.log("Stored clue:", xw.clues[[current.row, current.acrossStartIndex, ACROSS]], "at [" + current.row + "," + current.acrossStartIndex + "]"); 716 | // console.log("Stored clue:", xw.clues[[current.downStartIndex, current.col, DOWN]], "at [" + current.downStartIndex + "," + current.col + "]"); 717 | } 718 | 719 | function setTitle() { 720 | xw.title = document.getElementById("puzzle-title").innerHTML; 721 | } 722 | 723 | function setAuthor() { 724 | xw.author = document.getElementById("puzzle-author").innerHTML; 725 | } 726 | 727 | function suppressEnterKey(e) { 728 | if (e.key == "Enter") { 729 | e.preventDefault(); 730 | // console.log("Enter key behavior suppressed."); 731 | } 732 | } 733 | 734 | function generatePattern() { 735 | let title = xw.title; 736 | let author = xw.author; 737 | createNewPuzzle(); 738 | xw.title = title; 739 | xw.author = author; 740 | 741 | const pattern = patterns[randomNumber(0, patterns.length)]; // select random pattern 742 | if (!isSymmetrical) { // patterns are encoded as only one half of the grid... 743 | toggleSymmetry(); // so symmetry needs to be on to populate correctly 744 | } 745 | for (let i = 0; i < pattern.length; i++) { 746 | const row = pattern[i][0]; 747 | const col = pattern[i][1]; 748 | const symRow = xw.rows - 1 - row; 749 | const symCol = xw.cols - 1 - col; 750 | xw.fill[row][col] = BLOCK; 751 | xw.fill[symRow][symCol] = BLOCK; 752 | } 753 | isMutated = true; 754 | updateUI(); 755 | console.log("Generated layout."); 756 | } 757 | 758 | function toggleSymmetry() { 759 | isSymmetrical = !isSymmetrical; 760 | // Update UI button 761 | let symButton = document.getElementById("toggle-symmetry"); 762 | symButton.classList.toggle("button-on"); 763 | buttonState = symButton.getAttribute("data-state"); 764 | symButton.setAttribute("data-state", (buttonState == "on") ? "off" : "on"); 765 | symButton.setAttribute("data-tooltip", "Turn " + buttonState + " symmetry"); 766 | } 767 | 768 | function toggleStrictMatching() { 769 | isStrictMatching = !isStrictMatching; 770 | // Update UI button 771 | let strictButton = document.getElementById("toggle-strict-matching"); 772 | strictButton.classList.toggle("button-on"); 773 | buttonState = strictButton.getAttribute("data-state"); 774 | strictButton.setAttribute("data-state", (buttonState == "on") ? "off" : "on"); 775 | strictButton.setAttribute("data-tooltip", "Turn " + buttonState + " strict matching"); 776 | updateMatchesUI(); 777 | } 778 | 779 | function toggleDarkMode() { 780 | if (!isDarkMode) { 781 | document.documentElement.setAttribute("data-theme", "dark"); 782 | localStorage.setItem("theme", "dark"); 783 | } else { 784 | document.documentElement.setAttribute("data-theme", "light"); 785 | localStorage.setItem("theme", "light"); 786 | } 787 | isDarkMode = !isDarkMode; 788 | // Update UI button 789 | let darkButton = document.getElementById("toggle-dark-mode"); 790 | darkButton.classList.toggle("button-on"); 791 | buttonState = darkButton.getAttribute("data-state"); 792 | darkButton.setAttribute("data-state", (buttonState == "on") ? "off" : "on"); 793 | darkButton.setAttribute("data-tooltip", "Turn " + buttonState + " dark mode"); 794 | updateStatsUIColors(); 795 | } 796 | 797 | function toggleHelp() { 798 | document.getElementById("help").classList.toggle("hidden"); 799 | let helpButton = document.getElementById("toggle-help"); 800 | helpButton.classList.toggle("button-on"); 801 | if (helpButton.getAttribute("data-state") == "on") { 802 | helpButton.setAttribute("data-state", "off"); 803 | helpButton.setAttribute("data-tooltip", "Show help"); 804 | } else { 805 | helpButton.setAttribute("data-state", "on"); 806 | helpButton.setAttribute("data-tooltip", "Hide help"); 807 | } 808 | } 809 | 810 | function clearFill() { 811 | for (let i = 0; i < xw.rows; i++) { 812 | for (let j = 0; j < xw.cols; j++) { 813 | if (xw.fill[i][j] != BLOCK) { 814 | xw.fill[i][j] = BLANK; 815 | } 816 | } 817 | } 818 | isMutated = true; 819 | updateUI(); 820 | } 821 | 822 | function autoFill(isQuick = false) { 823 | console.log("Auto-filling..."); 824 | forced = null; 825 | grid.classList.remove("sat", "unsat"); 826 | if (!solveWorker) { 827 | solveWorker = new Worker('xw_worker.js'); 828 | solveWorkerState = 'ready'; 829 | } 830 | if (solveWorkerState != 'ready') { 831 | cancelSolveWorker(); 832 | } 833 | solvePending = [isQuick]; 834 | runSolvePending(); 835 | } 836 | 837 | function runSolvePending() { 838 | if (solveWorkerState != 'ready' || solvePending.length == 0) return; 839 | let isQuick = solvePending[0]; 840 | solvePending = []; 841 | solveTimeout = window.setTimeout(cancelSolveWorker, 30000); 842 | if (solveWordlist == null) { 843 | console.log('Rebuilding wordlist...'); 844 | solveWordlist = ''; 845 | for (let i = 3; i < wordlist.length; i++) { 846 | solveWordlist += wordlist[i].join('\n') + '\n'; 847 | } 848 | } 849 | //console.log(wordlist_str); 850 | let puz = ''; 851 | let isUnfillable = false; 852 | let rebusIndexes = []; 853 | let k = 0; 854 | for (let i = 0; i < xw.rows; i++) { 855 | for (let j = 0; j < xw.cols; j++) { 856 | if (xw.fill[i][j].length > 1) { 857 | rebusIndexes.push(k); 858 | if ((getWordAt(i, j, ACROSS) + getWordAt(i, j, DOWN)).includes(DASH)) { 859 | isUnfillable = true; 860 | } 861 | } 862 | puz = puz + xw.fill[i][j][0]; 863 | k++; 864 | } 865 | puz = puz + '\n'; 866 | } 867 | if (isUnfillable) { 868 | new Notification("Autofill requires the across and down answers for each rebus square to be completely filled.", 10); 869 | console.log("Autofill cancelled: Grid contains incomplete rebus entries."); 870 | return; 871 | } 872 | solveWorker.postMessage(['run', solveWordlist, puz, isQuick]); 873 | solveWorkerState = 'running'; 874 | solveWorker.onmessage = function(e) { 875 | switch (e.data[0]) { 876 | case 'sat': 877 | if (solveWorkerState == 'running') { 878 | if (isQuick) { 879 | console.log("Autofill: Solution found."); 880 | grid.classList.add("sat"); 881 | } else { 882 | let solution = e.data[1].split('\n'); 883 | solution.pop(); // strip empty last line 884 | let state = {}; 885 | state.old = []; 886 | state.new = []; 887 | k = 0; 888 | for (let i = 0; i < solution.length; i++) { 889 | state.old.push([]); 890 | state.new.push([]); 891 | for (let j = 0; j < solution[i].length; j++) { 892 | state.old[i].push(xw.fill[i][j]); 893 | if (!rebusIndexes.includes(k)) { 894 | xw.fill[i][j] = solution[i][j]; 895 | } 896 | state.new[i].push(xw.fill[i][j]); 897 | k++; 898 | } 899 | } 900 | state.row = current.row; 901 | state.col = current.col; 902 | state.direction = current.direction; 903 | actionTimeline.record(new Action("autoFill", state)); 904 | updateGridUI(); 905 | updateStatsUI(); 906 | grid.focus(); 907 | } 908 | } 909 | break; 910 | case 'unsat': 911 | if (solveWorkerState == 'running') { 912 | if (isQuick) { 913 | console.log("Autofill: No quick solution found."); 914 | grid.classList.add("unsat"); 915 | } else { 916 | console.log('Autofill: No solution found.'); 917 | // TODO: indicate on UI 918 | } 919 | } 920 | break; 921 | case 'forced': 922 | if (solveWorkerState == 'running') { 923 | forced = e.data[1].split('\n'); 924 | forced.pop(); // strip empty last line 925 | updateGridUI(); 926 | } 927 | break; 928 | case 'done': 929 | console.log('Autofill: returning to ready, state was ', solveWorkerState); 930 | solveWorkerReady(); 931 | break; 932 | case 'ack_cancel': 933 | console.log('Autofill: Cancel acknowledged.'); 934 | solveWorkerReady(); 935 | break; 936 | default: 937 | console.log('Autofill: Unexpected return,', e.data); 938 | break; 939 | } 940 | }; 941 | } 942 | 943 | function solveWorkerReady() { 944 | if (solveTimeout) { 945 | window.clearTimeout(solveTimeout); 946 | solveTimeout = null; 947 | } 948 | solveWorkerState = 'ready'; 949 | runSolvePending(); 950 | } 951 | 952 | function cancelSolveWorker() { 953 | if (solveWorkerState == 'running') { 954 | solveWorker.postMessage(['cancel']); 955 | solveWorkerState = 'cancelwait'; 956 | console.log("Autofill: Cancel sent."); // TODO: indicate on UI 957 | window.clearTimeout(solveTimeout); 958 | solveTimeout = null; 959 | } 960 | } 961 | 962 | function invalidateSolverWordlist() { 963 | solveWordlist = null; 964 | } 965 | 966 | function showMenu(e) { 967 | let menus = document.querySelectorAll("#toolbar .menu"); 968 | for (let i = 0; i < menus.length; i++) { 969 | menus[i].classList.add("hidden"); 970 | } 971 | const id = e.target.getAttribute("id"); 972 | let menu = document.getElementById(id + "-menu"); 973 | if (menu) { 974 | menu.classList.remove("hidden"); 975 | } 976 | } 977 | 978 | function hideMenu(e) { 979 | e.target.classList.add("hidden"); 980 | } 981 | 982 | function setDefault(e) { 983 | let button = e.target.closest("button"); 984 | let d = button.parentNode.querySelector(".default"); 985 | d.classList.remove("default"); 986 | button.classList.add("default"); 987 | menuButton = document.getElementById(button.parentNode.getAttribute("id").replace("-menu", "")); 988 | menuButton.innerHTML = button.innerHTML; 989 | } 990 | 991 | function doDefault(e) { 992 | const id = e.target.parentNode.getAttribute("id"); 993 | let menu = document.getElementById(id + "-menu"); 994 | if (menu) { 995 | let d = menu.querySelector(".default"); 996 | d.click(); 997 | } 998 | } 999 | 1000 | function enterRebus(e) { 1001 | let rebusInput = document.getElementById("rebus-input"); 1002 | if (rebusInput.value == "" || e.key == ESCAPE) { 1003 | showMenu({"target": document.getElementById("enter-rebus")}); 1004 | rebusInput.focus(); 1005 | return; 1006 | } 1007 | let activeCell = getGridSquare(current.row, current.col); 1008 | let fill = activeCell.querySelector(".fill"); 1009 | let oldContent = xw.fill[current.row][current.col]; 1010 | xw.fill[current.row][current.col] = rebusInput.value.toUpperCase(); 1011 | if (xw.fill[current.row][current.col].length > 1) { 1012 | fill.classList.add("rebus"); 1013 | } 1014 | let symRow = xw.rows - 1 - current.row; 1015 | let symCol = xw.cols - 1 - current.col; 1016 | let symOld = xw.fill[symRow][symCol]; 1017 | if (oldContent == BLOCK) { 1018 | if (isSymmetrical) { 1019 | xw.fill[symRow][symCol] = BLANK; 1020 | } 1021 | } 1022 | if (oldContent != xw.fill[current.row][current.col]) { 1023 | let state = { 1024 | "row": current.row, 1025 | "col": current.col, 1026 | "direction": current.direction, 1027 | "old": oldContent, 1028 | "new": xw.fill[current.row][current.col] 1029 | }; 1030 | if (isSymmetrical) { 1031 | let symState = { 1032 | "isSymmetrical": isSymmetrical, 1033 | "symRow": symRow, 1034 | "symCol": symCol, 1035 | "symOld": symOld, 1036 | "symNew": xw.fill[symRow][symCol] 1037 | }; 1038 | Object.assign(state, symState); 1039 | } 1040 | actionTimeline.record(new Action("editFill", state)); 1041 | } 1042 | 1043 | updateUI(); 1044 | document.getElementById("enter-rebus-menu").classList.add("hidden"); 1045 | if (current.direction == ACROSS) { 1046 | e = new KeyboardEvent("keydown", {"key": ARROW_RIGHT}); 1047 | } else { 1048 | e = new KeyboardEvent("keydown", {"key": ARROW_DOWN}); 1049 | } 1050 | keyboardHandler(e); 1051 | grid.focus(); 1052 | } 1053 | 1054 | function toggleCircle(useCircle = true) { 1055 | let state = { 1056 | "row": current.row, 1057 | "col": current.col, 1058 | "direction": current.direction 1059 | }; 1060 | if (useCircle != isCircleDefault) { 1061 | switchCirclesShades(); 1062 | actionTimeline.record(new Action("switchCirclesShades", state)); 1063 | return; 1064 | } 1065 | let activeCell = getGridSquare(current.row, current.col); 1066 | let type = isCircleDefault ? "circle" : "shade"; 1067 | if (activeCell.querySelector("." + type)) { 1068 | activeCell.removeChild(activeCell.querySelector("." + type)); 1069 | } else { 1070 | let div = document.createElement("div"); 1071 | div.setAttribute("class", type); 1072 | activeCell.appendChild(div); 1073 | } 1074 | updateUI(); 1075 | actionTimeline.record(new Action("toggleCircle", state)); 1076 | if (current.direction == ACROSS) { 1077 | e = new KeyboardEvent("keydown", {"key": ARROW_RIGHT}); 1078 | } else { 1079 | e = new KeyboardEvent("keydown", {"key": ARROW_DOWN}); 1080 | } 1081 | keyboardHandler(e); 1082 | grid.focus(); 1083 | } 1084 | 1085 | function toggleShade() { 1086 | toggleCircle(false); 1087 | } 1088 | 1089 | function switchCirclesShades() { 1090 | isCircleDefault = !isCircleDefault; 1091 | for (let i = 0; i < xw.rows; i++) { 1092 | for (let j = 0; j < xw.cols; j++) { 1093 | let cell = getGridSquare(i, j); 1094 | let circle = cell.querySelector(".circle"); 1095 | let shade = cell.querySelector(".shade"); 1096 | if (circle) { 1097 | let newShade = document.createElement("div"); 1098 | newShade.setAttribute("class", "shade"); 1099 | cell.appendChild(newShade); 1100 | cell.removeChild(circle); 1101 | } else if (shade) { 1102 | let newCircle = document.createElement("div"); 1103 | newCircle.setAttribute("class", "circle"); 1104 | cell.appendChild(newCircle); 1105 | cell.removeChild(shade); 1106 | } 1107 | } 1108 | } 1109 | } 1110 | 1111 | function undo() { 1112 | actionTimeline.undo(); 1113 | } 1114 | 1115 | function redo() { 1116 | actionTimeline.redo(); 1117 | } 1118 | 1119 | function randomNumber(min, max) { 1120 | return Math.floor(Math.random() * max) + min; 1121 | } 1122 | 1123 | function randomLetter() { 1124 | let alphabet = "AAAAAAAAABBCCDDDDEEEEEEEEEEEEFFGGGHHIIIIIIIIIJKLLLLMMNNNNNNOOOOOOOOPPQRRRRRRSSSSSSTTTTTTUUUUVVWWXYYZ"; 1125 | return alphabet[randomNumber(0, alphabet.length)]; 1126 | } 1127 | --------------------------------------------------------------------------------