PageSourceSearch

https://johnkerl.github.io/word-finder/

html johnkerl.github.io collected 2026-10-03 09:24:24 UTC 47,829 bytes, 1,550 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3
4<head>
5
6<!-- ================================================================ -->
7<!-- Google tag (gtag.js) -->
vendor: 64 bytes, lines 7-8
7
8<script async src="https://www.googletagmanager.com/gtag/js?id=
8G-BZ8T9K9XYD
vendor: 12 bytes, line 8
8"></script>
9<script>
10  
vendor: 133 bytes, lines 10-13
10window.dataLayer = window.dataLayer || [];
11  function gtag(){dataLayer.push(arguments);}
12  gtag('js', new Date());
13  gtag('config', '
13G-BZ8T9K9XYD
vendor: 4 bytes, line 13
13');
14</script>
14
15<!-- ================================================================ -->
16
17<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
18<meta name="description" content="On-line word-finder app">
19<meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- mobile-friendly -->
20<meta name="keywords" content="word finder anagram search puzzle">
21
22<title>Word Finder</title>
23
24<style>
25
26  /* BODY */
27  body {
28    color: black;
29    background-color: white;
30    font-family: Verdana, sans-serif;
31    font-size: 150%;
32    letter-spacing: 0.25px;
33    line-height: 125%;
34  }
35  h1 {
36    font-weight: bold;
37    color: maroon;
38    font-size: 110%;
39    text-align: center;
40    margin: 40px;
41  }
42  h2 {
43    font-weight: bold;
44    color: maroon;
45    font-size: 105%
46  }
47  h3 {
48    font-weight: bold;
49    color: maroon;
50    font-size: 100%
51  }
52  a:link {
53    color: maroon;
54  }
55  a:visited {
56    color: maroon;
57  }
58  a:active {
59    color: maroon;
60  }
61
62  .rounded {
63    border-radius: 4px;
64  }
65
66  .larger-input-text {
67    /* font-size: 100%; */
68    font-size: 0.8em;
69  }
70  .boxed-text {
71    border: 1px solid black;
72    padding-top: 4px;
73    padding-bottom: 4px;
74    padding-left: 8px;
75    padding-right: 8px;
76  }
77  .center {
78    text-align: center;
79  }
80  .maroon {
81    color: maroon;
82  }
83
84  /* TABLE */
85  table {
86    border: none;
87  }
88  /*
89  for debug
90  table {
91    border: 1px solid;
92    border-collapse: collapse;
93    border-color: #808080;
94  }
95  td {
96    border: 1px solid;
97    border-collapse: collapse;
98    border-color: #808080;
99  }
100  */
101
102  .smallertext {
103    font-size: 80%
104  }
105
106  /* ERROR-HANDLER */
107  .error {
108    color: red;
109    background-color: white;
110    padding: 10px;
111    margin: 10px;
112  }
113
114  /* TEXT INPUTS */
115  input {
116    font-weight: bold;
117    color: black;
118  }
119
120  /* BUTTONS */
121  button {
122    font-weight: bold;
123    background-color: #e8e8e8;
124    border: 0;
125    padding: 4px;
126    border-radius: 4px;
127    margin: 4px;
128  }
129  button:hover {
130    font-weight: bold;
131    background-color: #d8d8d8;
132    border: 0;
133    padding: 4px;
134    border-radius: 4px;
135    margin: 4px;
136  }
137  button:active {
138    font-weight: bold;
139    background-color: #c0c0c0;
140    border: 0;
141    padding: 4px;
142    border-radius: 4px;
143    margin: 4px;
144  }
145  button.selected {
146    font-weight: bold;
147  }
148  button.deselected {
149    font-weight: normal;
150  }
151  button.no-margin {
152    margin: 0px;
153  }
154  button.transparent {
155    font-weight: normal;
156    border: 0;
157    background-color: transparent;
158  }
159
160  /* MODALS */
161  /*
162    There's a trick here (found on the web).
163    o The modal background covers the *entire screen*.
164    o The modal content is a little box within that.
165  */
166  .modal-background {
167    display: none; /* Hidden by default */
168    position: fixed; /* Stay in place */
169    z-index: 1; /* Sit on top */
170    left: 0;
171    top: 0;
172    width: 100%; /* Entire screen */
173    height: 100%; /* Entire screen */
174    overflow: auto; /* Enable scroll if needed */
175    background-color: rgb(0, 0, 0); /* Fallback color in case alpha channel isn't supported */
176    background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity -- stuff "under" the modal is dimmed */
177  }
178  .modal-content {
179    background-color: white;
180    border-radius: 4px;
181    margin: 15% auto; /* 15% from the top and centered */
182    padding: 20px;
183    border: 2px solid gray;
184    width: 80%;
185  }
186
187  /* THEME SELECTOR */
188  .switch {
189    position: relative;
190    display: inline-block;
191    width: 30px;
192    height: 17px;
193  }
194  .switch input {
195    opacity: 0;
196    width: 0;
197    height: 0;
198  }
199  .slider {
200    position: absolute;
201    cursor: pointer;
202    top: 0;
203    left: 0;
204    right: 0;
205    bottom: 0;
206    background-color: #ccc;
207    -webkit-transition: .4s;
208    transition: .4s;
209  }
210  .slider:before {
211    position: absolute;
212    content: "";
213    height: 13px;
214    width: 13px;
215    left: 2px;
216    bottom: 2px;
217    background-color: white;
218    -webkit-transition: .4s;
219    transition: .4s;
220  }
221  input:checked + .slider {
222    background-color: #2196F3;
223  }
224  input:focus + .slider {
225    box-shadow: 0 0 1px #2196F3;
226  }
227  input:checked + .slider:before {
228    -webkit-transform: translateX(13px);
229    -ms-transform: translateX(13px);
230    transform: translateX(13px);
231  }
232  .slider.round {
233    border-radius: 17px;
234  }
235  .slider.round:before {
236    border-radius: 50%;
237  }
238
239</style>
240
241</head>
242
243<!-- ================================================================ -->
244<body>
245
246<h1><span class="boxed-text rounded">Word Finder</span></h1>
247
248<!--
249  This is a self-contained widget for error-text and cancel/X button.
250  We use Sliver's setErrorWidget to route top-level errors here where
251  they can be seen on the page.
252-->
253<span id="error-container" class="error">
254  <button
255    class=transparent
256    onClick='
257      document.getElementById("error-container").style.display = "none";
258      document.getElementById("error-text").innerHTML = "";
259    '
260  > &times; </button>
261  <span id="error-text"> </span>
262</span>
263
264<!-- fieldset allows a nice legend, of which I enjoy the styling -->
265<fieldset class="rounded">
266  <legend>
267    <button id="random-word-mode-button" class="no-margin" data-faro-user-action-name="mode-random"> Loading ... </button>
268    <button id="ngram-mode-button" class="no-margin" data-faro-user-action-name="mode-ngram"> Loading ... </button>
269    <button id="pattern-mode-button" class="no-margin" data-faro-user-action-name="mode-pattern"> Loading ... </button>
270    <button id="anagram-mode-button" class="no-margin" data-faro-user-action-name="mode-anagram"> Loading ... </button>
271    <button id="bee-mode-button" class="no-margin" data-faro-user-action-name="mode-bee"> Loading ... </button>
272  </legend>
273  <table>
274
275  <!-- Row for bee mode -->
276  <tr id="bee-mode-row-1">
277    <td> <button class="maroon" id="clear-letters-button"> Loading ... </button> </td>
278    <td>
279      <input
280        type=text
281        tabindex=1
282        placeholder="..."
283        size=10
284        autocapitalize="characters"
285        id="bee-mode-required-letters"
286        class="larger-input-text"
287      >
288    </td>
289    <td>&nbsp;</td>
290    <td class="smallertext"> Required letter(s) </td>
291  </tr>
292
293  <!-- Row for bee mode -->
294  <tr id="bee-mode-row-2">
295    <td> <button class="maroon" id="example-button">Loading ...</button> </td>
296    <td>
297      <input
298        type=text
299        tabindex=2
300        placeholder="..."
301        size=10
302        autocapitalize="characters"
303        id="bee-mode-other-letters"
304        class="larger-input-text"
305      >
306    </td>
307    <td>&nbsp;</td>
308    <td class="smallertext"> Other letter(s) </td>
309  </tr>
310
311  <!-- Row for bee mode -->
312  <tr id="bee-mode-row-3">
313    <td> <button class="maroon" id="all-others-button">Loading ...</button> </td>
314    <td> &nbsp; </td>
315    <td>&nbsp;</td>
316    <td> &nbsp; </td>
317  </tr>
318
319  <!-- Row for pattern mode -->
320  <tr id="pattern-mode-row-1">
321    <td> <button class="maroon" id="pattern-clear-letters-button"> Loading ... </button> </td>
322    <td>
323      <input
324        type=text
325        tabindex=1
326        placeholder="..."
327        size=10
328        id="pattern-mode-letters"
329        class="larger-input-text"
330      >
331    </td>
332    <td> &nbsp; </td>
333    <td class="smallertext"> Letters, including <code>.</code> and <code>*</code> </td>
334  </tr>
335
336  <!-- Row for anagram mode -->
337  <tr id="anagram-mode-row-1">
338    <td> <button class="maroon" id="anagram-clear-letters-button"> Loading ... </button> </td>
339    <td>
340      <input
341        type=text
342        tabindex=1
343        placeholder="..."
344        size=10
345        id="anagram-mode-letters"
346        class="larger-input-text"
347      >
348    </td>
349    <td> &nbsp; </td>
350    <td class="smallertext"> Letters </td>
351  </tr>
352
353  <!-- Row for random-word mode -->
354  <tr id="random-word-mode-row-1">
355    <td> <button class="maroon" id="random-word-generate-button"> Loading ... </button> </td>
356    <td style="width:3em">
357      <input type="number" id="random-word-count" style="width:5ch">
358    </td>
359    <td> &nbsp; </td>
360    <td class="smallertext"> Word count </td>
361  </tr>
362
363  <!-- Row for ngram mode -->
364  <tr id="ngram-mode-row-1">
365    <td> <button class="maroon" id="ngram-generate-button"> Loading ... </button> </td>
366    <td style="width:3em">
367      <input type="number" id="ngram-word-count-input" style="width:6ch">
368    </td>
369    <td> &nbsp; </td>
370    <td class="smallertext"> Word count </td>
371  </tr>
372
373  <!-- Row for ngram mode -->
374  <tr id="ngram-mode-row-2">
375    <td> &nbsp; </td>
376    <td style="width:3em">
377      <input type="number" id="ngram-n-input" style="width:6ch">
378    </td>
379    <td> &nbsp; </td>
380    <td class="smallertext"> The <i>n</i> in <i>n</i>-gram </td>
381  </tr>
382
383  <!-- Word list used by all modes -->
384  <tr>
385    <td colspan=2 style="text-align:right">
386      <select tabindex=3 id="word-list-dropdown">
387        <!--
388        These are helpful for testing out the ngram code in verbose mode:
389        <option value="./one-word.txt">One word (TEMP)</option>
390        <option value="./short-list.txt">Short list (TEMP)</option>
391        -->
392        <option value="./usual-suspects.txt">Usual suspects</option>
393        <option value="./words-200-swadesh.txt">English 200 (Swadesh)</option>
394        <option value="./words-2K.txt">English 2K (GSL)</option>
395        <option value="./words-10K.txt">English 10K</option>
396        <option value="./popular.txt" selected>English 25K (<tt>popular.txt</tt>)</option>
397        <option value="./words-40K-cumulative.txt">English 40K</option>
398        <option value="./enable1.txt" selected>English 173K (<tt>enable1.txt</tt>)</option>
399        <option value="./long-list.txt">English 300K</option>
400        <option value="./finnegans-words.txt">Finnegan</option>
401        <option value="./bahasa-indonesia.txt">Bahasa Indonesia</option>
402        <option value="./french.txt">French</option>
403        <option value="./german.txt">German</option>
404        <option value="./greek.txt">Greek</option>
405        <option value="./italian.txt">Italian</option>
406        <option value="./latin.txt">Latin</option>
407        <option value="./russian.txt">Russian</option>
408        <option value="./spanish.txt">Spanish</option>
409      </select>
410    </td>
411    <td>&nbsp;</td>
412    <td class="smallertext"> Word list </td>
413  </tr>
414
415  <!-- Min/max word lengths used by bee and pattern modes, but not anagram mode or random-word or ngram -->
416  <tr id="match-lengths-row">
417    <td> <button class="maroon" id="reset-match-lengths-button"> Reset </button> </td>
418    <td>
419      <span>
420        <input tabindex=4 type="number" id="min-match-length" name="min-match-length" style="width:4ch">
421        <input tabindex=5 type="number" id="max-match-length" name="max-match-length" style="width:4ch">
422      </span>
423    </td>
424    <td>&nbsp;</td>
425    <td class="smallertext"> <label>Min/max word lengths</label> </td>
426  </tr>
427
428  <tr>
429    <td>
430      <div>
431        <button id="random-word-mode-help-launcher-button"> Help </button>
432        <button id="ngram-mode-help-launcher-button"> Help </button>
433        <button id="pattern-mode-help-launcher-button"> Help </button>
434        <button id="anagram-mode-help-launcher-button"> Help </button>
435        <button id="bee-mode-help-launcher-button"> Help </button>
436      </div>
437    </td>
438    <td> &nbsp; </td>
439    <td> &nbsp; </td>
440  </tr>
441
442  </table>
443</fieldset>
444
445<br>
446<div>
447  <button class="maroon" id="output-show-hide-button"> Loading ... </button>
448</div>
449<div>
450  <span id="output" style="display:block"> Loading ... </span>
451</div>
452<div>
453  <span id="output-count" style="display:none"> Loading ... </span>
454</div>
455
456<!-- ================================================================ -->
457<hr>
458
459<div class=smallertext>
460  Source and documentation:
461  <br>
462  <a href="https://github.com/johnkerl/word-finder/blob/main/README.md">github.com/johnkerl/word-finder</a>
463</div>
464<br>
465
466<div class=smallertext>
467  <label class="switch">
468    <input type="checkbox" id="light-theme-dark-theme-toggle">
469    <span class="slider round"></span>
470  </label>
471  <span id="light-theme-dark-theme-label">Loading ...</span>
472</div>
473<br>
474
475<!-- ================================================================ -->
476
477<!-- Modal content -->
478
479<div id="random-word-mode-help-background" class="modal-background">
480  <div id="random-word-mode-help-content" class="modal-content">
481    <div>
482      <button id="random-word-mode-help-close-button" class="general white-on-green" > Close </button>
483    </div>
484    <div>
485
486    <p class="maroon">Help for random-word mode:</p>
487
488    <p> Select the word list.</p>
489    <p> Select how many words to generate at a time.</p>
490    <p> Use the Generate button to get a new random sample.</p>
491
492    </div>
493  </div>
494</div>
495
496<div id="ngram-mode-help-background" class="modal-background">
497  <div id="ngram-mode-help-content" class="modal-content">
498    <div>
499      <button id="ngram-mode-help-close-button" class="general white-on-green" > Close </button>
500    </div>
501    <div>
502
503    <p class="maroon">Help for Jabberwocky mode:</p>
504
505    <p> Select the word list.</p>
506    <p> Select how many words to generate at a time.</p>
507    <p> Select the <i>n</i> for <a href="https://en.wikipedia.org/wiki/N-gram" target="_blank">n-grams</a>:
508      the higher the <i>n</i>, the more closely the emitted words will resemble those in the word list.
509    </p>
510    <p> Use the Generate button to get a new random sample.
511      The words produced will be created by splicing together parts of real words in word list.
512    </p>
513    <p>(The name is taken from Lewis Carroll&rsquo;s famous poem
514      <a href="https://en.wikipedia.org/wiki/Jabberwocky" target="_blank"><i>Jabberwocky</i></a>.
515      If <i>Jabberwocky</i>&rsquo;s 71 words leave you wanting more, see also
516      <a href="https://en.wikipedia.org/wiki/Finnegans_Wake" target="_blank"><i>Finnegans Wake</i></a>.)
517    </p>
518
519    </div>
520  </div>
521</div>
522
523<div id="pattern-mode-help-background" class="modal-background">
524  <div id="pattern-mode-help-content" class="modal-content">
525    <div>
526      <button id="pattern-mode-help-close-button" class="general white-on-green" > Close </button>
527    </div>
528    <div>
529
530    <p class="maroon">Help for pattern mode:</p>
531
532    <p> Enter your desired letters in the <code>Letters</code> field.</p>
533
534    <p> Select the word list you want to search in.</p>
535
536    <p> You can use a <code>.</code> as a wildcard to match any single letter. For example, <code>LE.T</code>
537    matches <code>LEFT</code>, <code>LENT</code>, <code>LEST</code>, etc.</p>
538
539    <p> You can use a <code>*</code> as a wildcard to match zero or more of any letter. For example:</p>
540    <ul>
541      <li><code>OBOLUS</code> shows the one word <code>OBOLUS</code>, as a dictionary lookup</li>
542      <li><code>THR*</code> shows all words that begin with <code>THR</code></li>
543      <li><code>*EST</code> shows all words that end with <code>EST</code></li>
544      <li><code>TH*TH</code> shows all words that begin and end with <code>TH</code> and</li>
545      <li><code>*NIMB*</code> shows all words that have the sequence <code>NIMB</code> anywhere within.</li>
546    </ul>
547
548    <p> You can further restrict your results by setting minimum and maximum word lengths. </p>
549
550  </div>
551  </div>
552</div>
553
554<div id="anagram-mode-help-background" class="modal-background">
555  <div id="anagram-mode-help-content" class="modal-content">
556    <div>
557      <button id="anagram-mode-help-close-button" class="general white-on-green" > Close </button>
558    </div>
559    <div>
560
561    <p class="maroon">Help for anagram mode:</p>
562
563    <p> Enter your desired letters in the Letters field.</p>
564
565    <p> Select the word list you want to search in.</p>
566
567    <p> Results shown will be <a href="https://en.wikipedia.org/wiki/Anagram"
568    target="_blank">anagrams</a> of the letters you provide. For example, <code>ELATES</code> will
569    match <code>SEALET</code> but not <code>ELATE</code> or <code>SEALETS</code>.  </p>
570
571    <p> Some words with many anagrams include <code>STOP</code>, <code>SLATE</code>,
572    <code>PEARS</code>, and <code>TRIANGLE</code>.</p>
573
574    </div>
575  </div>
576</div>
577
578<div id="bee-mode-help-background" class="modal-background">
579  <div id="bee-mode-help-content" class="modal-content">
580    <div>
581      <button id="bee-mode-help-close-button" class="general white-on-green" > Close </button>
582    </div>
583    <div>
584
585    <p class="maroon">Help for bee mode:</p>
586
587      <p>Enter your required letters and your other letters. (For the NYT Spelling Bee,
588      this will be the center letter and the surrounding letters, respectively.)
589
590      <p>Choose the word list from the dropdown.</p>
591      <ul>
592
593        <li><i>Usual suspects</i> is my own short, curated list of once-bitten-twice-shy values from
594        previous Bees.</li>
595
596        <li><i>Long list</i> contains over 300,000 words, not all of which are in the NYT Spelling
597        Bee. (That is, this app can show words that the NYT Spelling Bee doesn&rsquo;t accept.)</li>
598
599        <li>You can use the 2K through 40K lists to adjust whether or not to see less-common words.</li>
600
601      </ul>
602
603      <p>The output shown will include all words from the selected word list which contain only your
604      required letters and your other letters:</p>
605      <ul>
606
607        <li>If the required letter is <code>P</code> and the other letters are <code>AELM</code>,
608        then you&rsquo;ll see <code>PALM</code> and <code>PALE</code> but not
609        <code>MEAL</code>.</li>
610
611        <li>If the required letter is <code>M</code> and the other letters are <code>AELP</code>,
612        then you&rsquo;ll see <code>MALL</code> and <code>MEAL</code> but not
613        <code>PALE</code>.</li>
614
615        <li>If the required letters are left empty and the other letters are <code>AELPM</code>,
616        then you&rsquo;ll see words including <code>MALL</code>, <code>MEAL</code>,
617        <code>PALE</code>, <code>MAPLE</code>, and <code>EEL</code>.  </li>
618
619      </ul>
620
621      <p> You can further restrict your results by setting minimum and maximum word lengths.
622
623    </div>
624  </div>
625</div>
626
627<!-- ================================================================ -->
628<script type=module>
629  'use strict';
630
631  import { getWebInstrumentations, initializeFaro } from 'https://esm.sh/@grafana/[email protected]';
632  import { TracingInstrumentation } from 'https://esm.sh/@grafana/[email protected]';
633
634  const faro = initializeFaro({
635    url: 'https://faro-collector-prod-us-east-3.grafana.net/collect/5a322a8a02b13a3c06b8884cc4b52076',
636    app: {
637      name: 'johnkerl-org-word-finder-1',
638      version: '1.0.0',
639      environment: 'production'
640    },
641
642    experimental: { trackNavigation: true },
643
644    ignoreUrls: [
645      /googletagmanager\.com/,
646      /google-analytics\.com/,
647    ],
648
649    ignoreErrors: [
650      /^ResizeObserver loop limit exceeded$/,
651      /^ResizeObserver loop completed with undelivered notifications$/,
652      /^Script error\.$/,
653      /chrome-extension:\/\//,
654      /moz-extension:\/\//,
655    ],
656
657    instrumentations: [
658      // Mandatory, omits default instrumentations otherwise.
659      ...getWebInstrumentations(),
660
661      // Tracing package to get end-to-end visibility for HTTP requests.
662      new TracingInstrumentation(),
663    ],
664  });
665
666  import {
667    Button,
668    ConstrainedTextInput,
669    GenericElement,
670    HelpWidgets,
671    LightDarkThemeSlider,
672    OneButtonSwitcher,
673    PersistentDropdown,
674    PersistentIntRangeInput,
675    URLAndPersistentNButtonSwitcher,
676    TextSpan,
677    isInteger,
678    setErrorWidget,
679  // } from "http://127.0.1:9999/sliver/sliver.js"
680  // nohup npx http-server ~/pub_http_internet -o -p 9999 &
681  // } from "https://johnkerl.org/sliver/sliver.js"
682  } from "./sliver-2025-08-12.js" // local copy, with cache-bust
683
684  import {
685    NGramsKeeper,
686  } from "./ngrams.js"
687
688  // ----------------------------------------------------------------
689  setErrorWidget("error-container", "error-text")
690
691  // ----------------------------------------------------------------
692  class WordFinderApp {
693
694    constructor() {
695
696      this.state = {
697        "logLevel": 0, // 0,1,2,3
698
699        "defaultLowMatchLength": 4,
700        "defaultHighMatchLength": 16,
701        "minMatchLength": 1,
702        "maxMatchLength": 30,
703
704        "defaultRandomWordCount": 5,
705        "minRandomWordCount": 1,
706        "maxRandomWordCount": 100,
707
708        "defaultNGramN": 4,
709        "minNGramN": 2,
710        "maxNGramN": 8,
711
712        "defaultNGramWordCount": 5,
713        "minNGramWordCount": 1,
714        "maxNGramWordCount": 100,
715
716        "mode": null,
717
718        "ngramsKeeper": null,
719
720        "wordListURL": null,
721        "wordList": null,
722        "wordListForAnagramMode": null, // Letters are sorted within words
723      }
724
725      this.setElementsAndCallbacks()
726      this.setWordList(this.elements.wordListDropdown.get())
727
728      if (this.elements.modeButtons.which() == 'bee-mode-button') {
729        this.elements.requiredLettersElement.focus()
730      } else if (this.elements.modeButtons.which() == 'anagram-mode-button') {
731        this.elements.anagramLettersElement.focus()
732      } else if (this.elements.modeButtons.which() == 'pattern-mode-button') {
733        this.elements.patternLettersElement.focus()
734      } else if (this.elements.modeButtons.which() == 'random-word-mode-button') {
735        this.elements.randomWordGenerateButton.focus()
736      } else if (this.elements.modeButtons.which() == 'ngram-mode-button') {
737        this.elements.ngramGenerateButton.focus()
738      }
739
740    }
741
742    setElementsAndCallbacks() {
743      this.elements = {}
744
745      this.elements.outputElement = new TextSpan("output", "")
746      this.elements.outputCountElement = new TextSpan("output-count", "")
747
748      // Bee-mode element
749      this.elements.requiredLettersElement = new ConstrainedTextInput(
750        "bee-mode-required-letters",
751        (text) => text.toUpperCase(),
752        //(text) => text,
753        (event) => {
754          this.logAt1("required-letters input: <<", event.target.value, ">>")
755          this.showBeeModeMatches()
756      })
757
758      // Bee-mode element
759      this.elements.otherLettersElement = new ConstrainedTextInput(
760        "bee-mode-other-letters",
761        (text) => text.toUpperCase(),
762        //(text) => text,
763        (event) => {
764          this.logAt1("other-letters input: <<", event.target.value, ">>")
765          this.showBeeModeMatches()
766      })
767
768      // Pattern-mode element
769      this.elements.patternLettersElement = new ConstrainedTextInput(
770        "pattern-mode-letters",
771        //(text) => text.toUpperCase(),
772        (text) => text,
773        (event) => {
774          this.logAt1("pattern-letters input: <<", event.target.value, ">>")
775          this.showPatternModeMatches()
776      })
777
778      // Anagram-mode element
779      this.elements.anagramLettersElement = new ConstrainedTextInput(
780        "anagram-mode-letters",
781        //(text) => text.toUpperCase(),
782        (text) => text,
783        (event) => {
784          this.logAt1("anagram-letters input: <<", event.target.value, ">>")
785          this.showAnagramModeMatches()
786      })
787
788      this.elements.wordListDropdown = new PersistentDropdown(
789        "word-list-dropdown",
790        (event) => {
791          let url = event.target.value
792          this.logAt1("selected word list: <<", url, ">>")
793          this.setWordList(url)
794          this.showMatches()
795      })
796
797      let minMatchLengthElement = new PersistentIntRangeInput(
798        "min-match-length",
799        this.state.defaultLowMatchLength, // default
800        this.state.minMatchLength, // min
801        this.state.maxMatchLength, // max
802        (event) => {
803          this.showMatches()
804        },
805      )
806
807      let maxMatchLengthElement = new PersistentIntRangeInput(
808        "max-match-length",
809        this.state.defaultHighMatchLength, // default
810        this.state.minMatchLength, // min
811        this.state.maxMatchLength, // max
812        (event) => {
813          this.showMatches()
814        },
815      )
816
817      // TODO: do this coupling at the class level -- IntRangeInputPair maybe -- with resetter
818      minMatchLengthElement.setPeerMax(maxMatchLengthElement)
819      maxMatchLengthElement.setPeerMin(minMatchLengthElement)
820
821      this.elements.minMatchLengthElement = minMatchLengthElement
822      this.elements.maxMatchLengthElement = maxMatchLengthElement
823
824      // Bee-mode element
825      this.elements.clearLettersButton = new Button(
826        "clear-letters-button",
827        "Clear",
828        (event) => {
829          this.logAt1("clear-letters button was clicked")
830          this.elements.requiredLettersElement.set("")
831          this.elements.otherLettersElement.set("")
832          this.showBeeModeMatches()
833        },
834      )
835
836      // Bee-mode element
837      this.elements.exampleButton = new Button(
838        "example-button",
839        "Example",
840        (event) => {
841          this.logAt1("example button was clicked")
842
843          this.elements.requiredLettersElement.set("E")
844          this.elements.otherLettersElement.set("XAMPLS")
845          this.elements.outputElement.set("")
846
847          this.showBeeModeMatches()
848        },
849      )
850
851      // Bee-mode element
852      this.elements.allOthersButton = new Button(
853        "all-others-button",
854        "A-Z",
855        (event) => {
856          this.logAt1("A-Z button was clicked")
857
858          this.elements.requiredLettersElement.set("")
859          this.elements.otherLettersElement.set("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
860          this.elements.outputElement.set("")
861
862          this.showBeeModeMatches()
863        },
864      )
865
866      // Pattern-mode element
867      this.elements.patternClearLettersButton = new Button(
868        "pattern-clear-letters-button",
869        "Clear",
870        (event) => {
871          this.logAt1("pattern-clear-letters button was clicked")
872          this.elements.patternLettersElement.set("")
873          this.showPatternModeMatches()
874        },
875      )
876
877      // Anagram-mode element
878      this.elements.anagramClearLettersButton = new Button(
879        "anagram-clear-letters-button",
880        "Clear",
881        (event) => {
882          this.logAt1("anagram-clear-letters button was clicked")
883          this.elements.anagramLettersElement.set("")
884          this.showAnagramModeMatches()
885        },
886      )
887
888      // Random-word-mode element
889      this.elements.randomWordGenerateButton = new Button(
890        "random-word-generate-button",
891        "Generate",
892        (event) => {
893          this.logAt1("random-word-generate button was clicked")
894          this.generateRandomWords()
895        },
896      )
897
898      // Random-word-mode element
899      this.elements.randomWordCountInput = new PersistentIntRangeInput(
900        "random-word-count",
901        this.state.defaultRandomWordCount, // default
902        this.state.minRandomWordCount, // min
903        this.state.maxRandomWordCount, // max
904        (event) => {
905          this.generateRandomWords()
906        },
907      )
908
909      // ngram-mode element
910      this.elements.ngramGenerateButton = new Button(
911        "ngram-generate-button",
912        "Generate",
913        (event) => {
914          this.logAt0("ngram-generate button was clicked")
915          // Do not recompute the NGramsKeeper -- re-use it
916          this.generateNGramWords()
917        },
918      )
919
920      // ngram-mode element
921      this.elements.ngramWordCountInput = new PersistentIntRangeInput(
922        "ngram-word-count-input",
923        this.state.defaultNGramWordCount, // default
924        this.state.minNGramWordCount, // min
925        this.state.maxNGramWordCount, // max
926        (event) => {
927          this.logAt0("ngram-word-count input was selected")
928          this.generateNGramWords()
929        },
930      )
931
932      // ngram-mode element
933      console
934      this.elements.ngramNInput = new PersistentIntRangeInput(
935        "ngram-n-input",
936        this.state.defaultNGramN, // default
937        this.state.minNGramN, // min
938        this.state.maxNGramN, // max
939        (event) => {
940          this.logAt0("ngram-n input was selected")
941          // Recompute the NGramsKeeper
942          this.state.ngramsKeeper = null
943          this.generateNGramWords()
944        },
945      )
946
947      this.elements.resetMatchLengthsButton = new Button(
948        "reset-match-lengths-button",
949        "Reset",
950        (event) => {
951          this.logAt1("reset-match-lengths button was clicked")
952          this.elements.minMatchLengthElement.resetToDefault(),
953          this.elements.maxMatchLengthElement.resetToDefault(),
954          this.showMatches()
955        },
956      )
957
958      this.outputShowHideButton = new OneButtonSwitcher(
959        "output-show-hide-button",
960        [this.elements.outputElement],
961        [this.elements.outputCountElement],
962        "Hide results",
963        "Show results",
964        () => {},
965      )
966
967      this.elements.matchLengthsRow = new GenericElement("match-lengths-row")
968
969      this.elements.modeButtons = new URLAndPersistentNButtonSwitcher( {
970
971          "random-word-mode-button": {
972            "text": "Random",
973            "items": [
974              new GenericElement("random-word-mode-row-1"),
975              new GenericElement("random-word-mode-help-launcher-button"),
976            ],
977            "callback": () => {
978              if (this.state.mode == "random-word-mode") {
979                return
980              }
981              this.state.mode = "random-word-mode";
982
983              this.state.ngramsKeeper = null
984              this.elements.matchLengthsRow.makeInvisible()
985
986              // The tab button being clicked gets the focus.
987              // So we have to delay.
988              // this.elements.randomWordGenerateButton.focus()
989              setTimeout(
990                () => {
991                  this.elements.randomWordGenerateButton.focus()
992                },
993                200,
994              )
995
996              this.generateRandomWords()
997            },
998            "urlShorthand": "random",
999          },
1000
1001          "ngram-mode-button": {
1002            "text": "Jabber",
1003            "items": [
1004              new GenericElement("ngram-mode-row-1"),
1005              new GenericElement("ngram-mode-row-2"),
1006              new GenericElement("ngram-mode-help-launcher-button"),
1007            ],
1008            "callback": () => {
1009              if (this.state.mode == "ngram-mode") {
1010                return
1011              }
1012              this.state.mode = "ngram-mode";
1013
1014              this.logAt0("swtich to ngram mode")
1015              this.elements.matchLengthsRow.makeInvisible()
1016
1017              // The tab button being clicked gets the focus.
1018              // So we have to delay.
1019              // this.elements.ngramGenerateButton.focus()
1020              setTimeout(
1021                () => {
1022                  this.elements.ngramGenerateButton.focus()
1023                },
1024                200,
1025              )
1026
1027              this.state.ngramsKeeper = null
1028              this.generateNGramWords()
1029            },
1030            "urlShorthand": "jabber",
1031          },
1032
1033          "pattern-mode-button": {
1034            "text": "Pattern",
1035            "items": [
1036              new GenericElement("pattern-mode-row-1"),
1037              new GenericElement("pattern-mode-help-launcher-button"),
1038            ],
1039            "callback": () => {
1040              if (this.state.mode == "pattern-mode") {
1041                return
1042              }
1043              this.state.mode = "pattern-mode";
1044
1045              this.state.ngramsKeeper = null
1046              this.elements.matchLengthsRow.makeVisible()
1047
1048              // The tab button being clicked gets the focus.
1049              // So we have to delay.
1050              // this.elements.patternLettersElement.focus()
1051              setTimeout(
1052                () => {
1053                  this.elements.patternLettersElement.focus()
1054                },
1055                200,
1056              )
1057
1058              this.showMatches()
1059            },
1060            "urlShorthand": "pattern",
1061          },
1062
1063          "anagram-mode-button": {
1064            "text": "Anagram",
1065            "items": [
1066              new GenericElement("anagram-mode-row-1"),
1067              new GenericElement("anagram-mode-help-launcher-button"),
1068            ],
1069            "callback": () => {
1070              if (this.state.mode == "anagram-mode") {
1071                return
1072              }
1073              this.state.mode = "anagram-mode";
1074
1075              this.state.ngramsKeeper = null
1076              this.elements.matchLengthsRow.makeInvisible()
1077
1078              // The tab button being clicked gets the focus.
1079              // So we have to delay.
1080              // this.elements.anagramLettersElement.focus()
1081              setTimeout(
1082                () => {
1083                  this.elements.anagramLettersElement.focus()
1084                },
1085                200,
1086              )
1087
1088              this.showMatches()
1089            },
1090            "urlShorthand": "anagram",
1091          },
1092
1093          "bee-mode-button": {
1094            "text": "Bee",
1095            "items": [
1096              new GenericElement("bee-mode-row-1"),
1097              new GenericElement("bee-mode-row-2"),
1098              new GenericElement("bee-mode-row-3"),
1099              new GenericElement("bee-mode-help-launcher-button"),
1100            ],
1101            "callback": () => {
1102              if (this.state.mode == "bee-mode") {
1103                return
1104              }
1105              this.state.mode = "bee-mode";
1106
1107              this.state.ngramsKeeper = null
1108              this.elements.matchLengthsRow.makeVisible()
1109
1110              // The tab button being clicked gets the focus.
1111              // So we have to delay.
1112              // this.elements.requiredLettersElement.focus()
1113              setTimeout(
1114                () => {
1115                  this.elements.requiredLettersElement.focus()
1116                },
1117                200,
1118              )
1119
1120              this.showMatches()
1121            },
1122            "urlShorthand": "bee",
1123          },
1124
1125        },
1126        "selected",   // CSS style class
1127        "deselected", // CSS style class
1128      )
1129
1130
1131      // ----------------------------------------------------------------
1132      this.elements.help = {}
1133
1134      this.elements.help.random = new HelpWidgets(
1135        "random-word-mode-help-background",
1136        "random-word-mode-help-content",
1137        "random-word-mode-help-launcher-button",
1138        "random-word-mode-help-close-button",
1139      )
1140
1141      this.elements.help.ngram = new HelpWidgets(
1142        "ngram-mode-help-background",
1143        "ngram-mode-help-content",
1144        "ngram-mode-help-launcher-button",
1145        "ngram-mode-help-close-button",
1146      )
1147
1148      this.elements.help.pattern = new HelpWidgets(
1149        "pattern-mode-help-background",
1150        "pattern-mode-help-content",
1151        "pattern-mode-help-launcher-button",
1152        "pattern-mode-help-close-button",
1153      )
1154
1155      this.elements.help.anagram = new HelpWidgets(
1156        "anagram-mode-help-background",
1157        "anagram-mode-help-content",
1158        "anagram-mode-help-launcher-button",
1159        "anagram-mode-help-close-button",
1160      )
1161
1162      this.elements.help.bee = new HelpWidgets(
1163        "bee-mode-help-background",
1164        "bee-mode-help-content",
1165        "bee-mode-help-launcher-button",
1166        "bee-mode-help-close-button",
1167      )
1168
1169      // ----------------------------------------------------------------
1170      // This is a hack to get the escape key to close the help modal on desktop.
1171      document.addEventListener('keydown', function(event) {
1172        if (event.key === 'Escape') {
1173          Object.keys(window.app.elements.help).forEach((id) => {
1174            window.app.elements.help[id].makeBackgroundInvisible()
1175          })
1176        }
1177      });
1178
1179      // ----------------------------------------------------------------
1180      // This is a hack. I need to research CSS theming.
1181      this.elements.lightDarkThemeElement = new LightDarkThemeSlider(
1182        "light-theme-dark-theme-toggle",
1183        "light-theme-dark-theme-label",
1184        (event) => {
1185          document.body.style.backgroundColor = "white"
1186          document.body.style.color = "black"
1187
1188          Object.keys(this.elements.help).forEach((id) => {
1189            this.elements.help[id].setColors("black", "white")
1190          })
1191
1192        },
1193        (event) => {
1194          document.body.style.backgroundColor = "black"
1195          document.body.style.color = "white"
1196
1197          Object.keys(this.elements.help).forEach((id) => {
1198            this.elements.help[id].setColors("white", "black")
1199          })
1200
1201        },
1202      )
1203
1204    }
1205
1206    checkWordList() {
1207      if (this.state.wordListURL == null) {
1208        this.elements.outputElement.set("No word list set")
1209        return false
1210      }
1211      if (this.state.wordList == null) {
1212        this.elements.outputElement.set('Failed to fetch word list from "' + this.state.wordListURL + '"')
1213        return false
1214      }
1215      return true
1216    }
1217
1218    setWordList(url) {
1219      this.logAt0("fetching word list from URL:", url)
1220      this.state.wordListURL = url
1221      faro.api?.pushEvent('word_list_selected', { list: url.split('/').pop().replace('.txt', '') })
1222      fetch(url).then((response) => response.text().then((text) => {
1223        this.state.wordList = text.split("\n")
1224        this.setWordListForAnagramMode()
1225        // Must be called from this callback, since this is all async
1226        this.showMatches()
1227      }))
1228    }
1229
1230    // Requires this.wordList to have already been computed.
1231    // This precomputes sorting each word by its letter, which is a performance optimization.
1232    setWordListForAnagramMode() {
1233      this.wordListForAnagramMode = {}
1234      this.state.wordList.forEach((word) => {
1235        this.wordListForAnagramMode[word] = word.split('').sort().join('')
1236      })
1237    }
1238
1239    showMatches() {
1240      if (this.elements.modeButtons == null) {
1241        // At app initialization
1242        return
1243      }
1244
1245      if (this.elements.modeButtons.which() == 'bee-mode-button') {
1246        this.showBeeModeMatches()
1247      } else if (this.elements.modeButtons.which() == 'pattern-mode-button') {
1248        this.showPatternModeMatches()
1249      } else if (this.elements.modeButtons.which() == 'anagram-mode-button') {
1250        this.showAnagramModeMatches()
1251      } else if (this.elements.modeButtons.which() == 'random-word-mode-button') {
1252        this.generateRandomWords()
1253      } else if (this.elements.modeButtons.which() == 'ngram-mode-button') {
1254        this.state.ngramsKeeper = null
1255        this.generateNGramWords()
1256      }
1257    }
1258
1259    showBeeModeMatches() {
1260      if (!this.checkWordList()) {
1261        return
1262      }
1263
1264      let requiredLetters = this.elements.requiredLettersElement.get()
1265      let otherLetters = this.elements.otherLettersElement.get()
1266
1267      let matches = this.getBeeMatchesFromWordList(requiredLetters, otherLetters)
1268      matches = matches.map(match => match.toUpperCase())
1269      matches = matches.sort()
1270      this.logAt2(matches)
1271
1272      this.elements.outputCountElement.set("(" + String(matches.length) + " hidden)")
1273
1274      if (requiredLetters.length > 0 || otherLetters.length > 0) {
1275        faro.api?.pushEvent('search', { mode: 'bee', results: String(matches.length) })
1276      }
1277
1278      if (requiredLetters.length == 0 && otherLetters.length == 0) {
1279        this.elements.outputElement.set("")
1280      } else if (matches.length == 0) {
1281        this.elements.outputElement.set("(No matches)")
1282      } else {
1283        this.elements.outputElement.set(matches.join(" "))
1284      }
1285    }
1286
1287    getBeeMatchesFromWordList(requiredLetters, optionalLetters) {
1288      this.logAt1("matcher enter")
1289      this.logAt1("requiredLetters ", requiredLetters)
1290      this.logAt1("optionalLetters", optionalLetters)
1291      this.logAt1("wordList.length", this.state.wordList.length)
1292      this.logAt3("wordList", this.state.wordList)
1293
1294      requiredLetters = requiredLetters.toLowerCase()
1295      optionalLetters = optionalLetters.toLowerCase()
1296      let minMatchLength = this.elements.minMatchLengthElement.get()
1297      let maxMatchLength = this.elements.maxMatchLengthElement.get()
1298      let matches = []
1299
1300      this.state.wordList.forEach((word) => {
1301        this.logAt3("word <<", word, ">>")
1302        if (this.wordBeeMatches(requiredLetters, optionalLetters, minMatchLength, maxMatchLength, word)) {
1303          this.logAt2("match", word)
1304          matches.push(word)
1305        }
1306      })
1307      return matches
1308    }
1309
1310    wordBeeMatches(requiredLetters, optionalLetters, minMatchLength, maxMatchLength, word) {
1311      let allLetters = requiredLetters + optionalLetters
1312
1313      if (word.length < minMatchLength) {
1314        this.logAt3("-- match out 1: word", word, "with length", word.length, "less than min", minMatchLength)
1315        return false
1316      }
1317      if (word.length > maxMatchLength) {
1318        this.logAt3("-- match out 2: word", word, "with length", word.length, "more than max", maxMatchLength)
1319        return false
1320      }
1321      for (let i = 0; i < requiredLetters.length; i++) {
1322        let requiredLetter = requiredLetters[i]
1323        if (word.indexOf(requiredLetter) < 0) {
1324          this.logAt3("-- match out 3: word", word, "missing required letter", requiredLetter, "from", requiredLetters)
1325          return false
1326        }
1327      }
1328      for (let i = 0; i < word.length; i++) {
1329        let letter = word[i]
1330        if (allLetters.indexOf(letter) < 0) {
1331          this.logAt3("-- match out 4: word", word, "missing letter", letter, "from", allLetters)
1332          return false
1333        }
1334      }
1335      this.logAt2("-- match:", word)
1336      return true
1337    }
1338
1339    regexify(patternLetters) {
1340      // Regex mapping:
1341      // o A-Z -> self
1342      // o . -> .
1343      // o * -> .*
1344      // o Wrap in ^ and $
1345
1346      this.logAt1("regex in ", patternLetters)
1347      let regexString = "^"
1348      let azre = new RegExp("^[A-Za-z]$")
1349      for (let i = 0; i < patternLetters.length; i++) {
1350        let patternLetter = patternLetters[i]
1351        this.logAt1("  reqlt", patternLetter)
1352        if (patternLetter == ".")  {
1353          regexString += "."
1354        } else if (patternLetter == "*")  {
1355          regexString += ".*"
1356        } else if (azre.exec(patternLetter)) {
1357          regexString += patternLetter.toLowerCase()
1358        }
1359      }
1360      regexString += "$"
1361      this.logAt1("regex out", regexString)
1362
1363      return new RegExp(regexString)
1364    }
1365
1366    showPatternModeMatches() {
1367      if (!this.checkWordList()) {
1368        return
1369      }
1370      let patternLetters = this.elements.patternLettersElement.get()
1371
1372      let matches = this.getPatternMatchesFromWordList(patternLetters)
1373      // matches = matches.map(match => match.toUpperCase())
1374      matches = matches.sort()
1375      this.logAt2(matches)
1376
1377      this.elements.outputCountElement.set("(" + String(matches.length) + " hidden)")
1378      if (patternLetters.length > 0) {
1379        faro.api?.pushEvent('search', { mode: 'pattern', results: String(matches.length) })
1380      }
1381      if (patternLetters.length == 0) {
1382        this.elements.outputElement.set("")
1383      } else if (matches.length == 0) {
1384        this.elements.outputElement.set("(No matches)")
1385      } else {
1386        this.elements.outputElement.set(matches.join(" "))
1387      }
1388    }
1389
1390    getPatternMatchesFromWordList(patternLetters) {
1391      this.logAt1("matcher enter")
1392      this.logAt1("patternLetters ", patternLetters)
1393      this.logAt1("wordList.length", this.state.wordList.length)
1394      this.logAt3("wordList", this.state.wordList)
1395
1396      patternLetters = patternLetters.toLowerCase()
1397      let re = this.regexify(patternLetters)
1398
1399      let matches = []
1400      let minMatchLength = this.elements.minMatchLengthElement.get()
1401      let maxMatchLength = this.elements.maxMatchLengthElement.get()
1402      this.state.wordList.forEach((word) => {
1403        if (word.length < minMatchLength) {
1404        } else if (word.length > maxMatchLength) {
1405        } else if (re.exec(word)) {
1406          matches.push(word)
1407        }
1408      })
1409      return matches
1410    }
1411
1412    showAnagramModeMatches() {
1413      if (!this.checkWordList()) {
1414        return
1415      }
1416      let anagramLetters = this.elements.anagramLettersElement.get()
1417
1418      let matches = this.getAnagramMatchesFromWordList(anagramLetters)
1419      //matches = matches.map(match => match.toUpperCase())
1420      matches = matches.sort()
1421      this.logAt2(matches)
1422
1423      this.elements.outputCountElement.set("(" + String(matches.length) + " hidden)")
1424      if (anagramLetters.length > 0) {
1425        faro.api?.pushEvent('search', { mode: 'anagram', results: String(matches.length) })
1426      }
1427      if (anagramLetters.length == 0) {
1428        this.elements.outputElement.set("")
1429      } else if (matches.length == 0) {
1430        this.elements.outputElement.set("(No matches)")
1431      } else {
1432        this.elements.outputElement.set(matches.join(" "))
1433      }
1434    }
1435
1436    getAnagramMatchesFromWordList(anagramLetters) {
1437      this.logAt1("matcher enter")
1438      this.logAt1("anagramLetters ", anagramLetters)
1439      this.logAt1("wordList.length", this.state.wordList.length)
1440      this.logAt3("wordList", this.state.wordList)
1441
1442      let sortedInput = anagramLetters.toLowerCase().split('').sort().join('');
1443
1444      let matches = []
1445      for (const [word, sortedWord] of Object.entries(this.wordListForAnagramMode)) {
1446        this.logAt3("-- matcher check", word, " vs ", anagramLetters)
1447        if (sortedWord == sortedInput) {
1448          matches.push(word)
1449        }
1450      }
1451      return matches
1452    }
1453
1454    generateRandomWords() {
1455      if (!this.checkWordList()) {
1456        return
1457      }
1458
1459      let matches = []
1460      // TODO: sample without replacement.
1461      // In practice, dups are super-rare.
1462      let k = this.elements.randomWordCountInput.get()
1463      let n = this.state.wordList.length
1464      for (let i = 0; i < k; i++) {
1465          let randomIndex = Math.floor(Math.random() * n);
1466          let word = this.state.wordList[randomIndex]
1467          matches.push(word)
1468      }
1469
1470      this.elements.outputCountElement.set("(" + String(matches.length) + " hidden)")
1471      this.elements.outputElement.set(matches.join(" "))
1472      faro.api?.pushEvent('generate', { mode: 'random' })
1473    }
1474
1475    setUpNGramsKeeper() {
1476      if (this.state.ngramsKeeper != null) {
1477        return true
1478      }
1479
1480      if (!this.checkWordList()) {
1481        // Initialization/constructor sequencing
1482        return false
1483      }
1484
1485      let n = this.elements.ngramNInput.get()
1486      this.state.ngramsKeeper = new NGramsKeeper(
1487        this.state.wordList,
1488        n,
1489      )
1490
1491      return true
1492    }
1493
1494    generateNGramWords() {
1495      if (!this.setUpNGramsKeeper()) {
1496        return
1497      }
1498
1499      let outputs = []
1500      let m = this.elements.ngramWordCountInput.get()
1501      for (let i = 0; i < m; i++) {
1502        let word = this.state.ngramsKeeper.emit_word()
1503        if (word != null) {
1504          outputs.push(word)
1505        } else {
1506          outputs.push('(failed to generate)')
1507        }
1508      }
1509
1510      this.elements.outputCountElement.set("(" + String(outputs.length) + " hidden)")
1511      this.elements.outputElement.set(outputs.join(" "))
1512      faro.api?.pushEvent('generate', { mode: 'ngram' })
1513    }
1514
1515    // ----------------------------------------------------------------
1516    // LOGGING METHODS
1517
1518    logAt0(...args) {
1519      this.logAt(0, ...args)
1520    }
1521
1522    logAt1(...args) {
1523      this.logAt(1, ...args)
1524    }
1525
1526    logAt2(...args) {
1527      this.logAt(2, ...args)
1528    }
1529
1530    logAt3(...args) {
1531      this.logAt(3, ...args)
1532    }
1533
1534    logAt(level, ...args) {
1535      if (this.state.logLevel >= level) {
1536        console.log(...args)
1537      }
1538    }
1539
1540  }
1541
1542  // ----------------------------------------------------------------
1543  const app = new WordFinderApp()
1544  // Expose from module visibility to top-level visibility for Console use
1545  window.app = app
1546
1547</script>
1547
1548<!-- ================================================================ -->
1549</body>
1550</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.