@jjlmoya/utils-aquarium 1.59.0 → 1.61.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.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +4 -1
- package/src/index.ts +2 -0
- package/src/tests/i18n_coverage.test.ts +1 -1
- package/src/tests/registry_contract.test.ts +67 -0
- package/src/tests/tool_validation.test.ts +4 -3
- package/src/tool/aquariumEvaporationTopOffPlanner/aquarium-evaporation-top-off-planner.css +490 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.astro +6 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.ts +10 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/component.astro +61 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/controller.ts +232 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/dom-views.ts +39 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/entry.ts +27 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/evaluator.ts +9 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/de.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/en.ts +179 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/es.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/fr.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/id.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/it.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ja.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ko.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/nl.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pl.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pt.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ru.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/shared.ts +79 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/sv.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/tr.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/translations.ts +509 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/zh.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/index.ts +11 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/logic.test.ts +61 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/logic.ts +102 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/seo.astro +13 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/storage.ts +28 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/ui.ts +49 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/aquarium-lighting-photoperiod-planner.css +9 -9
- package/src/tool/aquariumLightingPhotoperiodPlanner/component.astro +3 -1
- package/src/tool/aquariumLightingPhotoperiodPlanner/controller.ts +21 -9
- package/src/tool/aquariumLightingPhotoperiodPlanner/dom-views.test.ts +2 -1
- package/src/tool/aquariumLightingPhotoperiodPlanner/dom-views.ts +20 -8
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/de.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/en.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/es.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/fr.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/id.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/it.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ja.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ko.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/nl.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/pl.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/pt.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ru.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/sv.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/tr.ts +2 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/zh.ts +4 -2
- package/src/tool/aquariumLightingPhotoperiodPlanner/logic.test.ts +10 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/logic.ts +10 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/storage.ts +12 -8
- package/src/tool/aquariumLightingPhotoperiodPlanner/ui.ts +2 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/fr.ts +1 -1
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/it.ts +1 -1
- package/src/tools.ts +6 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { AquariumEvaporationTopOffPlannerUI } from './ui';
|
|
3
|
+
import './aquarium-evaporation-top-off-planner.css';
|
|
4
|
+
|
|
5
|
+
interface Props { ui: AquariumEvaporationTopOffPlannerUI; }
|
|
6
|
+
|
|
7
|
+
const { ui } = Astro.props;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<div class="n-evaporation" data-tool-root data-ui={JSON.stringify(ui)}>
|
|
11
|
+
<section class="n-output" aria-labelledby="n-evaporation-results">
|
|
12
|
+
<div class="n-output-head">
|
|
13
|
+
<div class="n-answer">
|
|
14
|
+
<span class="n-answer-label">{ui.topOffLabel}</span>
|
|
15
|
+
<h2 id="n-evaporation-results" class="n-main-result"><strong data-output="main-amount">—</strong><span data-output="main-frequency">{ui.emptyState}</span></h2>
|
|
16
|
+
</div>
|
|
17
|
+
<span class="n-status" data-output="status">{ui.stableStatus}</span>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="n-scene" data-output="scene" aria-live="polite"></div>
|
|
21
|
+
|
|
22
|
+
<div class="n-stat-grid" aria-live="polite">
|
|
23
|
+
<div class="n-stat n-stat-feature"><span>{ui.nextRefillLabel}</span><strong data-output="next-refill">—</strong></div>
|
|
24
|
+
<div class="n-stat"><span>{ui.dailyLossLabel}</span><strong data-output="daily-loss">—</strong></div>
|
|
25
|
+
<div class="n-stat"><span>{ui.levelAfterIntervalLabel}</span><strong data-output="level-after">—</strong></div>
|
|
26
|
+
<div class="n-stat"><span>{ui.reservoirDaysLabel}</span><strong data-output="reservoir-days">—</strong></div>
|
|
27
|
+
<div class="n-stat" data-salinity-result hidden><span>{ui.estimatedSalinityLabel}</span><strong data-output="salinity">—</strong></div>
|
|
28
|
+
</div>
|
|
29
|
+
<p class="n-alert" data-output="alert" role="status" hidden></p>
|
|
30
|
+
</section>
|
|
31
|
+
|
|
32
|
+
<section class="n-controls" aria-labelledby="n-evaporation-inputs">
|
|
33
|
+
<div class="n-controls-head">
|
|
34
|
+
<h2 id="n-evaporation-inputs">{ui.unitsLabel}</h2>
|
|
35
|
+
<div class="n-unit-toggle" role="group" aria-label={ui.unitsLabel}>
|
|
36
|
+
<button type="button" data-unit-choice="metric" class="is-active" aria-pressed="true">{ui.metricLabel}</button>
|
|
37
|
+
<button type="button" data-unit-choice="imperial" aria-pressed="false">{ui.imperialLabel}</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="n-fields">
|
|
42
|
+
<label class="n-field"><span>{ui.tankVolumeLabel} <small data-unit="tank">{ui.litreUnit}</small></span><input data-field="tankVolume" type="number" min="0.1" max="100000" step="0.1" inputmode="decimal" /></label>
|
|
43
|
+
<div class="n-field"><div class="n-field-head"><label for="n-evaporation-loss">{ui.measuredLossLabel} <small data-unit="loss">{ui.millilitreUnit}</small></label><div class="n-mini-toggle" role="group" aria-label={ui.lossUnitLabel}><button type="button" data-loss-unit-choice="millilitres" class="is-active" aria-pressed="true">{ui.millilitreUnit}</button><button type="button" data-loss-unit-choice="litres" aria-pressed="false">{ui.litreUnit}</button><button type="button" data-loss-unit-choice="fluidOunces" aria-pressed="false">{ui.fluidOunceUnit}</button></div></div><input id="n-evaporation-loss" data-field="measuredLoss" type="number" min="0.1" max="100000" step="0.1" inputmode="decimal" /></div>
|
|
44
|
+
<label class="n-field"><span>{ui.observationDaysLabel} <small>{ui.dayUnit}</small></span><input data-field="observationDays" type="number" min="1" max="90" step="1" inputmode="numeric" /></label>
|
|
45
|
+
<label class="n-field"><span>{ui.refillIntervalLabel} <small>{ui.dayUnit}</small></span><input data-field="refillIntervalDays" type="number" min="1" max="30" step="1" inputmode="numeric" /></label>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<details class="n-advanced"><summary>{ui.advancedLabel}</summary><div class="n-advanced-grid"><label class="n-field"><span>{ui.reservoirCapacityLabel} <small data-unit="reservoir">{ui.litreUnit}</small></span><input data-field="reservoirCapacity" type="number" min="0.1" max="100000" step="0.1" inputmode="decimal" /></label><div class="n-water-type"><span>{ui.waterTypeLabel}</span><div class="n-mini-toggle" role="group" aria-label={ui.waterTypeLabel}><button type="button" data-water-type-choice="freshwater" class="is-active" aria-pressed="true">{ui.freshwaterLabel}</button><button type="button" data-water-type-choice="marine" aria-pressed="false">{ui.marineLabel}</button></div></div><label class="n-field" data-marine-field hidden><span>{ui.startingSalinityLabel} <small>SG</small></span><input data-field="startingSpecificGravity" type="number" min="1" max="1.04" step="0.001" inputmode="decimal" /></label></div></details>
|
|
49
|
+
<div class="n-schedule-head"><h3>{ui.scheduleTitle}</h3><span>{ui.levelLabel}</span></div>
|
|
50
|
+
<div class="n-calendar" data-output="schedule" role="list" aria-label={ui.scheduleTitle}></div>
|
|
51
|
+
</section>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<script>
|
|
55
|
+
import { startEvaporationController } from './controller';
|
|
56
|
+
|
|
57
|
+
const root = document.querySelector<HTMLElement>('[data-tool-root]');
|
|
58
|
+
if (root?.dataset.ui) {
|
|
59
|
+
startEvaporationController({ root, ui: JSON.parse(root.dataset.ui) });
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { calculateEvaporation, type EvaporationInputs } from './logic';
|
|
2
|
+
import { evaluateEvaporation, type EvaporationStatus } from './evaluator';
|
|
3
|
+
import { formatValue, renderScheduleRows, renderWaterline } from './dom-views';
|
|
4
|
+
import { readEvaporationState, writeEvaporationState, type StoredEvaporationState } from './storage';
|
|
5
|
+
import type { AquariumEvaporationTopOffPlannerUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface ControllerOptions {
|
|
8
|
+
root: HTMLElement;
|
|
9
|
+
ui: AquariumEvaporationTopOffPlannerUI;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Units = 'metric' | 'imperial';
|
|
13
|
+
type LossUnit = 'millilitres' | 'litres' | 'fluidOunces';
|
|
14
|
+
type WaterType = 'freshwater' | 'marine';
|
|
15
|
+
|
|
16
|
+
interface DisplayState {
|
|
17
|
+
units: Units;
|
|
18
|
+
lossUnit: LossUnit;
|
|
19
|
+
waterType: WaterType;
|
|
20
|
+
input: EvaporationInputs;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const IMPERIAL_GALLON_LITRES = 4.54609;
|
|
24
|
+
const IMPERIAL_FLUID_OUNCE_MILLILITRES = 28.4131;
|
|
25
|
+
const DEFAULT_INPUT: EvaporationInputs = {
|
|
26
|
+
tankVolumeLitres: 120,
|
|
27
|
+
measuredLossMillilitres: 1200,
|
|
28
|
+
observationDays: 7,
|
|
29
|
+
refillIntervalDays: 3,
|
|
30
|
+
reservoirCapacityLitres: 10,
|
|
31
|
+
startingSpecificGravity: 1.025,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function numberInput(root: HTMLElement, field: string): HTMLInputElement {
|
|
35
|
+
return root.querySelector(`[data-field="${field}"]`) as HTMLInputElement;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lossToMillilitres(value: number, unit: LossUnit): number {
|
|
39
|
+
if (unit === 'litres') return value * 1000;
|
|
40
|
+
if (unit === 'fluidOunces') return value * IMPERIAL_FLUID_OUNCE_MILLILITRES;
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function millilitresToLoss(value: number, unit: LossUnit): number {
|
|
45
|
+
if (unit === 'litres') return value / 1000;
|
|
46
|
+
if (unit === 'fluidOunces') return value / IMPERIAL_FLUID_OUNCE_MILLILITRES;
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readInput(root: HTMLElement, state: DisplayState): EvaporationInputs {
|
|
51
|
+
const tankValue = Number(numberInput(root, 'tankVolume').value);
|
|
52
|
+
const lossValue = Number(numberInput(root, 'measuredLoss').value);
|
|
53
|
+
const reservoirValue = Number(numberInput(root, 'reservoirCapacity').value);
|
|
54
|
+
const salinityValue = Number(numberInput(root, 'startingSpecificGravity').value);
|
|
55
|
+
const input: EvaporationInputs = {
|
|
56
|
+
tankVolumeLitres: state.units === 'metric' ? tankValue : tankValue * IMPERIAL_GALLON_LITRES,
|
|
57
|
+
measuredLossMillilitres: lossToMillilitres(lossValue, state.lossUnit),
|
|
58
|
+
observationDays: Number(numberInput(root, 'observationDays').value),
|
|
59
|
+
refillIntervalDays: Number(numberInput(root, 'refillIntervalDays').value),
|
|
60
|
+
reservoirCapacityLitres: state.units === 'metric' ? reservoirValue : reservoirValue * IMPERIAL_GALLON_LITRES,
|
|
61
|
+
};
|
|
62
|
+
if (Number.isFinite(salinityValue) && salinityValue > 0) input.startingSpecificGravity = salinityValue;
|
|
63
|
+
return input;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeInput(root: HTMLElement, state: DisplayState): void {
|
|
67
|
+
const tank = state.units === 'metric' ? state.input.tankVolumeLitres : state.input.tankVolumeLitres / IMPERIAL_GALLON_LITRES;
|
|
68
|
+
const loss = millilitresToLoss(state.input.measuredLossMillilitres, state.lossUnit);
|
|
69
|
+
const reservoirLitres = state.input.reservoirCapacityLitres ?? 10;
|
|
70
|
+
const reservoir = state.units === 'metric' ? reservoirLitres : reservoirLitres / IMPERIAL_GALLON_LITRES;
|
|
71
|
+
numberInput(root, 'tankVolume').value = String(Number(tank.toFixed(2)));
|
|
72
|
+
numberInput(root, 'measuredLoss').value = String(Number(loss.toFixed(2)));
|
|
73
|
+
numberInput(root, 'observationDays').value = String(state.input.observationDays);
|
|
74
|
+
numberInput(root, 'refillIntervalDays').value = String(state.input.refillIntervalDays);
|
|
75
|
+
numberInput(root, 'reservoirCapacity').value = String(Number(reservoir.toFixed(2)));
|
|
76
|
+
numberInput(root, 'startingSpecificGravity').value = String(state.input.startingSpecificGravity ?? 1.025);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function unitLabel(ui: AquariumEvaporationTopOffPlannerUI, unit: LossUnit): string {
|
|
80
|
+
if (unit === 'litres') return ui.litreUnit;
|
|
81
|
+
if (unit === 'fluidOunces') return ui.fluidOunceUnit;
|
|
82
|
+
return ui.millilitreUnit;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function volumeUnit(ui: AquariumEvaporationTopOffPlannerUI, units: Units): string {
|
|
86
|
+
return units === 'metric' ? ui.litreUnit : ui.gallonUnit;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function lossUnitForUnits(units: Units, current: LossUnit): LossUnit {
|
|
90
|
+
if (units === 'imperial') return 'fluidOunces';
|
|
91
|
+
return current === 'fluidOunces' ? 'millilitres' : current;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function updateLabels(root: HTMLElement, ui: AquariumEvaporationTopOffPlannerUI, state: DisplayState): void {
|
|
95
|
+
const volume = volumeUnit(ui, state.units);
|
|
96
|
+
root.querySelector('[data-unit="tank"]')?.replaceChildren(document.createTextNode(volume));
|
|
97
|
+
root.querySelector('[data-unit="reservoir"]')?.replaceChildren(document.createTextNode(volume));
|
|
98
|
+
root.querySelector('[data-unit="loss"]')?.replaceChildren(document.createTextNode(unitLabel(ui, state.lossUnit)));
|
|
99
|
+
root.querySelectorAll<HTMLButtonElement>('[data-unit-choice]').forEach((button) => {
|
|
100
|
+
const active = button.dataset.unitChoice === state.units;
|
|
101
|
+
button.classList.toggle('is-active', active);
|
|
102
|
+
button.setAttribute('aria-pressed', String(active));
|
|
103
|
+
});
|
|
104
|
+
root.querySelectorAll<HTMLButtonElement>('[data-loss-unit-choice]').forEach((button) => {
|
|
105
|
+
const visible = state.units === 'metric' || button.dataset.lossUnitChoice === 'fluidOunces';
|
|
106
|
+
button.hidden = !visible;
|
|
107
|
+
const active = button.dataset.lossUnitChoice === state.lossUnit;
|
|
108
|
+
button.classList.toggle('is-active', active);
|
|
109
|
+
button.setAttribute('aria-pressed', String(active));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function updateWaterType(root: HTMLElement, state: DisplayState): void {
|
|
114
|
+
root.querySelectorAll<HTMLButtonElement>('[data-water-type-choice]').forEach((button) => {
|
|
115
|
+
const active = button.dataset.waterTypeChoice === state.waterType;
|
|
116
|
+
button.classList.toggle('is-active', active);
|
|
117
|
+
button.setAttribute('aria-pressed', String(active));
|
|
118
|
+
});
|
|
119
|
+
const marineField = root.querySelector<HTMLElement>('[data-marine-field]');
|
|
120
|
+
if (marineField) marineField.hidden = state.waterType !== 'marine';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function outputVolume(valueLitres: number, units: Units): number {
|
|
124
|
+
return units === 'metric' ? valueLitres : valueLitres / IMPERIAL_GALLON_LITRES;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function outputLoss(valueLitres: number, unit: LossUnit): number {
|
|
128
|
+
return millilitresToLoss(valueLitres * 1000, unit);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function statusText(ui: AquariumEvaporationTopOffPlannerUI, status: EvaporationStatus): string {
|
|
132
|
+
if (status === 'frequent') return ui.frequentStatus;
|
|
133
|
+
if (status === 'watch') return ui.watchStatus;
|
|
134
|
+
return ui.stableStatus;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function setOutput(root: HTMLElement, selector: string, value: string): void {
|
|
138
|
+
const element = root.querySelector(selector);
|
|
139
|
+
if (element) element.textContent = value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clearResults(root: HTMLElement, ui: AquariumEvaporationTopOffPlannerUI): void {
|
|
143
|
+
setOutput(root, '[data-output="main-amount"]', '—');
|
|
144
|
+
setOutput(root, '[data-output="main-frequency"]', ui.invalidState);
|
|
145
|
+
root.querySelector('[data-output="scene"]')?.replaceChildren();
|
|
146
|
+
root.querySelector('[data-output="schedule"]')?.replaceChildren();
|
|
147
|
+
const alert = root.querySelector<HTMLElement>('[data-output="alert"]');
|
|
148
|
+
if (alert) alert.hidden = true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderSecondary(root: HTMLElement, ui: AquariumEvaporationTopOffPlannerUI, result: NonNullable<ReturnType<typeof calculateEvaporation>>, state: DisplayState): void {
|
|
152
|
+
const loss = formatValue(outputLoss(result.dailyLossLitres, state.lossUnit), 'en-US', state.lossUnit === 'litres' ? 2 : 1);
|
|
153
|
+
setOutput(root, '[data-output="daily-loss"]', `${loss} ${unitLabel(ui, state.lossUnit)}`);
|
|
154
|
+
setOutput(root, '[data-output="next-refill"]', `${formatValue(outputVolume(result.nextRefillLitres, state.units), 'en-US', 2)} ${volumeUnit(ui, state.units)}`);
|
|
155
|
+
setOutput(root, '[data-output="level-after"]', `${formatValue(result.levelAfterIntervalPercent, 'en-US', 1)} ${ui.percentUnit}`);
|
|
156
|
+
setOutput(root, '[data-output="reservoir-days"]', result.reservoirDays === undefined ? '—' : `${formatValue(result.reservoirDays, 'en-US', 1)} ${ui.dayUnit}`);
|
|
157
|
+
setOutput(root, '[data-output="salinity"]', result.salinityAfterInterval === undefined ? '—' : formatValue(result.salinityAfterInterval, 'en-US', 3));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderSignals(root: HTMLElement, ui: AquariumEvaporationTopOffPlannerUI, result: NonNullable<ReturnType<typeof calculateEvaporation>>, state: DisplayState): void {
|
|
161
|
+
const alert = root.querySelector<HTMLElement>('[data-output="alert"]');
|
|
162
|
+
if (alert) {
|
|
163
|
+
alert.textContent = result.isCritical ? ui.criticalAlert : '';
|
|
164
|
+
alert.hidden = !result.isCritical;
|
|
165
|
+
}
|
|
166
|
+
const salinity = root.querySelector<HTMLElement>('[data-salinity-result]');
|
|
167
|
+
if (salinity) salinity.hidden = state.waterType !== 'marine';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderResults(root: HTMLElement, ui: AquariumEvaporationTopOffPlannerUI, state: DisplayState): void {
|
|
171
|
+
const result = calculateEvaporation(state.input);
|
|
172
|
+
if (result === null) {
|
|
173
|
+
clearResults(root, ui);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
setOutput(root, '[data-output="main-amount"]', `${formatValue(outputVolume(result.nextRefillLitres, state.units), 'en-US', 2)} ${volumeUnit(ui, state.units)}`);
|
|
177
|
+
setOutput(root, '[data-output="main-frequency"]', `${ui.everyLabel} ${state.input.refillIntervalDays} ${ui.dayUnit}`);
|
|
178
|
+
setOutput(root, '[data-output="status"]', statusText(ui, evaluateEvaporation(result)));
|
|
179
|
+
renderSecondary(root, ui, result, state);
|
|
180
|
+
renderSignals(root, ui, result, state);
|
|
181
|
+
const scene = root.querySelector('[data-output="scene"]');
|
|
182
|
+
if (scene) scene.innerHTML = renderWaterline(result, ui);
|
|
183
|
+
const schedule = root.querySelector('[data-output="schedule"]');
|
|
184
|
+
if (schedule) schedule.innerHTML = renderScheduleRows(result, ui, state.units);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function storedState(): DisplayState {
|
|
188
|
+
const stored = readEvaporationState() as StoredEvaporationState | null;
|
|
189
|
+
if (stored === null) return { units: 'metric', lossUnit: 'millilitres', waterType: 'freshwater', input: DEFAULT_INPUT };
|
|
190
|
+
const units: Units = stored.units === 'imperial' ? 'imperial' : 'metric';
|
|
191
|
+
const lossUnit = stored.lossUnit ?? (units === 'imperial' ? 'fluidOunces' : 'millilitres');
|
|
192
|
+
return {
|
|
193
|
+
units,
|
|
194
|
+
lossUnit,
|
|
195
|
+
waterType: stored.waterType === 'marine' ? 'marine' : 'freshwater',
|
|
196
|
+
input: { ...DEFAULT_INPUT, ...stored.input },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function persistAndRender(root: HTMLElement, ui: AquariumEvaporationTopOffPlannerUI, state: DisplayState): void {
|
|
201
|
+
updateLabels(root, ui, state);
|
|
202
|
+
updateWaterType(root, state);
|
|
203
|
+
renderResults(root, ui, state);
|
|
204
|
+
writeEvaporationState(state);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function startEvaporationController({ root, ui }: ControllerOptions): void {
|
|
208
|
+
let state = storedState();
|
|
209
|
+
writeInput(root, state);
|
|
210
|
+
persistAndRender(root, ui, state);
|
|
211
|
+
root.querySelectorAll<HTMLButtonElement>('[data-unit-choice]').forEach((button) => button.addEventListener('click', () => {
|
|
212
|
+
const nextUnits = button.dataset.unitChoice as Units;
|
|
213
|
+
const nextLossUnit = lossUnitForUnits(nextUnits, state.lossUnit);
|
|
214
|
+
state = { ...state, units: nextUnits, lossUnit: nextLossUnit, input: readInput(root, state) };
|
|
215
|
+
writeInput(root, state);
|
|
216
|
+
persistAndRender(root, ui, state);
|
|
217
|
+
}));
|
|
218
|
+
root.querySelectorAll<HTMLButtonElement>('[data-loss-unit-choice]').forEach((button) => button.addEventListener('click', () => {
|
|
219
|
+
const nextLossUnit = button.dataset.lossUnitChoice as LossUnit;
|
|
220
|
+
state = { ...state, lossUnit: nextLossUnit, input: readInput(root, state) };
|
|
221
|
+
writeInput(root, state);
|
|
222
|
+
persistAndRender(root, ui, state);
|
|
223
|
+
}));
|
|
224
|
+
root.querySelectorAll<HTMLButtonElement>('[data-water-type-choice]').forEach((button) => button.addEventListener('click', () => {
|
|
225
|
+
state = { ...state, waterType: button.dataset.waterTypeChoice as WaterType };
|
|
226
|
+
persistAndRender(root, ui, state);
|
|
227
|
+
}));
|
|
228
|
+
root.querySelectorAll<HTMLInputElement>('input').forEach((input) => input.addEventListener('input', () => {
|
|
229
|
+
state = { ...state, input: readInput(root, state) };
|
|
230
|
+
persistAndRender(root, ui, state);
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AquariumEvaporationTopOffPlannerUI } from './ui';
|
|
2
|
+
import type { EvaporationResult } from './logic';
|
|
3
|
+
|
|
4
|
+
export function formatValue(value: number, locale: string, digits = 1): string {
|
|
5
|
+
return new Intl.NumberFormat(locale, {
|
|
6
|
+
minimumFractionDigits: digits,
|
|
7
|
+
maximumFractionDigits: digits,
|
|
8
|
+
}).format(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
12
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderWaterline(result: EvaporationResult, ui: AquariumEvaporationTopOffPlannerUI): string {
|
|
16
|
+
const level = clamp(result.levelAfterIntervalPercent, 0, 100);
|
|
17
|
+
const waterHeight = 156 * (level / 100);
|
|
18
|
+
const waterY = 222 - waterHeight;
|
|
19
|
+
const lossHeight = 156 - waterHeight;
|
|
20
|
+
return `<svg class="n-evaporation-scene" viewBox="0 0 320 250" role="img" aria-label="${ui.levelAfterIntervalLabel}"><rect class="n-tank-frame" x="54" y="38" width="212" height="184" rx="12"></rect><rect class="n-water" x="62" y="${waterY.toFixed(2)}" width="196" height="${waterHeight.toFixed(2)}" rx="6"></rect><rect class="n-loss-zone" x="62" y="66" width="196" height="${Math.max(0, lossHeight).toFixed(2)}" rx="6"></rect><line class="n-waterline" x1="62" y1="${waterY.toFixed(2)}" x2="258" y2="${waterY.toFixed(2)}"></line><path class="n-mark" d="M42 66h18M42 222h18"></path><text class="n-mark-label" x="18" y="71">100%</text><text class="n-mark-label" x="18" y="227">0%</text><text class="n-scene-label" x="160" y="25">${formatValue(level, 'en-US', 1)}%</text><text class="n-scene-caption" x="160" y="240">${ui.levelLabel}</text></svg>`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function unitFor(ui: AquariumEvaporationTopOffPlannerUI, units: 'metric' | 'imperial'): string {
|
|
24
|
+
return units === 'metric' ? ui.litreUnit : ui.gallonUnit;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderScheduleRows(
|
|
28
|
+
result: EvaporationResult,
|
|
29
|
+
ui: AquariumEvaporationTopOffPlannerUI,
|
|
30
|
+
units: 'metric' | 'imperial',
|
|
31
|
+
): string {
|
|
32
|
+
return result.schedule.slice(1).map((point) => {
|
|
33
|
+
const refill = point.refillLitres > 0
|
|
34
|
+
? `${formatValue(point.refillLitres / (units === 'metric' ? 1 : 4.54609), 'en-US', 2)} ${unitFor(ui, units)}`
|
|
35
|
+
: '';
|
|
36
|
+
const marker = refill === '' ? '' : `<strong>${refill}</strong>`;
|
|
37
|
+
return `<div class="n-calendar-item${refill === '' ? '' : ' is-refill'}" role="listitem"><span>${point.day}</span><small>${formatValue(point.levelPercent, 'en-US', 1)}%</small>${marker}</div>`;
|
|
38
|
+
}).join('');
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AquariumToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { AquariumEvaporationTopOffPlannerUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { AquariumEvaporationTopOffPlannerUI };
|
|
5
|
+
export type AquariumEvaporationTopOffPlannerLocaleContent = ToolLocaleContent<AquariumEvaporationTopOffPlannerUI>;
|
|
6
|
+
|
|
7
|
+
export const aquariumEvaporationTopOffPlanner: AquariumToolEntry<AquariumEvaporationTopOffPlannerUI> = {
|
|
8
|
+
id: 'aquarium-evaporation-top-off-planner',
|
|
9
|
+
icons: { bg: 'mdi:fishbowl-outline', fg: 'mdi:water-minus' },
|
|
10
|
+
i18n: {
|
|
11
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
12
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
13
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
14
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
15
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
16
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
17
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
18
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
19
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
20
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
21
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
22
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
23
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
24
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
25
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EvaporationResult } from './logic';
|
|
2
|
+
|
|
3
|
+
export type EvaporationStatus = 'steady' | 'watch' | 'frequent';
|
|
4
|
+
|
|
5
|
+
export function evaluateEvaporation(result: EvaporationResult): EvaporationStatus {
|
|
6
|
+
if (result.dailyRatePercent >= 1) return 'frequent';
|
|
7
|
+
if (result.dailyRatePercent >= 0.5) return 'watch';
|
|
8
|
+
return 'steady';
|
|
9
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
2
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
3
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
4
|
+
import type { AquariumEvaporationTopOffPlannerUI } from '../ui';
|
|
5
|
+
|
|
6
|
+
const slug = 'aquarium-evaporation-top-off-planner';
|
|
7
|
+
const title = 'Aquarium Evaporation and Top Off Planner';
|
|
8
|
+
const description = 'Turn a real aquarium evaporation measurement into a daily loss, refill amount and simple top off calendar for freshwater or saltwater tanks.';
|
|
9
|
+
|
|
10
|
+
const faq = [
|
|
11
|
+
{
|
|
12
|
+
question: 'How should I measure evaporation for this planner?',
|
|
13
|
+
answer: 'Mark the normal waterline, avoid water changes, measure the water you add after several days, and enter that amount with the exact observation period. A longer observation reduces the effect of one unusual day.',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
question: 'Does evaporation remove salt from a marine aquarium?',
|
|
17
|
+
answer: 'No. Evaporation removes water while dissolved salt remains. Use the planner for the missing volume, then follow your aquarium method for preparing top off water and check salinity when appropriate.',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
question: 'Why does the planner use my own measurement instead of room conditions?',
|
|
21
|
+
answer: 'Aquarium covers, airflow, lighting, temperature, humidity and surface agitation can change the loss. A measured loss is more useful for scheduling than a generic climate estimate.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
question: 'Can this replace water changes?',
|
|
25
|
+
answer: 'No. Top off replaces water lost to evaporation. It does not remove accumulated waste or replace a water change chosen for water quality and animal care.',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const howTo = [
|
|
30
|
+
{
|
|
31
|
+
name: 'Enter the working tank volume',
|
|
32
|
+
text: 'Use the approximate water volume currently in the aquarium, not the outside dimensions or the advertised maximum capacity.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'Record a real evaporation loss',
|
|
36
|
+
text: 'Measure how much water you add after a stable observation period. Enter the amount in millilitres or fluid ounces and the number of days observed.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'Choose a refill interval',
|
|
40
|
+
text: 'Choose how often you want to top off. The planner estimates the amount due at that interval and shows the level between refills.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'Use the calendar as a routine',
|
|
44
|
+
text: 'Use the projected refill rows as a reminder, then remeasure after changes to the cover, airflow, lighting or room conditions.',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
49
|
+
'@context': 'https://schema.org',
|
|
50
|
+
'@type': 'FAQPage',
|
|
51
|
+
mainEntity: faq.map((item) => ({
|
|
52
|
+
'@type': 'Question',
|
|
53
|
+
name: item.question,
|
|
54
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const howToSchema: WithContext<HowTo> = {
|
|
59
|
+
'@context': 'https://schema.org',
|
|
60
|
+
'@type': 'HowTo',
|
|
61
|
+
name: title,
|
|
62
|
+
description,
|
|
63
|
+
step: howTo.map((step, index) => ({
|
|
64
|
+
'@type': 'HowToStep',
|
|
65
|
+
position: index + 1,
|
|
66
|
+
name: step.name,
|
|
67
|
+
text: step.text,
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
72
|
+
'@context': 'https://schema.org',
|
|
73
|
+
'@type': 'SoftwareApplication',
|
|
74
|
+
name: title,
|
|
75
|
+
description,
|
|
76
|
+
applicationCategory: 'UtilityApplication',
|
|
77
|
+
operatingSystem: 'All',
|
|
78
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
79
|
+
inLanguage: 'en',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const content: ToolLocaleContent<AquariumEvaporationTopOffPlannerUI> = {
|
|
83
|
+
slug,
|
|
84
|
+
title,
|
|
85
|
+
description,
|
|
86
|
+
faq,
|
|
87
|
+
bibliography,
|
|
88
|
+
howTo,
|
|
89
|
+
schemas: [faqSchema, howToSchema, appSchema],
|
|
90
|
+
seo: [
|
|
91
|
+
{ type: 'title', text: 'Schedule From a Real Waterline', level: 2 },
|
|
92
|
+
{
|
|
93
|
+
type: 'paragraph',
|
|
94
|
+
html: 'Evaporation is easy to notice and surprisingly easy to misjudge. A covered tank in a humid room can lose very little, while strong surface movement, warm lights or dry air can make the waterline fall faster. This planner starts with the amount you actually replaced, so the routine reflects your aquarium rather than a generic room estimate.',
|
|
95
|
+
},
|
|
96
|
+
{ type: 'title', text: 'Measure the Loss Before You Plan the Top Off', level: 2 },
|
|
97
|
+
{
|
|
98
|
+
type: 'paragraph',
|
|
99
|
+
html: 'Mark the normal waterline and observe the tank for several days without a water change. Measure the water added to restore the line. The planner divides that measured volume by the observation period, then projects the loss to your chosen refill interval. Repeat the measurement after changing a lid, fan, light schedule or room location.',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: 'list',
|
|
103
|
+
items: [
|
|
104
|
+
'<strong>Use a stable observation period:</strong> several days are more useful than a single reading.',
|
|
105
|
+
'<strong>Use working volume:</strong> include the water currently present, not the empty glass capacity.',
|
|
106
|
+
'<strong>Choose a practical interval:</strong> shorter intervals reduce the visible level swing.',
|
|
107
|
+
'<strong>Recheck after changes:</strong> airflow and surface agitation can change the measured rate.',
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
{ type: 'title', text: 'Read the Waterline as a Maintenance Signal', level: 2 },
|
|
111
|
+
{
|
|
112
|
+
type: 'paragraph',
|
|
113
|
+
html: 'The daily rate shows how much water the tank loses relative to its working volume. The next refill tells you what to prepare for the selected interval, while the calendar makes the loss visible between refills. These are scheduling estimates, not a water quality assessment and not a substitute for observing animals and testing the water.',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: 'tip',
|
|
117
|
+
title: 'Saltwater needs a separate salinity check',
|
|
118
|
+
html: '<p>Evaporation leaves dissolved salt behind. For a marine aquarium, top off with the water type required by your method and check salinity when the level has moved noticeably. Never treat the calculated volume as a universal dosing instruction.</p>',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: 'table',
|
|
122
|
+
headers: ['Output', 'What it tells you'],
|
|
123
|
+
rows: [
|
|
124
|
+
['Daily loss', 'The measured evaporation converted to one day'],
|
|
125
|
+
['Next refill', 'The volume expected at your chosen interval'],
|
|
126
|
+
['Thirty day loss', 'A planning estimate for water preparation'],
|
|
127
|
+
['Level after interval', 'The projected level before the scheduled top off'],
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
ui: {
|
|
132
|
+
unitsLabel: 'Units',
|
|
133
|
+
metricLabel: 'Metric',
|
|
134
|
+
imperialLabel: 'Imperial',
|
|
135
|
+
tankVolumeLabel: 'Working tank volume',
|
|
136
|
+
measuredLossLabel: 'Water added after observation',
|
|
137
|
+
lossUnitLabel: 'Measured loss unit',
|
|
138
|
+
observationDaysLabel: 'Observation period',
|
|
139
|
+
refillIntervalLabel: 'Top off every',
|
|
140
|
+
reservoirCapacityLabel: 'ATO reservoir',
|
|
141
|
+
advancedLabel: 'Optional ATO and marine settings',
|
|
142
|
+
waterTypeLabel: 'Water type',
|
|
143
|
+
freshwaterLabel: 'Freshwater',
|
|
144
|
+
marineLabel: 'Marine',
|
|
145
|
+
startingSalinityLabel: 'Starting salinity',
|
|
146
|
+
estimatedSalinityLabel: 'Estimated SG',
|
|
147
|
+
reservoirDaysLabel: 'ATO autonomy',
|
|
148
|
+
criticalAlert: 'Critical level swing: shorten the refill interval.',
|
|
149
|
+
volumeHint: 'Use the water currently in the tank.',
|
|
150
|
+
lossHint: 'Measure the amount needed to restore the line.',
|
|
151
|
+
intervalHint: 'Shorter intervals make the level swing smaller.',
|
|
152
|
+
resultTitle: 'Your top off rhythm',
|
|
153
|
+
dailyLossLabel: 'Daily loss',
|
|
154
|
+
dailyRateLabel: 'Daily rate of tank volume',
|
|
155
|
+
nextRefillLabel: 'Next top off',
|
|
156
|
+
thirtyDayLabel: 'Thirty day preparation',
|
|
157
|
+
levelAfterIntervalLabel: 'Projected level before top off',
|
|
158
|
+
twoPercentDaysLabel: 'Days to lose 2 percent',
|
|
159
|
+
scheduleTitle: 'Fourteen day waterline',
|
|
160
|
+
dayLabel: 'Day',
|
|
161
|
+
topOffLabel: 'Top off',
|
|
162
|
+
everyLabel: 'every',
|
|
163
|
+
levelLabel: 'Level before top off',
|
|
164
|
+
stableStatus: 'Steady measured loss',
|
|
165
|
+
watchStatus: 'Watch the waterline',
|
|
166
|
+
frequentStatus: 'Frequent top off likely',
|
|
167
|
+
emptyState: 'Add your tank data to begin.',
|
|
168
|
+
invalidState: 'Check the values: use positive numbers, an observation up to 90 days and a refill interval up to 30 days.',
|
|
169
|
+
measurementNote: 'This projection is based on your measured loss. Recheck it after changing the tank cover, airflow, lighting or room conditions.',
|
|
170
|
+
saltwaterNote: 'Evaporation leaves dissolved salt behind. Follow your aquarium method for the correct top off water and check salinity when needed.',
|
|
171
|
+
resetLabel: 'Reset example',
|
|
172
|
+
litreUnit: 'L',
|
|
173
|
+
gallonUnit: 'imp gal',
|
|
174
|
+
millilitreUnit: 'mL',
|
|
175
|
+
fluidOunceUnit: 'fl oz',
|
|
176
|
+
dayUnit: 'days',
|
|
177
|
+
percentUnit: '%',
|
|
178
|
+
},
|
|
179
|
+
};
|