@jjlmoya/utils-tabletop 1.33.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +2 -0
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/qa-test-helpers.ts +32 -0
  7. package/src/tests/qa_bibliography_links.test.ts +46 -0
  8. package/src/tests/qa_claim_evidence.test.ts +69 -0
  9. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  10. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  11. package/src/tests/registry_contract.test.ts +67 -0
  12. package/src/tests/seo_translation_completeness.test.ts +18 -12
  13. package/src/tests/tool_validation.test.ts +1 -1
  14. package/src/tests/translation_copy.test.ts +0 -1
  15. package/src/tool/board-game-timer/i18n/en.ts +1 -2
  16. package/src/tool/board-game-timer/i18n/es.ts +2 -2
  17. package/src/tool/card-draw-odds-calculator/bibliography.astro +16 -0
  18. package/src/tool/card-draw-odds-calculator/bibliography.ts +7 -0
  19. package/src/tool/card-draw-odds-calculator/card-draw-odds-calculator.css +382 -0
  20. package/src/tool/card-draw-odds-calculator/component.astro +89 -0
  21. package/src/tool/card-draw-odds-calculator/controller.ts +118 -0
  22. package/src/tool/card-draw-odds-calculator/dom-views.ts +90 -0
  23. package/src/tool/card-draw-odds-calculator/entry.ts +26 -0
  24. package/src/tool/card-draw-odds-calculator/evaluator.ts +30 -0
  25. package/src/tool/card-draw-odds-calculator/i18n/de.ts +48 -0
  26. package/src/tool/card-draw-odds-calculator/i18n/en.ts +76 -0
  27. package/src/tool/card-draw-odds-calculator/i18n/es.ts +48 -0
  28. package/src/tool/card-draw-odds-calculator/i18n/fr.ts +48 -0
  29. package/src/tool/card-draw-odds-calculator/i18n/id.ts +27 -0
  30. package/src/tool/card-draw-odds-calculator/i18n/it.ts +15 -0
  31. package/src/tool/card-draw-odds-calculator/i18n/ja.ts +15 -0
  32. package/src/tool/card-draw-odds-calculator/i18n/ko.ts +15 -0
  33. package/src/tool/card-draw-odds-calculator/i18n/nl.ts +15 -0
  34. package/src/tool/card-draw-odds-calculator/i18n/pl.ts +15 -0
  35. package/src/tool/card-draw-odds-calculator/i18n/pt.ts +15 -0
  36. package/src/tool/card-draw-odds-calculator/i18n/ru.ts +15 -0
  37. package/src/tool/card-draw-odds-calculator/i18n/sv.ts +15 -0
  38. package/src/tool/card-draw-odds-calculator/i18n/tr.ts +15 -0
  39. package/src/tool/card-draw-odds-calculator/i18n/zh.ts +15 -0
  40. package/src/tool/card-draw-odds-calculator/index.ts +11 -0
  41. package/src/tool/card-draw-odds-calculator/logic.test.ts +50 -0
  42. package/src/tool/card-draw-odds-calculator/logic.ts +100 -0
  43. package/src/tool/card-draw-odds-calculator/seo.astro +16 -0
  44. package/src/tool/card-draw-odds-calculator/storage.ts +22 -0
  45. package/src/tool/card-draw-odds-calculator/ui.ts +37 -0
  46. package/src/tool/dice-roller-simulator/i18n/es.ts +3 -3
  47. package/src/tool/dungeon-map-generator/component.astro +14 -10
  48. package/src/tool/dungeon-map-generator/dom-views.ts +5 -4
  49. package/src/tool/dungeon-map-generator/i18n/es.ts +2 -2
  50. package/src/tool/encounter-difficulty-calculator/dom-views.ts +8 -1
  51. package/src/tool/fantasy-runes-translator/logic.test.ts +32 -0
  52. package/src/tool/first-player-selector/i18n/en.ts +1 -1
  53. package/src/tool/first-player-selector/i18n/es.ts +3 -3
  54. package/src/tool/mus-scoreboard/component.astro +3 -3
  55. package/src/tool/mus-scoreboard/controller.ts +2 -1
  56. package/src/tool/mus-scoreboard/dom-views.ts +6 -1
  57. package/src/tool/rpg-settlement-exploration-map-generator/component.astro +14 -3
  58. package/src/tool/rpg-settlement-exploration-map-generator/editor.ts +38 -6
  59. package/src/tools.ts +2 -0
