├── .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 |
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 | | Words: | |
190 | | Mean word length: | |
191 | | Blocks: | |
192 | | Fully connected squares: | |
193 | | Open squares: | |
194 | | Letters: | |
195 | | Mean Scrabble points: | |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
Keyboard Shortcuts
205 |
206 | | Undo | Ctrl/MetaZ |
207 | | Redo | ShiftCtrl/MetaZ |
208 | | Toggle block | . |
209 | | Switch direction | Enter |
210 | | Enter rebus | Esc |
211 | | Toggle circle/shade | ` |
212 |
213 |
214 |
219 |
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 |
--------------------------------------------------------------------------------