@jjlmoya/utils-aquarium 1.0.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 (68) hide show
  1. package/package.json +56 -0
  2. package/scripts/postinstall.mjs +27 -0
  3. package/src/category/i18n/de.ts +13 -0
  4. package/src/category/i18n/en.ts +13 -0
  5. package/src/category/i18n/es.ts +13 -0
  6. package/src/category/i18n/fr.ts +13 -0
  7. package/src/category/i18n/id.ts +13 -0
  8. package/src/category/i18n/it.ts +13 -0
  9. package/src/category/i18n/ja.ts +13 -0
  10. package/src/category/i18n/ko.ts +13 -0
  11. package/src/category/i18n/nl.ts +13 -0
  12. package/src/category/i18n/pl.ts +13 -0
  13. package/src/category/i18n/pt.ts +13 -0
  14. package/src/category/i18n/ru.ts +13 -0
  15. package/src/category/i18n/sv.ts +13 -0
  16. package/src/category/i18n/tr.ts +13 -0
  17. package/src/category/i18n/zh.ts +13 -0
  18. package/src/category/index.ts +24 -0
  19. package/src/category/seo.astro +12 -0
  20. package/src/components/PreviewNavSidebar.astro +116 -0
  21. package/src/components/PreviewToolbar.astro +143 -0
  22. package/src/data.ts +14 -0
  23. package/src/entries.ts +7 -0
  24. package/src/env.d.ts +5 -0
  25. package/src/index.ts +20 -0
  26. package/src/layouts/PreviewLayout.astro +117 -0
  27. package/src/pages/[locale]/[slug].astro +65 -0
  28. package/src/pages/[locale].astro +69 -0
  29. package/src/pages/index.astro +4 -0
  30. package/src/tests/diacritics_density.test.ts +118 -0
  31. package/src/tests/faq_count.test.ts +13 -0
  32. package/src/tests/i18n_coverage.test.ts +14 -0
  33. package/src/tests/inverted_punctuation.test.ts +84 -0
  34. package/src/tests/locale_completeness.test.ts +29 -0
  35. package/src/tests/mocks/astro_mock.js +2 -0
  36. package/src/tests/no_en_dash.test.ts +70 -0
  37. package/src/tests/no_h1_in_components.test.ts +48 -0
  38. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  39. package/src/tests/schemas_fulfillment.test.ts +23 -0
  40. package/src/tests/script_density.test.ts +94 -0
  41. package/src/tests/seo_length.test.ts +26 -0
  42. package/src/tests/seo_parity.test.ts +60 -0
  43. package/src/tests/seo_translation_completeness.test.ts +69 -0
  44. package/src/tests/shared-test-helpers.ts +56 -0
  45. package/src/tests/slug_language_code_format.test.ts +23 -0
  46. package/src/tests/slug_uniqueness.test.ts +81 -0
  47. package/src/tests/spanish_leakage.test.ts +175 -0
  48. package/src/tests/title_quality.test.ts +55 -0
  49. package/src/tests/tool_exports.test.ts +34 -0
  50. package/src/tests/tool_validation.test.ts +11 -0
  51. package/src/tests/translation_copy.test.ts +123 -0
  52. package/src/tool/aquariumTankVolumeWaterChangeCalculator/aquarium-tank-volume-water-change-calculator.css +391 -0
  53. package/src/tool/aquariumTankVolumeWaterChangeCalculator/bibliography.astro +6 -0
  54. package/src/tool/aquariumTankVolumeWaterChangeCalculator/bibliography.ts +14 -0
  55. package/src/tool/aquariumTankVolumeWaterChangeCalculator/component.astro +93 -0
  56. package/src/tool/aquariumTankVolumeWaterChangeCalculator/controller.ts +147 -0
  57. package/src/tool/aquariumTankVolumeWaterChangeCalculator/dom-views.ts +64 -0
  58. package/src/tool/aquariumTankVolumeWaterChangeCalculator/entry.ts +13 -0
  59. package/src/tool/aquariumTankVolumeWaterChangeCalculator/evaluator.ts +19 -0
  60. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/en.ts +186 -0
  61. package/src/tool/aquariumTankVolumeWaterChangeCalculator/index.ts +11 -0
  62. package/src/tool/aquariumTankVolumeWaterChangeCalculator/logic.test.ts +60 -0
  63. package/src/tool/aquariumTankVolumeWaterChangeCalculator/logic.ts +132 -0
  64. package/src/tool/aquariumTankVolumeWaterChangeCalculator/seo.astro +13 -0
  65. package/src/tool/aquariumTankVolumeWaterChangeCalculator/storage.ts +30 -0
  66. package/src/tool/aquariumTankVolumeWaterChangeCalculator/ui.ts +41 -0
  67. package/src/tools.ts +9 -0
  68. package/src/types.ts +54 -0