@@ -0,0 +1,382 @@
1
+ :root {
2
+ --n-ink: #172326;
3
+ --n-muted: #667477;
4
+ --n-paper: #f6f3ec;
5
+ --n-panel: #fffdf8;
6
+ --n-line: #d7d8cf;
7
+ --n-accent: #bd5b39;
8
+ --n-accent-soft: #f2d4c5;
9
+ --n-teal: #2e7772;
10
+ --n-shadow: rgba(26, 43, 42, 0.12);
11
+ }
12
+
13
+ .theme-dark {
14
+ --n-ink: #edf3ee;
15
+ --n-muted: #aebdb9;
16
+ --n-paper: #1d2728;
17
+ --n-panel: #263334;
18
+ --n-line: #455554;
19
+ --n-accent: #e58a61;
20
+ --n-accent-soft: #59382e;
21
+ --n-teal: #69b8a8;
22
+ --n-shadow: rgba(0, 0, 0, 0.3);
23
+ }
24
+
25
+ .card-draw-odds-tool {
26
+ --n-ink: #172326;
27
+ --n-muted: #667477;
28
+ --n-paper: #f6f3ec;
29
+ --n-panel: #fffdf8;
30
+ --n-line: #d7d8cf;
31
+ --n-accent: #bd5b39;
32
+ --n-accent-soft: #f2d4c5;
33
+ --n-teal: #2e7772;
34
+ --n-shadow: rgba(26, 43, 42, 0.12);
35
+
36
+ display: grid;
37
+ grid-template-columns: minmax(260px, 0.8fr) minmax(360px, 1.45fr);
38
+ gap: 1px;
39
+ overflow: hidden;
40
+ border: 1px solid var(--n-line);
41
+ background: var(--n-line);
42
+ color: var(--n-ink);
43
+ }
44
+
45
+ .theme-dark .card-draw-odds-tool {
46
+ --n-ink: #edf3ee;
47
+ --n-muted: #aebdb9;
48
+ --n-paper: #1d2728;
49
+ --n-panel: #263334;
50
+ --n-line: #455554;
51
+ --n-accent: #e58a61;
52
+ --n-accent-soft: #59382e;
53
+ --n-teal: #69b8a8;
54
+ --n-shadow: rgba(0, 0, 0, 0.3);
55
+ }
56
+
57
+ .card-draw-controls,
58
+ .card-draw-results {
59
+ background: var(--n-panel);
60
+ padding: clamp(1.2rem, 3vw, 2.4rem);
61
+ }
62
+
63
+ .card-draw-controls {
64
+ background: var(--n-paper);
65
+ }
66
+
67
+ .controls-heading,
68
+ .result-header,
69
+ .timeline-title-row,
70
+ .timeline-heading,
71
+ .result-meta {
72
+ display: flex;
73
+ justify-content: space-between;
74
+ gap: 1rem;
75
+ }
76
+
77
+ .controls-heading,
78
+ .result-header {
79
+ align-items: flex-start;
80
+ }
81
+
82
+ .controls-heading p,
83
+ .result-header p,
84
+ .timeline-title-row span,
85
+ .mulligan-note {
86
+ margin: 0;
87
+ color: var(--n-muted);
88
+ font-size: 0.86rem;
89
+ line-height: 1.5;
90
+ }
91
+
92
+ .section-kicker {
93
+ color: var(--n-accent);
94
+ font-size: 0.7rem;
95
+ font-weight: 800;
96
+ letter-spacing: 0.12em;
97
+ text-transform: uppercase;
98
+ }
99
+
100
+ .field-grid {
101
+ display: grid;
102
+ grid-template-columns: repeat(2, minmax(0, 1fr));
103
+ gap: 1rem;
104
+ margin-top: 2rem;
105
+ }
106
+
107
+ .field {
108
+ position: relative;
109
+ display: grid;
110
+ gap: 0.45rem;
111
+ }
112
+
113
+ .field-wide {
114
+ grid-column: 1 / -1;
115
+ }
116
+
117
+ .field > span,
118
+ .field > label,
119
+ .custom-select > label {
120
+ color: var(--n-muted);
121
+ font-size: 0.78rem;
122
+ font-weight: 700;
123
+ }
124
+
125
+ .field input,
126
+ .select-trigger {
127
+ width: 100%;
128
+ border: 1px solid var(--n-line);
129
+ border-radius: 0.45rem;
130
+ background: var(--n-panel);
131
+ color: var(--n-ink);
132
+ font: inherit;
133
+ padding: 0.72rem 0.78rem;
134
+ }
135
+
136
+ .field input:focus,
137
+ .select-trigger:focus,
138
+ .select-menu button:focus {
139
+ outline: 2px solid var(--n-accent);
140
+ outline-offset: 2px;
141
+ }
142
+
143
+ .select-trigger {
144
+ cursor: pointer;
145
+ text-align: left;
146
+ }
147
+
148
+ .select-menu {
149
+ position: absolute;
150
+ z-index: 2;
151
+ top: calc(100% + 0.35rem);
152
+ right: 0;
153
+ left: 0;
154
+ display: grid;
155
+ overflow: hidden;
156
+ border: 1px solid var(--n-line);
157
+ border-radius: 0.45rem;
158
+ background: var(--n-panel);
159
+ box-shadow: 0 0.8rem 1.8rem var(--n-shadow);
160
+ }
161
+
162
+ .select-menu[hidden] {
163
+ display: none;
164
+ }
165
+
166
+ .select-menu button {
167
+ border: 0;
168
+ border-bottom: 1px solid var(--n-line);
169
+ background: transparent;
170
+ color: var(--n-ink);
171
+ cursor: pointer;
172
+ font: inherit;
173
+ padding: 0.68rem 0.78rem;
174
+ text-align: left;
175
+ }
176
+
177
+ .select-menu button:last-child {
178
+ border-bottom: 0;
179
+ }
180
+
181
+ .select-menu button:hover {
182
+ background: var(--n-accent-soft);
183
+ }
184
+
185
+ .final-result {
186
+ display: grid;
187
+ justify-items: end;
188
+ gap: 0.2rem;
189
+ }
190
+
191
+ .final-result span,
192
+ .result-meta span {
193
+ color: var(--n-muted);
194
+ font-size: 0.76rem;
195
+ }
196
+
197
+ .final-result strong {
198
+ color: var(--n-accent);
199
+ font-size: clamp(2.1rem, 5vw, 3.5rem);
200
+ line-height: 0.95;
201
+ }
202
+
203
+ .card-fan-stage {
204
+ display: grid;
205
+ min-height: 14rem;
206
+ place-items: end center;
207
+ margin: 1.5rem 0 1rem;
208
+ overflow: hidden;
209
+ border-bottom: 1px solid var(--n-line);
210
+ }
211
+
212
+ .card-fan {
213
+ position: relative;
214
+ width: min(100%, 26rem);
215
+ height: 11rem;
216
+ }
217
+
218
+ .fan-card {
219
+ position: absolute;
220
+ bottom: 0;
221
+ left: calc(50% - 1.8rem);
222
+ width: 3.6rem;
223
+ height: 8.5rem;
224
+ border: 2px solid var(--n-teal);
225
+ border-radius: 0.65rem 0.65rem 0.35rem 0.35rem;
226
+ background: var(--n-paper);
227
+ box-shadow: 0 0.4rem 0.7rem var(--n-shadow);
228
+ transform: rotate(calc((var(--fan-index) - 5) * 8deg));
229
+ transform-origin: 50% 100%;
230
+ }
231
+
232
+ .fan-card::after {
233
+ position: absolute;
234
+ top: 0.65rem;
235
+ right: 0.55rem;
236
+ bottom: 0.65rem;
237
+ left: 0.55rem;
238
+ border: 1px solid var(--n-line);
239
+ border-radius: 0.25rem;
240
+ content: '';
241
+ }
242
+
243
+ .fan-card-target {
244
+ border-color: var(--n-accent);
245
+ background: var(--n-accent-soft);
246
+ }
247
+
248
+ .fan-card-target::before {
249
+ position: absolute;
250
+ top: 50%;
251
+ left: 50%;
252
+ color: var(--n-accent);
253
+ content: '+';
254
+ font-size: 1.2rem;
255
+ transform: translate(-50%, -50%);
256
+ }
257
+
258
+ .fan-marker {
259
+ position: absolute;
260
+ right: 0;
261
+ bottom: 0.6rem;
262
+ border: 1px solid var(--n-accent);
263
+ border-radius: 999px;
264
+ background: var(--n-panel);
265
+ color: var(--n-accent);
266
+ font-size: 0.72rem;
267
+ font-weight: 800;
268
+ padding: 0.45rem 0.65rem;
269
+ }
270
+
271
+ .result-meta {
272
+ align-items: center;
273
+ border-bottom: 1px solid var(--n-line);
274
+ padding-bottom: 1.1rem;
275
+ }
276
+
277
+ .result-meta strong {
278
+ color: var(--n-teal);
279
+ font-size: 0.9rem;
280
+ }
281
+
282
+ .timeline-block {
283
+ padding-top: 1.5rem;
284
+ }
285
+
286
+ .timeline-title-row {
287
+ align-items: baseline;
288
+ margin-bottom: 1rem;
289
+ }
290
+
291
+ .timeline-title-row h2 {
292
+ margin: 0;
293
+ font-size: 1rem;
294
+ }
295
+
296
+ .timeline {
297
+ display: grid;
298
+ gap: 0.85rem;
299
+ }
300
+
301
+ .timeline-row {
302
+ display: grid;
303
+ grid-template-columns: minmax(7rem, 0.8fr) minmax(5rem, 1.4fr) 3.5rem;
304
+ align-items: center;
305
+ gap: 0.7rem;
306
+ }
307
+
308
+ .timeline-heading {
309
+ align-items: baseline;
310
+ flex-wrap: wrap;
311
+ }
312
+
313
+ .timeline-title {
314
+ font-size: 0.78rem;
315
+ font-weight: 800;
316
+ }
317
+
318
+ .timeline-cards {
319
+ color: var(--n-muted);
320
+ font-size: 0.68rem;
321
+ }
322
+
323
+ .timeline-track {
324
+ height: 0.45rem;
325
+ overflow: hidden;
326
+ border-radius: 999px;
327
+ background: var(--n-paper);
328
+ }
329
+
330
+ .timeline-fill {
331
+ display: block;
332
+ height: 100%;
333
+ border-radius: inherit;
334
+ background: var(--n-teal);
335
+ }
336
+
337
+ .timeline-result {
338
+ color: var(--n-ink);
339
+ font-size: 0.8rem;
340
+ text-align: right;
341
+ }
342
+
343
+ .mulligan-note {
344
+ margin-top: 1.2rem;
345
+ border-left: 2px solid var(--n-accent);
346
+ padding-left: 0.8rem;
347
+ }
348
+
349
+ .validation-error {
350
+ grid-column: 1 / -1;
351
+ background: var(--n-panel);
352
+ color: var(--n-accent);
353
+ padding: 1.5rem;
354
+ }
355
+
356
+ @media (max-width: 760px) {
357
+ .card-draw-odds-tool {
358
+ grid-template-columns: 1fr;
359
+ }
360
+
361
+ .timeline-row {
362
+ grid-template-columns: minmax(6.4rem, 0.75fr) minmax(4rem, 1.2fr) 3.2rem;
363
+ }
364
+ }
365
+
366
+ @media (max-width: 420px) {
367
+ .field-grid {
368
+ grid-template-columns: 1fr;
369
+ }
370
+
371
+ .field-wide {
372
+ grid-column: auto;
373
+ }
374
+
375
+ .result-header {
376
+ display: grid;
377
+ }
378
+
379
+ .final-result {
380
+ justify-items: start;
381
+ }
382
+ }
@@ -0,0 +1,89 @@
1
+ ---
2
+ import './card-draw-odds-calculator.css';
3
+ import type { CardDrawOddsUI } from './ui';
4
+ import { DEFAULT_INPUT } from './logic';
5
+
6
+ interface Props {
7
+ ui: CardDrawOddsUI;
8
+ }
9
+
10
+ const { ui } = Astro.props;
11
+ ---
12
+
13
+ <section class="card-draw-odds-tool" data-card-draw-tool data-ui={JSON.stringify(ui)}>
14
+ <form class="card-draw-controls" novalidate>
15
+ <div class="controls-heading">
16
+ <span class="section-kicker">{ui.configureLabel}</span>
17
+ <p>{ui.configureHint}</p>
18
+ </div>
19
+ <div class="field-grid">
20
+ <div class="field field-wide custom-select" data-select-key="preset" data-value="custom">
21
+ <label id="preset-label">{ui.presetLabel}</label>
22
+ <button type="button" class="select-trigger" data-select-trigger aria-expanded="false" aria-haspopup="listbox" aria-controls="preset-options" aria-labelledby="preset-label">{ui.presetCustom}</button>
23
+ <div id="preset-options" class="select-menu" data-select-menu role="listbox" hidden>
24
+ <button type="button" data-option-value="custom" role="option" aria-selected="true">{ui.presetCustom}</button>
25
+ <button type="button" data-option-value="standard" role="option" aria-selected="false">{ui.presetStandard}</button>
26
+ <button type="button" data-option-value="limited" role="option" aria-selected="false">{ui.presetLimited}</button>
27
+ <button type="button" data-option-value="singleton" role="option" aria-selected="false">{ui.presetSingleton}</button>
28
+ </div>
29
+ </div>
30
+ <label class="field">
31
+ <span>{ui.deckSizeLabel}</span>
32
+ <input data-input-key="deckSize" type="number" min="1" max="10000" value={DEFAULT_INPUT.deckSize} inputmode="numeric" />
33
+ </label>
34
+ <label class="field">
35
+ <span>{ui.targetCopiesLabel}</span>
36
+ <input data-input-key="targetCopies" type="number" min="0" max="10000" value={DEFAULT_INPUT.targetCopies} inputmode="numeric" />
37
+ </label>
38
+ <label class="field">
39
+ <span>{ui.openingHandLabel}</span>
40
+ <input data-input-key="openingHand" type="number" min="0" max="10000" value={DEFAULT_INPUT.openingHand} inputmode="numeric" />
41
+ </label>
42
+ <label class="field">
43
+ <span>{ui.drawsPerTurnLabel}</span>
44
+ <input data-input-key="drawsPerTurn" type="number" min="0" max="10000" value={DEFAULT_INPUT.drawsPerTurn} inputmode="numeric" />
45
+ </label>
46
+ <label class="field">
47
+ <span>{ui.throughTurnLabel}</span>
48
+ <input data-input-key="throughTurn" type="number" min="0" max="60" value={DEFAULT_INPUT.throughTurn} inputmode="numeric" />
49
+ </label>
50
+ <div class="field field-wide custom-select" data-select-key="mulliganMode" data-value={DEFAULT_INPUT.mulliganMode}>
51
+ <label id="mulligan-label">{ui.mulliganLabel}</label>
52
+ <button type="button" class="select-trigger" data-select-trigger aria-expanded="false" aria-haspopup="listbox" aria-controls="mulligan-options" aria-labelledby="mulligan-label">{ui.mulliganFullRedraw}</button>
53
+ <div id="mulligan-options" class="select-menu" data-select-menu role="listbox" hidden>
54
+ <button type="button" data-option-value="none" role="option" aria-selected="false">{ui.mulliganNone}</button>
55
+ <button type="button" data-option-value="fullRedraw" role="option" aria-selected="true">{ui.mulliganFullRedraw}</button>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </form>
60
+
61
+ <div class="card-draw-results" data-result-content>
62
+ <div class="result-header">
63
+ <div>
64
+ <span class="section-kicker">{ui.visualLabel}</span>
65
+ <p>{ui.visualHint}</p>
66
+ </div>
67
+ <div class="final-result" aria-live="polite" aria-atomic="true">
68
+ <span>{ui.finalChanceLabel}</span>
69
+ <strong data-final-probability>0%</strong>
70
+ </div>
71
+ </div>
72
+ <div class="card-fan-stage" data-card-fan aria-hidden="true"></div>
73
+ <div class="result-meta">
74
+ <span>{ui.expectedCopiesLabel}</span>
75
+ <strong data-expected-copies>0.00 {ui.copyLabel}</strong>
76
+ </div>
77
+ <div class="timeline-block">
78
+ <div class="timeline-title-row">
79
+ <h2>{ui.probabilityByTurnLabel}</h2>
80
+ <span>{ui.explanationLabel}</span>
81
+ </div>
82
+ <div class="timeline" data-timeline></div>
83
+ <p class="mulligan-note" data-mulligan-note></p>
84
+ </div>
85
+ </div>
86
+ <div class="validation-error" data-validation-error hidden role="alert"></div>
87
+ </section>
88
+
89
+ <script src="./controller.ts"></script>
@@ -0,0 +1,118 @@
1
+ import { calculateCardDrawOdds, DEFAULT_INPUT, PRESETS, type CardDrawInput, type MulliganMode } from './logic';
2
+ import { clearError, renderError, renderResult } from './dom-views';
3
+ import { evaluateInput, type InputErrorKey } from './evaluator';
4
+ import { loadCardDrawInput, saveCardDrawInput } from './storage';
5
+ import type { CardDrawOddsUI } from './ui';
6
+
7
+ type InputKey = 'deckSize' | 'targetCopies' | 'openingHand' | 'drawsPerTurn' | 'throughTurn';
8
+
9
+ const readUi = (root: HTMLElement): CardDrawOddsUI => JSON.parse(root.dataset.ui ?? '{}') as CardDrawOddsUI;
10
+
11
+ const errorUiKey: Record<InputErrorKey, keyof CardDrawOddsUI> = {
12
+ invalidDeck: 'invalidDeck',
13
+ invalidCopies: 'invalidCopies',
14
+ invalidHand: 'invalidHand',
15
+ invalidDraws: 'invalidDraws',
16
+ invalidTurns: 'invalidTurns',
17
+ };
18
+
19
+ const getInputs = (root: HTMLElement): NodeListOf<HTMLInputElement> => root.querySelectorAll<HTMLInputElement>('input[data-input-key]');
20
+
21
+ const readInput = (root: HTMLElement): CardDrawInput => {
22
+ const values = {} as Record<InputKey, number>;
23
+ getInputs(root).forEach((element) => {
24
+ values[element.dataset.inputKey as InputKey] = Number(element.value);
25
+ });
26
+ const mulligan = root.querySelector<HTMLElement>('[data-select-key="mulliganMode"]');
27
+ return { ...values, mulliganMode: (mulligan?.dataset.value ?? 'fullRedraw') as MulliganMode };
28
+ };
29
+
30
+ const writeInput = (root: HTMLElement, input: CardDrawInput): void => {
31
+ getInputs(root).forEach((element) => {
32
+ const key = element.dataset.inputKey as InputKey;
33
+ element.value = String(input[key]);
34
+ });
35
+ setSelectValue(root, 'mulliganMode', input.mulliganMode);
36
+ };
37
+
38
+ const setSelectValue = (root: HTMLElement, key: string, value: string): void => {
39
+ const select = root.querySelector<HTMLElement>(`[data-select-key="${key}"]`);
40
+ if (!select) return;
41
+ select.dataset.value = value;
42
+ const option = select.querySelector<HTMLElement>(`[data-option-value="${value}"]`);
43
+ const trigger = select.querySelector<HTMLButtonElement>('[data-select-trigger]');
44
+ if (option && trigger) trigger.textContent = option.textContent;
45
+ select.querySelectorAll<HTMLElement>('[data-option-value]').forEach((item) => {
46
+ item.setAttribute('aria-selected', String(item === option));
47
+ });
48
+ select.querySelector<HTMLElement>('[data-select-menu]')?.setAttribute('hidden', '');
49
+ trigger?.setAttribute('aria-expanded', 'false');
50
+ };
51
+
52
+ const applyPreset = (root: HTMLElement, preset: string): void => {
53
+ if (!PRESETS[preset]) return;
54
+ writeInput(root, { ...readInput(root), ...PRESETS[preset] });
55
+ setSelectValue(root, 'preset', preset);
56
+ };
57
+
58
+ const update = (root: HTMLElement): void => {
59
+ const input = readInput(root);
60
+ const evaluation = evaluateInput(input);
61
+ if (!evaluation.valid) {
62
+ const ui = readUi(root);
63
+ renderError(root, evaluation.errorKey ? (ui[errorUiKey[evaluation.errorKey]] ?? ui.invalidInput) : ui.invalidInput);
64
+ return;
65
+ }
66
+ clearError(root);
67
+ saveCardDrawInput(input);
68
+ renderResult(root, calculateCardDrawOdds(input), input, readUi(root));
69
+ };
70
+
71
+ const bindSelect = (root: HTMLElement, select: HTMLElement): void => {
72
+ const trigger = select.querySelector<HTMLButtonElement>('[data-select-trigger]');
73
+ const menu = select.querySelector<HTMLElement>('[data-select-menu]');
74
+ if (!trigger || !menu) return;
75
+ trigger.addEventListener('click', () => {
76
+ const isOpen = !menu.hasAttribute('hidden');
77
+ menu.toggleAttribute('hidden', isOpen);
78
+ trigger.setAttribute('aria-expanded', String(!isOpen));
79
+ });
80
+ menu.querySelectorAll<HTMLElement>('[data-option-value]').forEach((option) => {
81
+ option.addEventListener('click', () => {
82
+ setSelectValue(root, select.dataset.selectKey ?? '', option.dataset.optionValue ?? '');
83
+ update(root);
84
+ });
85
+ });
86
+ };
87
+
88
+ const bindEvents = (root: HTMLElement): void => {
89
+ root.querySelectorAll<HTMLElement>('[data-select-key]').forEach((select) => bindSelect(root, select));
90
+ root.querySelectorAll<HTMLInputElement>('input[data-input-key]').forEach((input) => input.addEventListener('input', () => {
91
+ setSelectValue(root, 'preset', 'custom');
92
+ update(root);
93
+ }));
94
+ root.querySelectorAll<HTMLElement>('[data-option-value]').forEach((option) => {
95
+ if (option.closest('[data-select-key="mulliganMode"]')) return;
96
+ option.addEventListener('click', () => applyPreset(root, option.dataset.optionValue ?? ''));
97
+ });
98
+ root.querySelector('form')?.addEventListener('submit', (event) => event.preventDefault());
99
+ document.addEventListener('click', (event) => {
100
+ if (event.target instanceof Node && root.contains(event.target)) return;
101
+ root.querySelectorAll<HTMLElement>('[data-select-menu]').forEach((menu) => menu.setAttribute('hidden', ''));
102
+ root.querySelectorAll<HTMLButtonElement>('[data-select-trigger]').forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));
103
+ });
104
+ document.addEventListener('keydown', (event) => {
105
+ if (event.key !== 'Escape') return;
106
+ root.querySelectorAll<HTMLElement>('[data-select-menu]').forEach((menu) => menu.setAttribute('hidden', ''));
107
+ root.querySelectorAll<HTMLButtonElement>('[data-select-trigger]').forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));
108
+ });
109
+ };
110
+
111
+ const start = (root: HTMLElement): void => {
112
+ writeInput(root, { ...DEFAULT_INPUT, ...loadCardDrawInput() });
113
+ setSelectValue(root, 'preset', 'custom');
114
+ bindEvents(root);
115
+ update(root);
116
+ };
117
+
118
+ document.querySelectorAll<HTMLElement>('[data-card-draw-tool]').forEach(start);
@@ -0,0 +1,90 @@
1
+ import type { CardDrawOddsUI } from './ui';
2
+ import type { CardDrawInput, CardDrawResult, DrawOddsPoint } from './logic';
3
+
4
+ const getLocale = (): string => document.documentElement.lang || 'en';
5
+
6
+ const formatPercent = (value: number): string => new Intl.NumberFormat(getLocale(), {
7
+ style: 'percent',
8
+ maximumFractionDigits: 1,
9
+ }).format(value);
10
+
11
+ const formatCopies = (value: number): string => new Intl.NumberFormat(getLocale(), {
12
+ minimumFractionDigits: 2,
13
+ maximumFractionDigits: 2,
14
+ }).format(value);
15
+
16
+ const createElement = <T extends keyof HTMLElementTagNameMap>(tag: T, className?: string): HTMLElementTagNameMap[T] => {
17
+ const element = document.createElement(tag);
18
+ if (className) element.className = className;
19
+ return element;
20
+ };
21
+
22
+ const renderCardFan = (container: HTMLElement, result: CardDrawResult): void => {
23
+ container.replaceChildren();
24
+ const fan = createElement('div', 'card-fan');
25
+ const visibleCards = 11;
26
+ for (let index = 0; index < visibleCards; index += 1) {
27
+ const card = createElement('span', index === 5 ? 'fan-card fan-card-target' : 'fan-card');
28
+ card.style.setProperty('--fan-index', String(index));
29
+ fan.append(card);
30
+ }
31
+ const marker = createElement('div', 'fan-marker');
32
+ marker.textContent = formatPercent(result.finalProbability);
33
+ fan.append(marker);
34
+ container.append(fan);
35
+ };
36
+
37
+ const renderTimelinePoint = (point: DrawOddsPoint, input: CardDrawInput, ui: CardDrawOddsUI): HTMLElement => {
38
+ const row = createElement('div', 'timeline-row');
39
+ const heading = createElement('div', 'timeline-heading');
40
+ const title = createElement('span', 'timeline-title');
41
+ title.textContent = point.stage === 'opening' ? ui.openingHandStage : `${ui.turnLabel} ${point.turn}`;
42
+ const cards = createElement('span', 'timeline-cards');
43
+ cards.textContent = `${ui.cardsSeenLabel}: ${point.cardsSeen} ${ui.cardsLabel}`;
44
+ heading.append(title, cards);
45
+ const track = createElement('div', 'timeline-track');
46
+ const fill = createElement('span', 'timeline-fill');
47
+ fill.style.width = `${point.probability * 100}%`;
48
+ track.append(fill);
49
+ const result = createElement('strong', 'timeline-result');
50
+ result.textContent = formatPercent(point.probability);
51
+ row.append(heading, track, result);
52
+ if (point.stage === 'opening' && input.mulliganMode === 'fullRedraw') row.dataset.mulligan = 'true';
53
+ return row;
54
+ };
55
+
56
+ const renderTimeline = (container: HTMLElement, result: CardDrawResult, input: CardDrawInput, ui: CardDrawOddsUI): void => {
57
+ container.replaceChildren();
58
+ result.points.forEach((point) => container.append(renderTimelinePoint(point, input, ui)));
59
+ };
60
+
61
+ export const renderResult = (root: HTMLElement, result: CardDrawResult, input: CardDrawInput, ui: CardDrawOddsUI): void => {
62
+ const finalProbability = root.querySelector<HTMLElement>('[data-final-probability]');
63
+ const expectedCopies = root.querySelector<HTMLElement>('[data-expected-copies]');
64
+ const note = root.querySelector<HTMLElement>('[data-mulligan-note]');
65
+ const fan = root.querySelector<HTMLElement>('[data-card-fan]');
66
+ const timeline = root.querySelector<HTMLElement>('[data-timeline]');
67
+ if (!finalProbability || !expectedCopies || !note || !fan || !timeline) return;
68
+ finalProbability.textContent = formatPercent(result.finalProbability);
69
+ expectedCopies.textContent = `${formatCopies(result.expectedCopiesAtFinal)} ${ui.copyLabel}`;
70
+ note.textContent = input.mulliganMode === 'fullRedraw' ? ui.fullRedrawNote : ui.noMulliganNote;
71
+ renderCardFan(fan, result);
72
+ renderTimeline(timeline, result, input, ui);
73
+ };
74
+
75
+ export const renderError = (root: HTMLElement, message: string): void => {
76
+ const error = root.querySelector<HTMLElement>('[data-validation-error]');
77
+ const result = root.querySelector<HTMLElement>('[data-result-content]');
78
+ if (!error || !result) return;
79
+ error.textContent = message;
80
+ error.hidden = false;
81
+ result.hidden = true;
82
+ };
83
+
84
+ export const clearError = (root: HTMLElement): void => {
85
+ const error = root.querySelector<HTMLElement>('[data-validation-error]');
86
+ const result = root.querySelector<HTMLElement>('[data-result-content]');
87
+ if (!error || !result) return;
88
+ error.hidden = true;
89
+ result.hidden = false;
90
+ };