@@ -0,0 +1,391 @@
1
+ :root {
2
+ --n-ink: #17313b;
3
+ --n-muted: #5d7379;
4
+ --n-paper: #f4f8f5;
5
+ --n-surface: #fff;
6
+ --n-line: #c9d9d4;
7
+ --n-water: #277b91;
8
+ --n-water-soft: #d8eef0;
9
+ --n-gravel: #c7a77d;
10
+ --n-accent: #d96f4f;
11
+ --n-accent-ink: #fff;
12
+ --n-error: #a33632;
13
+ --n-shadow: rgba(23, 49, 59, 0.12);
14
+ }
15
+
16
+ .theme-dark {
17
+ --n-ink: #edf7f4;
18
+ --n-muted: #b1c8c5;
19
+ --n-paper: #17282d;
20
+ --n-surface: #21383d;
21
+ --n-line: #49666a;
22
+ --n-water: #72c5d0;
23
+ --n-water-soft: #244c55;
24
+ --n-gravel: #d5b583;
25
+ --n-accent: #ef9474;
26
+ --n-accent-ink: #251512;
27
+ --n-error: #ff9b91;
28
+ --n-shadow: rgba(0, 0, 0, 0.28);
29
+ }
30
+
31
+ .atvwc-root {
32
+ --n-card: var(--n-surface);
33
+ --n-field: var(--n-paper);
34
+ --n-focus: var(--n-water);
35
+
36
+ color: var(--n-ink);
37
+ background: var(--n-paper);
38
+ border: 1px solid var(--n-line);
39
+ border-radius: 1.5rem;
40
+ box-shadow: 0 1rem 2.5rem var(--n-shadow);
41
+ padding: clamp(1rem, 3vw, 2rem);
42
+ }
43
+
44
+ .atvwc-heading,
45
+ .atvwc-panel-topline,
46
+ .atvwc-result-heading,
47
+ .atvwc-footnotes {
48
+ align-items: flex-start;
49
+ display: flex;
50
+ gap: 1rem;
51
+ justify-content: space-between;
52
+ }
53
+
54
+ .atvwc-eyebrow,
55
+ .atvwc-subtitle,
56
+ .atvwc-help,
57
+ .atvwc-result-hint,
58
+ .atvwc-empty,
59
+ .atvwc-error,
60
+ .atvwc-footnotes,
61
+ .atvwc-control-label {
62
+ margin: 0;
63
+ }
64
+
65
+ .atvwc-eyebrow {
66
+ font-size: 1.15rem;
67
+ font-weight: 800;
68
+ letter-spacing: .02em;
69
+ }
70
+
71
+ .atvwc-subtitle,
72
+ .atvwc-help,
73
+ .atvwc-result-hint,
74
+ .atvwc-empty,
75
+ .atvwc-footnotes {
76
+ color: var(--n-muted);
77
+ font-size: .9rem;
78
+ line-height: 1.5;
79
+ }
80
+
81
+ .atvwc-unit-control,
82
+ .atvwc-shapes {
83
+ display: flex;
84
+ flex-wrap: wrap;
85
+ gap: .4rem;
86
+ }
87
+
88
+ .atvwc-unit-control {
89
+ align-items: center;
90
+ justify-content: flex-end;
91
+ }
92
+
93
+ .atvwc-unit-control button,
94
+ .atvwc-shape,
95
+ .atvwc-reset,
96
+ .atvwc-calculate {
97
+ border: 1px solid var(--n-line);
98
+ border-radius: .7rem;
99
+ cursor: pointer;
100
+ font: inherit;
101
+ min-height: 2.6rem;
102
+ padding: .55rem .8rem;
103
+ }
104
+
105
+ .atvwc-unit-control button,
106
+ .atvwc-shape,
107
+ .atvwc-reset {
108
+ background: var(--n-card);
109
+ color: var(--n-ink);
110
+ }
111
+
112
+ .atvwc-unit-control button[aria-pressed="true"],
113
+ .atvwc-shape.is-active {
114
+ background: var(--n-water-soft);
115
+ border-color: var(--n-water);
116
+ color: var(--n-ink);
117
+ font-weight: 700;
118
+ }
119
+
120
+ .atvwc-layout {
121
+ display: grid;
122
+ gap: 1.25rem;
123
+ grid-template-columns: minmax(0, 1.1fr) minmax(18rem, .9fr);
124
+ margin-top: 1.5rem;
125
+ }
126
+
127
+ .atvwc-panel {
128
+ background: var(--n-card);
129
+ border: 1px solid var(--n-line);
130
+ border-radius: 1.1rem;
131
+ padding: clamp(1rem, 2vw, 1.5rem);
132
+ }
133
+
134
+ .atvwc-panel h2 {
135
+ font-size: 1.1rem;
136
+ margin: 0;
137
+ }
138
+
139
+ .atvwc-reset {
140
+ min-height: 2.25rem;
141
+ padding: .35rem .65rem;
142
+ }
143
+
144
+ .atvwc-fieldset {
145
+ border: 0;
146
+ margin: 1.25rem 0 0;
147
+ padding: 0;
148
+ }
149
+
150
+ .atvwc-fieldset legend {
151
+ font-weight: 700;
152
+ margin-bottom: .6rem;
153
+ }
154
+
155
+ .atvwc-input-grid {
156
+ display: grid;
157
+ gap: .8rem;
158
+ grid-template-columns: repeat(3, minmax(0, 1fr));
159
+ margin-top: 1rem;
160
+ }
161
+
162
+ .atvwc-field {
163
+ display: grid;
164
+ gap: .35rem;
165
+ min-width: 0;
166
+ }
167
+
168
+ .atvwc-field > span {
169
+ font-size: .86rem;
170
+ font-weight: 700;
171
+ }
172
+
173
+ .atvwc-field input {
174
+ background: var(--n-field);
175
+ border: 1px solid var(--n-line);
176
+ border-radius: .65rem;
177
+ box-sizing: border-box;
178
+ color: var(--n-ink);
179
+ font: inherit;
180
+ min-height: 2.7rem;
181
+ min-width: 0;
182
+ padding: .55rem .65rem;
183
+ width: 100%;
184
+ }
185
+
186
+ .atvwc-field input:focus-visible,
187
+ .atvwc-unit-control button:focus-visible,
188
+ .atvwc-shape:focus-visible,
189
+ .atvwc-reset:focus-visible,
190
+ .atvwc-calculate:focus-visible {
191
+ outline: 3px solid var(--n-focus);
192
+ outline-offset: 2px;
193
+ }
194
+
195
+ .atvwc-calculate {
196
+ background: var(--n-accent);
197
+ border-color: var(--n-accent);
198
+ color: var(--n-accent-ink);
199
+ font-weight: 800;
200
+ margin-top: 1.25rem;
201
+ width: 100%;
202
+ }
203
+
204
+ .atvwc-bow-note {
205
+ display: none;
206
+ }
207
+
208
+ .atvwc-visual-panel {
209
+ display: flex;
210
+ flex-direction: column;
211
+ min-height: 100%;
212
+ }
213
+
214
+ .atvwc-tank-stage {
215
+ align-items: center;
216
+ background: var(--n-water-soft);
217
+ border: 1px solid var(--n-line);
218
+ border-radius: .9rem;
219
+ display: flex;
220
+ height: 10rem;
221
+ justify-content: center;
222
+ overflow: hidden;
223
+ position: relative;
224
+ }
225
+
226
+ .atvwc-tank {
227
+ background: var(--n-field);
228
+ border: .35rem solid var(--n-water);
229
+ border-radius: .8rem .8rem 1.2rem 1.2rem;
230
+ bottom: 1.2rem;
231
+ height: 6.4rem;
232
+ overflow: hidden;
233
+ position: absolute;
234
+ width: 10rem;
235
+ }
236
+
237
+ .atvwc-water,
238
+ .atvwc-gravel {
239
+ bottom: 0;
240
+ left: 0;
241
+ position: absolute;
242
+ width: 100%;
243
+ }
244
+
245
+ .atvwc-water {
246
+ background: var(--n-water);
247
+ height: 70%;
248
+ opacity: .82;
249
+ transition: height .25s ease;
250
+ }
251
+
252
+ .atvwc-gravel {
253
+ background: var(--n-gravel);
254
+ height: 13%;
255
+ opacity: .9;
256
+ }
257
+
258
+ .atvwc-glass-line {
259
+ border: 2px solid var(--n-surface);
260
+ border-radius: .5rem;
261
+ height: 82%;
262
+ left: 8%;
263
+ opacity: .7;
264
+ position: absolute;
265
+ top: 8%;
266
+ width: 84%;
267
+ }
268
+
269
+ .atvwc-fish {
270
+ color: var(--n-accent);
271
+ font-size: 1.5rem;
272
+ position: absolute;
273
+ }
274
+
275
+ .atvwc-fish-one {
276
+ left: 20%;
277
+ top: 32%;
278
+ }
279
+
280
+ .atvwc-fish-two {
281
+ right: 19%;
282
+ top: 54%;
283
+ transform: scaleX(-1);
284
+ }
285
+
286
+ .atvwc-result-heading {
287
+ align-items: center;
288
+ margin-top: 1.25rem;
289
+ }
290
+
291
+ .atvwc-result-heading h2 {
292
+ font-size: clamp(1.8rem, 5vw, 2.7rem);
293
+ line-height: 1.05;
294
+ margin: .25rem 0 0;
295
+ }
296
+
297
+ .atvwc-scale {
298
+ background: var(--n-water-soft);
299
+ border-radius: 999px;
300
+ color: var(--n-ink);
301
+ font-size: .8rem;
302
+ font-weight: 800;
303
+ padding: .35rem .6rem;
304
+ text-align: center;
305
+ }
306
+
307
+ .atvwc-result-grid {
308
+ display: grid;
309
+ gap: .65rem;
310
+ grid-template-columns: repeat(2, minmax(0, 1fr));
311
+ margin-top: 1rem;
312
+ }
313
+
314
+ .atvwc-result-card {
315
+ background: var(--n-field);
316
+ border: 1px solid var(--n-line);
317
+ border-radius: .75rem;
318
+ display: grid;
319
+ gap: .35rem;
320
+ min-height: 4.7rem;
321
+ padding: .75rem;
322
+ }
323
+
324
+ .atvwc-result-card span {
325
+ color: var(--n-muted);
326
+ font-size: .78rem;
327
+ }
328
+
329
+ .atvwc-result-card strong {
330
+ font-size: 1.15rem;
331
+ }
332
+
333
+ .atvwc-result-card-highlight {
334
+ border-color: var(--n-accent);
335
+ }
336
+
337
+ .atvwc-footnotes {
338
+ flex-wrap: wrap;
339
+ gap: .6rem 1rem;
340
+ margin-top: auto;
341
+ padding-top: 1rem;
342
+ }
343
+
344
+ .atvwc-error {
345
+ color: var(--n-error);
346
+ font-weight: 700;
347
+ margin-top: .75rem;
348
+ }
349
+
350
+ .atvwc-root[data-state="ready"] .atvwc-empty,
351
+ .atvwc-root[data-state="error"] .atvwc-empty {
352
+ display: none;
353
+ }
354
+
355
+ .atvwc-root[data-state="error"] .atvwc-result-card strong,
356
+ .atvwc-root[data-state="error"] .atvwc-footnotes strong {
357
+ color: var(--n-muted);
358
+ }
359
+
360
+ @media (max-width: 760px) {
361
+ .atvwc-heading,
362
+ .atvwc-layout {
363
+ display: block;
364
+ }
365
+
366
+ .atvwc-unit-control {
367
+ justify-content: flex-start;
368
+ margin-top: 1rem;
369
+ }
370
+
371
+ .atvwc-visual-panel {
372
+ margin-top: 1.25rem;
373
+ }
374
+
375
+ .atvwc-input-grid {
376
+ grid-template-columns: repeat(2, minmax(0, 1fr));
377
+ }
378
+ }
379
+
380
+ @media (max-width: 430px) {
381
+ .atvwc-input-grid,
382
+ .atvwc-result-grid {
383
+ grid-template-columns: 1fr;
384
+ }
385
+ }
386
+
387
+ @media (prefers-reduced-motion: reduce) {
388
+ .atvwc-water {
389
+ transition: none;
390
+ }
391
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <Bibliography links={bibliography} />
@@ -0,0 +1,14 @@
1
+ export const bibliography = [
2
+ {
3
+ name: 'Centro Español de Metrología, Sistema Internacional de Unidades',
4
+ url: 'https://www.cem.es/sites/default/files/2021-01/SistemaInternacionalUnidades.pdf',
5
+ },
6
+ {
7
+ name: 'U.S. Geological Survey, Water Density',
8
+ url: 'https://www.usgs.gov/water-science-school/science/water-density',
9
+ },
10
+ {
11
+ name: 'OpenStax, Measuring Volume',
12
+ url: 'https://openstax.org/books/contemporary-mathematics/pages/9-3-measuring-volume',
13
+ },
14
+ ];
@@ -0,0 +1,93 @@
1
+ ---
2
+ import type { AquariumTankVolumeWaterChangeCalculatorUI } from './ui';
3
+
4
+ const { ui } = Astro.props;
5
+ const t = (ui ?? {}) as AquariumTankVolumeWaterChangeCalculatorUI;
6
+ ---
7
+
8
+ <div id="aquarium-tank-volume-water-change-calculator" class="atvwc-root" data-ui={JSON.stringify(t)}>
9
+ <div class="atvwc-heading">
10
+ <div>
11
+ <p class="atvwc-eyebrow">{t.toolTitle}</p>
12
+ <p class="atvwc-subtitle">{t.toolSubtitle}</p>
13
+ </div>
14
+ <div class="atvwc-unit-control" role="group" aria-label={t.unitLabel}>
15
+ <span class="atvwc-control-label">{t.unitLabel}</span>
16
+ <button type="button" data-unit-choice="metric" aria-pressed="true">{t.metricLabel}</button>
17
+ <button type="button" data-unit-choice="imperial" aria-pressed="false">{t.imperialLabel}</button>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="atvwc-layout">
22
+ <section class="atvwc-panel atvwc-form-panel" aria-labelledby="atvwc-dimensions-title">
23
+ <div class="atvwc-panel-topline">
24
+ <h2 id="atvwc-dimensions-title">{t.dimensionsTitle}</h2>
25
+ <button type="button" class="atvwc-reset" data-action="reset">{t.resetLabel}</button>
26
+ </div>
27
+
28
+ <fieldset class="atvwc-fieldset">
29
+ <legend>{t.shapeLabel}</legend>
30
+ <div class="atvwc-shapes">
31
+ <button type="button" class="atvwc-shape is-active" data-shape="rectangular" aria-pressed="true">{t.rectangularShape}</button>
32
+ <button type="button" class="atvwc-shape" data-shape="cylindrical" aria-pressed="false">{t.cylindricalShape}</button>
33
+ <button type="button" class="atvwc-shape" data-shape="bow" aria-pressed="false">{t.bowShape}</button>
34
+ </div>
35
+ <p class="atvwc-help">{t.shapeHint}</p>
36
+ </fieldset>
37
+
38
+ <div class="atvwc-input-grid" data-shape-fields="rectangular">
39
+ <label class="atvwc-field"><span>{t.lengthLabel} <span data-length-unit></span></span><input data-input="lengthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
40
+ <label class="atvwc-field"><span>{t.widthLabel} <span data-length-unit></span></span><input data-input="widthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
41
+ <label class="atvwc-field"><span>{t.heightLabel} <span data-length-unit></span></span><input data-input="heightCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
42
+ </div>
43
+
44
+ <div class="atvwc-input-grid" data-shape-fields="cylindrical" hidden>
45
+ <label class="atvwc-field"><span>{t.diameterLabel} <span data-length-unit></span></span><input data-input="diameterCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
46
+ <label class="atvwc-field"><span>{t.heightLabel} <span data-length-unit></span></span><input data-input="heightCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
47
+ </div>
48
+
49
+ <div class="atvwc-input-grid" data-shape-fields="bow" hidden>
50
+ <label class="atvwc-field"><span>{t.lengthLabel} <span data-length-unit></span></span><input data-input="lengthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
51
+ <label class="atvwc-field"><span>{t.widthLabel} <span data-length-unit></span></span><input data-input="widthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
52
+ <label class="atvwc-field"><span>{t.heightLabel} <span data-length-unit></span></span><input data-input="heightCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
53
+ <label class="atvwc-field"><span>{t.bowDepthLabel} <span data-length-unit></span></span><input data-input="bowDepthCm" type="number" min="0" max="500" step="0.1" inputmode="decimal" /></label>
54
+ </div>
55
+
56
+ <div class="atvwc-input-grid">
57
+ <label class="atvwc-field"><span>{t.substrateLabel} <span data-length-unit></span></span><input data-input="substrateDepthCm" type="number" min="0" max="500" step="0.1" inputmode="decimal" /></label>
58
+ <label class="atvwc-field"><span>{t.changeLabel} %</span><input data-input="changePercent" type="number" min="0" max="100" step="1" inputmode="decimal" /></label>
59
+ </div>
60
+
61
+ <p class="atvwc-help">{t.substrateHint}</p>
62
+ <p class="atvwc-help atvwc-bow-note">{t.bowHint}</p>
63
+ <button type="button" class="atvwc-calculate" data-action="calculate">{t.calculateLabel}</button>
64
+ </section>
65
+
66
+ <section class="atvwc-panel atvwc-visual-panel" aria-labelledby="atvwc-results-title">
67
+ <div class="atvwc-tank-stage" aria-hidden="true">
68
+ <div class="atvwc-tank"><div class="atvwc-water" data-water></div><div class="atvwc-gravel"></div><div class="atvwc-glass-line"></div></div>
69
+ <div class="atvwc-fish atvwc-fish-one">◈</div>
70
+ <div class="atvwc-fish atvwc-fish-two">◈</div>
71
+ </div>
72
+ <div class="atvwc-result-heading">
73
+ <div><p class="atvwc-eyebrow">{t.netVolumeLabel}</p><h2 id="atvwc-results-title" data-result="net">{t.emptyState}</h2></div>
74
+ <span class="atvwc-scale" data-result="scale"></span>
75
+ </div>
76
+ <p class="atvwc-result-hint">{t.resultHint}</p>
77
+ <p class="atvwc-error" data-error role="alert"></p>
78
+ <p class="atvwc-empty" data-empty>{t.emptyState}</p>
79
+ <div class="atvwc-result-grid">
80
+ <div class="atvwc-result-card"><span>{t.grossVolumeLabel}</span><strong data-result="gross">-</strong></div>
81
+ <div class="atvwc-result-card"><span>{t.substrateVolumeLabel}</span><strong data-result="substrate">-</strong></div>
82
+ <div class="atvwc-result-card atvwc-result-card-highlight"><span>{t.changeVolumeLabel}</span><strong data-result="change">-</strong></div>
83
+ <div class="atvwc-result-card"><span>{t.waterWeightLabel}</span><strong data-result="mass">-</strong></div>
84
+ </div>
85
+ <div class="atvwc-footnotes"><span>{t.footprintLabel}: <strong data-result="footprint">-</strong></span><span>{t.approximationLabel}: <strong data-result="share">-</strong></span></div>
86
+ </section>
87
+ </div>
88
+ </div>
89
+
90
+ <script>
91
+ import { initAquariumTankVolumeWaterChangeCalculator } from './controller';
92
+ initAquariumTankVolumeWaterChangeCalculator();
93
+ </script>
@@ -0,0 +1,147 @@
1
+ import {
2
+ calculateTankVolume,
3
+ centimetresToInches,
4
+ inchesToCentimetres,
5
+ type TankInput,
6
+ type UnitSystem,
7
+ } from './logic';
8
+ import { defaultAquariumState, loadAquariumState, saveAquariumState, type SavedAquariumState } from './storage';
9
+ import { renderEmpty, renderError, renderResults } from './dom-views';
10
+ import type { AquariumTankVolumeWaterChangeCalculatorUI } from './ui';
11
+
12
+ interface ControllerContext {
13
+ root: HTMLElement;
14
+ ui: AquariumTankVolumeWaterChangeCalculatorUI;
15
+ state: SavedAquariumState;
16
+ locale: string;
17
+ }
18
+
19
+ const DIMENSION_FIELDS = ['lengthCm', 'widthCm', 'heightCm', 'diameterCm', 'bowDepthCm', 'substrateDepthCm'] as const;
20
+
21
+ function getInput(root: HTMLElement, field: string): HTMLInputElement | null {
22
+ const selector = '[data-input="' + field + '"]';
23
+ const inputs = Array.from(root.querySelectorAll<HTMLInputElement>(selector));
24
+ return inputs.find((input) => input.closest('[hidden]') === null) ?? inputs[0] ?? null;
25
+ }
26
+
27
+ function readNumber(root: HTMLElement, field: string, unit: UnitSystem): number {
28
+ const element = getInput(root, field);
29
+ const value = element === null ? Number.NaN : Number.parseFloat(element.value);
30
+ if (unit === 'metric' || field.endsWith('Percent')) return value;
31
+ return inchesToCentimetres(value);
32
+ }
33
+
34
+ function readInput(context: ControllerContext): TankInput {
35
+ const input = { ...context.state.input };
36
+ const activeShape = context.root.querySelector<HTMLElement>('[data-shape].is-active')?.dataset.shape;
37
+ if (activeShape === 'rectangular' || activeShape === 'cylindrical' || activeShape === 'bow') input.shape = activeShape;
38
+ for (const field of DIMENSION_FIELDS) input[field] = readNumber(context.root, field, context.state.unit);
39
+ input.changePercent = readNumber(context.root, 'changePercent', 'metric');
40
+ return input;
41
+ }
42
+
43
+ function writeNumber(root: HTMLElement, field: string, valueCm: number, unit: UnitSystem): void {
44
+ const element = getInput(root, field);
45
+ if (element === null) return;
46
+ const value = unit === 'metric' ? valueCm : centimetresToInches(valueCm);
47
+ element.value = String(Math.round(value * 10) / 10);
48
+ }
49
+
50
+ function setUnitButtons(context: ControllerContext): void {
51
+ context.root.querySelectorAll<HTMLElement>('[data-unit-choice]').forEach((button) => {
52
+ const active = button.dataset.unitChoice === context.state.unit;
53
+ button.classList.toggle('is-active', active);
54
+ button.setAttribute('aria-pressed', String(active));
55
+ });
56
+ const unitText = context.state.unit === 'metric' ? context.ui.lengthUnitMetric : context.ui.lengthUnitImperial;
57
+ context.root.querySelectorAll<HTMLElement>('[data-length-unit]').forEach((label) => {
58
+ label.textContent = unitText;
59
+ });
60
+ }
61
+
62
+ function setShapeButtons(context: ControllerContext): void {
63
+ context.root.querySelectorAll<HTMLElement>('[data-shape]').forEach((button) => {
64
+ const active = button.dataset.shape === context.state.input.shape;
65
+ button.classList.toggle('is-active', active);
66
+ button.setAttribute('aria-pressed', String(active));
67
+ });
68
+ context.root.querySelectorAll<HTMLElement>('[data-shape-fields]').forEach((group) => {
69
+ group.toggleAttribute('hidden', group.getAttribute('data-shape-fields') !== context.state.input.shape);
70
+ });
71
+ }
72
+
73
+ function writeInput(context: ControllerContext): void {
74
+ for (const field of DIMENSION_FIELDS) writeNumber(context.root, field, context.state.input[field], context.state.unit);
75
+ const change = getInput(context.root, 'changePercent');
76
+ if (change !== null) change.value = String(context.state.input.changePercent);
77
+ context.root.dataset.unit = context.state.unit;
78
+ setUnitButtons(context);
79
+ setShapeButtons(context);
80
+ }
81
+
82
+ function calculate(context: ControllerContext): void {
83
+ context.state.input = readInput(context);
84
+ saveAquariumState(context.state);
85
+ const result = calculateTankVolume(context.state.input);
86
+ if (result.ok === false) {
87
+ renderError(context.root, context.ui.invalidState);
88
+ return;
89
+ }
90
+ renderResults(context.root, {
91
+ result: result.value,
92
+ unit: context.state.unit,
93
+ locale: context.locale,
94
+ ui: context.ui,
95
+ });
96
+ }
97
+
98
+ function reset(context: ControllerContext): void {
99
+ context.state = defaultAquariumState();
100
+ writeInput(context);
101
+ renderEmpty(context.root, context.ui.emptyState);
102
+ calculate(context);
103
+ }
104
+
105
+ function bind(context: ControllerContext): void {
106
+ context.root.querySelectorAll<HTMLElement>('[data-shape]').forEach((button) => {
107
+ button.addEventListener('click', () => {
108
+ const shape = button.dataset.shape;
109
+ if (shape !== 'rectangular' && shape !== 'cylindrical' && shape !== 'bow') return;
110
+ context.state.input.shape = shape;
111
+ setShapeButtons(context);
112
+ calculate(context);
113
+ });
114
+ });
115
+ context.root.querySelectorAll<HTMLElement>('[data-unit-choice]').forEach((button) => {
116
+ button.addEventListener('click', () => {
117
+ const next = button.dataset.unitChoice;
118
+ if (next !== 'metric' && next !== 'imperial') return;
119
+ context.state.input = readInput(context);
120
+ context.state.unit = next;
121
+ writeInput(context);
122
+ calculate(context);
123
+ });
124
+ });
125
+ context.root.querySelectorAll('input').forEach((input) => input.addEventListener('input', () => calculate(context)));
126
+ context.root.querySelector('[data-action="reset"]')?.addEventListener('click', () => reset(context));
127
+ context.root.querySelector('[data-action="calculate"]')?.addEventListener('click', () => calculate(context));
128
+ }
129
+
130
+ function readUi(root: HTMLElement): AquariumTankVolumeWaterChangeCalculatorUI {
131
+ return JSON.parse(root.dataset.ui ?? '{}') as AquariumTankVolumeWaterChangeCalculatorUI;
132
+ }
133
+
134
+ export function initAquariumTankVolumeWaterChangeCalculator(): void {
135
+ const root = document.querySelector<HTMLElement>('#aquarium-tank-volume-water-change-calculator');
136
+ if (root === null) return;
137
+ const stored = loadAquariumState();
138
+ const context: ControllerContext = {
139
+ root,
140
+ ui: readUi(root),
141
+ state: stored ?? defaultAquariumState(),
142
+ locale: document.documentElement.lang || 'en',
143
+ };
144
+ writeInput(context);
145
+ bind(context);
146
+ calculate(context);
147
+ }