@jjlmoya/utils-aquarium 1.69.0 → 1.70.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 +13 -4
- package/src/entries.ts +18 -10
- package/src/index.ts +2 -0
- package/src/tests/mfe_assets_contract.test.ts +3 -1
- package/src/tests/tool_validation.test.ts +10 -8
- package/src/tool/aquariumNitrateDilutionCalculator/aquarium-nitrate-dilution-calculator.css +887 -0
- package/src/tool/aquariumNitrateDilutionCalculator/bibliography.astro +6 -0
- package/src/tool/aquariumNitrateDilutionCalculator/bibliography.ts +12 -0
- package/src/tool/aquariumNitrateDilutionCalculator/component.astro +120 -0
- package/src/tool/aquariumNitrateDilutionCalculator/controller.ts +96 -0
- package/src/tool/aquariumNitrateDilutionCalculator/dom-views.ts +151 -0
- package/src/tool/aquariumNitrateDilutionCalculator/entry.ts +27 -0
- package/src/tool/aquariumNitrateDilutionCalculator/evaluator.ts +17 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/de.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/en.ts +124 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/es.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/fr.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/id.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/it.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/ja.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/ko.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/nl.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/pl.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/pt.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/ru.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/sv.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/tr.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/i18n/zh.ts +30 -0
- package/src/tool/aquariumNitrateDilutionCalculator/index.ts +11 -0
- package/src/tool/aquariumNitrateDilutionCalculator/localized-content.ts +138 -0
- package/src/tool/aquariumNitrateDilutionCalculator/logic.test.ts +47 -0
- package/src/tool/aquariumNitrateDilutionCalculator/logic.ts +122 -0
- package/src/tool/aquariumNitrateDilutionCalculator/seo.astro +13 -0
- package/src/tool/aquariumNitrateDilutionCalculator/storage.ts +36 -0
- package/src/tool/aquariumNitrateDilutionCalculator/ui.ts +53 -0
- package/src/tool/aquariumNitrateDilutionCalculator/units.test.ts +17 -0
- package/src/tool/aquariumNitrateDilutionCalculator/units.ts +19 -0
- package/src/tools.ts +16 -37
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Bridges Lab Protocols, Dilution Formula',
|
|
6
|
+
url: 'https://bridgeslab.sph.umich.edu/protocols/index.php/Dilution_Formula',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'sera Ratgeber Aquariumpflege, Wasserwechsel',
|
|
10
|
+
url: 'https://www.sera.de/fileadmin/epapers/PDF/11102_RG_Aquariumpflege_D.pdf',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { AquariumNitrateDilutionCalculatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
interface Props { ui?: Record<string, unknown>; }
|
|
5
|
+
|
|
6
|
+
const { ui = {} } = Astro.props;
|
|
7
|
+
const t = ui as AquariumNitrateDilutionCalculatorUI;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<div id="aquarium-nitrate-dilution-calculator" class="and-tool">
|
|
11
|
+
<script is:inline id="aquarium-nitrate-dilution-calculator-ui" type="application/json" set:html={JSON.stringify(t)}></script>
|
|
12
|
+
<div class="and-surface">
|
|
13
|
+
<header class="and-topline">
|
|
14
|
+
<div class="and-identity">
|
|
15
|
+
<span class="and-no3-mark" aria-hidden="true">NO3</span>
|
|
16
|
+
<div>
|
|
17
|
+
<p class="and-kicker">{t.toolKicker}</p>
|
|
18
|
+
<p class="and-intro">{t.inputInstruction}</p>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<fieldset class="and-unit-switch">
|
|
22
|
+
<legend>{t.unitSystemLabel}</legend>
|
|
23
|
+
<div class="and-unit-options" role="group" aria-label={t.unitSystemLabel}>
|
|
24
|
+
<button type="button" data-unit-choice="metric" aria-pressed="true"><strong>{t.metricLabel}</strong><small>{t.metricUnitSummary}</small></button>
|
|
25
|
+
<button type="button" data-unit-choice="imperial" aria-pressed="false"><strong>{t.imperialLabel}</strong><small>{t.imperialUnitSummary}</small></button>
|
|
26
|
+
</div>
|
|
27
|
+
</fieldset>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<div class="and-workspace">
|
|
31
|
+
<section class="and-readings" aria-labelledby="and-readings-title">
|
|
32
|
+
<div class="and-section-heading">
|
|
33
|
+
<span class="and-section-index">01</span>
|
|
34
|
+
<div><p class="and-kicker">{t.readingsTitle}</p><p id="and-readings-title" class="and-section-note">{t.inputsLegend}</p></div>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="and-reading-list">
|
|
37
|
+
<label class="and-reading-row">
|
|
38
|
+
<span class="and-reading-index">A</span>
|
|
39
|
+
<span class="and-reading-copy"><strong>{t.volumeLabel}</strong><small>{t.volumeHint}</small></span>
|
|
40
|
+
<input data-input="volumeLitres" type="number" min="1" max="100000" step="1" inputmode="decimal" />
|
|
41
|
+
<em data-unit-label="volume">{t.volumeUnit}</em>
|
|
42
|
+
</label>
|
|
43
|
+
<label class="and-reading-row">
|
|
44
|
+
<span class="and-reading-index">B</span>
|
|
45
|
+
<span class="and-reading-copy"><strong>{t.currentLabel}</strong><small>{t.readingHint}</small></span>
|
|
46
|
+
<input data-input="currentNitrate" type="number" min="0" max="1000" step="0.1" inputmode="decimal" />
|
|
47
|
+
<em data-unit-label="nitrate">{t.nitrateUnit}</em>
|
|
48
|
+
</label>
|
|
49
|
+
<label class="and-reading-row">
|
|
50
|
+
<span class="and-reading-index">C</span>
|
|
51
|
+
<span class="and-reading-copy"><strong>{t.targetLabel}</strong><small>{t.readingHint}</small></span>
|
|
52
|
+
<input data-input="targetNitrate" type="number" min="0" max="1000" step="0.1" inputmode="decimal" />
|
|
53
|
+
<em data-unit-label="nitrate">{t.nitrateUnit}</em>
|
|
54
|
+
</label>
|
|
55
|
+
<label class="and-reading-row">
|
|
56
|
+
<span class="and-reading-index">D</span>
|
|
57
|
+
<span class="and-reading-copy"><strong>{t.sourceLabel}</strong><small>{t.sourceHint}</small></span>
|
|
58
|
+
<input data-input="sourceNitrate" type="number" min="0" max="1000" step="0.1" inputmode="decimal" />
|
|
59
|
+
<em data-unit-label="nitrate">{t.nitrateUnit}</em>
|
|
60
|
+
</label>
|
|
61
|
+
</div>
|
|
62
|
+
<button type="button" class="and-reset" data-action="reset">{t.resetLabel}</button>
|
|
63
|
+
</section>
|
|
64
|
+
|
|
65
|
+
<section class="and-decision" aria-live="polite" aria-labelledby="and-decision-title">
|
|
66
|
+
<div class="and-decision-heading">
|
|
67
|
+
<div><span class="and-section-index">02</span><p id="and-decision-title" class="and-kicker">{t.decisionTitle}</p></div>
|
|
68
|
+
<span class="and-status" data-result="status">{t.inputsLegend}</span>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="and-answer-block">
|
|
71
|
+
<span class="and-answer-label">{t.actionLabel}</span>
|
|
72
|
+
<strong class="and-answer-number" data-result="required">-</strong>
|
|
73
|
+
<span class="and-answer-unit">{t.requiredChangeLabel}</span>
|
|
74
|
+
</div>
|
|
75
|
+
<aside class="and-safety-note" data-safety-note hidden role="note">
|
|
76
|
+
<strong data-result="safety-label"></strong>
|
|
77
|
+
<span data-result="safety-copy"></span>
|
|
78
|
+
</aside>
|
|
79
|
+
<div class="and-flow-scene" aria-hidden="true">
|
|
80
|
+
<div class="and-vessel">
|
|
81
|
+
<span class="and-vessel-lid"></span>
|
|
82
|
+
<div class="and-vessel-body"><div class="and-water-fill"></div><span class="and-marker and-marker-current"><i></i>{t.currentMarker}</span><span class="and-marker and-marker-target"><i></i>{t.targetMarker}</span></div>
|
|
83
|
+
<span class="and-vessel-foot"></span>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="and-flow-arrow"><span>{t.sceneCaption}</span><i></i></div>
|
|
86
|
+
<div class="and-new-water"><span class="and-new-water-cap"></span><div class="and-new-water-body"><div class="and-new-water-fill"></div></div><small>{t.newWaterLabel}</small></div>
|
|
87
|
+
</div>
|
|
88
|
+
<p class="and-answer-copy" data-result="answer">{t.sourceHint}</p>
|
|
89
|
+
<div class="and-metrics">
|
|
90
|
+
<div><span>{t.requiredLitresLabel}</span><strong data-result="required-litres">-</strong></div>
|
|
91
|
+
<div><span>{t.afterChangeLabel}</span><strong data-result="after">-</strong></div>
|
|
92
|
+
<div><span data-result="scenario">{t.scenarioLabel}</span><strong data-result="repeat">-</strong></div>
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<section class="and-scenarios" aria-labelledby="and-scenarios-title">
|
|
98
|
+
<div class="and-scenario-heading"><div><span class="and-section-index">03</span><p id="and-scenarios-title" class="and-kicker">{t.scenarioLabel}</p></div><small>{t.scenarioHint}</small></div>
|
|
99
|
+
<div class="and-scenario-choices" role="group" aria-label={t.scenarioLabel}>
|
|
100
|
+
<button type="button" data-scenario-choice="25" aria-pressed="true"><i></i>{t.scenarioTwentyFive}</button>
|
|
101
|
+
<button type="button" data-scenario-choice="40" aria-pressed="false"><i></i>{t.scenarioForty}</button>
|
|
102
|
+
<button type="button" data-scenario-choice="50" aria-pressed="false"><i></i>{t.scenarioFifty}</button>
|
|
103
|
+
<button type="button" data-scenario-choice="75" aria-pressed="false"><i></i>{t.scenarioSeventyFive}</button>
|
|
104
|
+
</div>
|
|
105
|
+
<ol class="and-scenario-list" data-scenario-list></ol>
|
|
106
|
+
<details class="and-plan">
|
|
107
|
+
<summary>{t.planLabel}</summary>
|
|
108
|
+
<p>{t.planHint}</p>
|
|
109
|
+
<ol class="and-plan-list" data-plan-list></ol>
|
|
110
|
+
</details>
|
|
111
|
+
<p class="and-biology-note">{t.biologyHint}</p>
|
|
112
|
+
</section>
|
|
113
|
+
<p class="and-error" data-error role="alert"></p>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<script>
|
|
118
|
+
import { initAquariumNitrateDilutionCalculator } from './controller';
|
|
119
|
+
initAquariumNitrateDilutionCalculator();
|
|
120
|
+
</script>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { calculateNitrateDilution } from './logic';
|
|
2
|
+
import { defaultNitrateState, loadNitrateState, saveNitrateState, type SavedNitrateState } from './storage';
|
|
3
|
+
import { renderNitrateResults } from './dom-views';
|
|
4
|
+
import type { AquariumNitrateDilutionCalculatorUI } from './ui';
|
|
5
|
+
import { volumeFromDisplay, volumeToDisplay, type UnitSystem } from './units';
|
|
6
|
+
|
|
7
|
+
interface ControllerContext {
|
|
8
|
+
root: HTMLElement;
|
|
9
|
+
ui: AquariumNitrateDilutionCalculatorUI;
|
|
10
|
+
state: SavedNitrateState;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const INPUT_FIELDS = ['volumeLitres', 'currentNitrate', 'targetNitrate', 'sourceNitrate'] as const;
|
|
14
|
+
|
|
15
|
+
function getInput(root: HTMLElement, field: string): HTMLInputElement | null {
|
|
16
|
+
return root.querySelector<HTMLInputElement>(`[data-input="${field}"]`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeState(context: ControllerContext): void {
|
|
20
|
+
const volumeInput = getInput(context.root, 'volumeLitres');
|
|
21
|
+
if (volumeInput !== null) volumeInput.value = String(Number(volumeToDisplay(context.state.input.volumeLitres, context.state.unitSystem).toFixed(2)));
|
|
22
|
+
INPUT_FIELDS.filter((field) => field !== 'volumeLitres').forEach((field) => {
|
|
23
|
+
const input = getInput(context.root, field);
|
|
24
|
+
if (input !== null) input.value = String(context.state.input[field]);
|
|
25
|
+
});
|
|
26
|
+
context.root.querySelectorAll<HTMLElement>('[data-scenario-choice]').forEach((button) => {
|
|
27
|
+
const active = Number(button.dataset.scenarioChoice) === context.state.scenarioPercent;
|
|
28
|
+
button.classList.toggle('is-active', active);
|
|
29
|
+
button.setAttribute('aria-pressed', String(active));
|
|
30
|
+
});
|
|
31
|
+
context.root.querySelectorAll<HTMLElement>('[data-unit-choice]').forEach((button) => {
|
|
32
|
+
const active = button.dataset.unitChoice === context.state.unitSystem;
|
|
33
|
+
button.setAttribute('aria-pressed', String(active));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readState(context: ControllerContext): void {
|
|
38
|
+
INPUT_FIELDS.forEach((field) => {
|
|
39
|
+
const input = getInput(context.root, field);
|
|
40
|
+
const value = input === null ? Number.NaN : Number.parseFloat(input.value);
|
|
41
|
+
context.state.input[field] = field === 'volumeLitres' ? volumeFromDisplay(value, context.state.unitSystem) : value;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function render(context: ControllerContext, readInputs = true): void {
|
|
46
|
+
if (readInputs) readState(context);
|
|
47
|
+
saveNitrateState(context.state);
|
|
48
|
+
renderNitrateResults({ root: context.root, ui: context.ui, locale: 'en-US', scenarioPercent: context.state.scenarioPercent, unitSystem: context.state.unitSystem }, calculateNitrateDilution(context.state.input));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function reset(context: ControllerContext): void {
|
|
52
|
+
context.state = defaultNitrateState();
|
|
53
|
+
writeState(context);
|
|
54
|
+
render(context, false);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function bindInputs(context: ControllerContext): void {
|
|
58
|
+
context.root.querySelectorAll<HTMLInputElement>('[data-input]').forEach((input) => input.addEventListener('input', () => render(context)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bindScenarios(context: ControllerContext): void {
|
|
62
|
+
context.root.querySelectorAll<HTMLButtonElement>('[data-scenario-choice]').forEach((button) => {
|
|
63
|
+
button.addEventListener('click', () => {
|
|
64
|
+
context.state.scenarioPercent = Number(button.dataset.scenarioChoice);
|
|
65
|
+
writeState(context);
|
|
66
|
+
render(context);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function bindUnitSystem(context: ControllerContext): void {
|
|
72
|
+
context.root.querySelectorAll<HTMLButtonElement>('[data-unit-choice]').forEach((button) => {
|
|
73
|
+
button.addEventListener('click', () => {
|
|
74
|
+
const nextUnit = button.dataset.unitChoice;
|
|
75
|
+
if (nextUnit !== 'metric' && nextUnit !== 'imperial') return;
|
|
76
|
+
readState(context);
|
|
77
|
+
context.state.unitSystem = nextUnit as UnitSystem;
|
|
78
|
+
writeState(context);
|
|
79
|
+
render(context, false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function initAquariumNitrateDilutionCalculator(): void {
|
|
85
|
+
const root = document.querySelector<HTMLElement>('#aquarium-nitrate-dilution-calculator');
|
|
86
|
+
const uiElement = document.querySelector<HTMLScriptElement>('#aquarium-nitrate-dilution-calculator-ui');
|
|
87
|
+
if (root === null || uiElement === null) return;
|
|
88
|
+
const ui = JSON.parse(uiElement.textContent ?? '{}') as AquariumNitrateDilutionCalculatorUI;
|
|
89
|
+
const context: ControllerContext = { root, ui, state: loadNitrateState() };
|
|
90
|
+
writeState(context);
|
|
91
|
+
bindInputs(context);
|
|
92
|
+
bindScenarios(context);
|
|
93
|
+
bindUnitSystem(context);
|
|
94
|
+
root.querySelector<HTMLElement>('[data-action="reset"]')?.addEventListener('click', () => reset(context));
|
|
95
|
+
render(context, false);
|
|
96
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { NitrateCalculation, NitrateResult } from './logic';
|
|
2
|
+
import { evaluateNitrateCalculation } from './evaluator';
|
|
3
|
+
import type { AquariumNitrateDilutionCalculatorUI } from './ui';
|
|
4
|
+
import { nitrateUnit, volumeToDisplay, volumeUnit, type UnitSystem } from './units';
|
|
5
|
+
|
|
6
|
+
interface RenderContext {
|
|
7
|
+
root: HTMLElement;
|
|
8
|
+
ui: AquariumNitrateDilutionCalculatorUI;
|
|
9
|
+
locale: string;
|
|
10
|
+
scenarioPercent: number;
|
|
11
|
+
unitSystem: UnitSystem;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function numberFormat(value: number, locale: string, maximumFractionDigits = 1): string {
|
|
15
|
+
return new Intl.NumberFormat(locale, { maximumFractionDigits }).format(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function setText(root: HTMLElement, key: string, value: string): void {
|
|
19
|
+
const element = root.querySelector<HTMLElement>(`[data-result="${key}"]`);
|
|
20
|
+
if (element !== null) element.textContent = value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function setError(root: HTMLElement, message: string): void {
|
|
24
|
+
const error = root.querySelector<HTMLElement>('[data-error]');
|
|
25
|
+
if (error !== null) error.textContent = message;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function renderSafetyNote(context: RenderContext, requiredPercent: number): void {
|
|
29
|
+
const note = context.root.querySelector<HTMLElement>('[data-safety-note]');
|
|
30
|
+
context.root.classList.remove('is-high-change', 'is-critical-change');
|
|
31
|
+
if (note === null) return;
|
|
32
|
+
if (requiredPercent <= 50) {
|
|
33
|
+
note.hidden = true;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const critical = requiredPercent > 75;
|
|
37
|
+
context.root.classList.add(critical ? 'is-critical-change' : 'is-high-change');
|
|
38
|
+
note.hidden = false;
|
|
39
|
+
setText(context.root, 'safety-label', critical ? context.ui.criticalSafetyLabel : context.ui.safetyLabel);
|
|
40
|
+
setText(context.root, 'safety-copy', critical ? context.ui.criticalSafetyHint : context.ui.safetyHint);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setUnitLabels(context: RenderContext): void {
|
|
44
|
+
const displayVolumeUnit = volumeUnit(context.unitSystem);
|
|
45
|
+
const displayNitrateUnit = nitrateUnit(context.unitSystem);
|
|
46
|
+
context.root.querySelectorAll<HTMLElement>('[data-unit-label="volume"]').forEach((element) => { element.textContent = displayVolumeUnit; });
|
|
47
|
+
context.root.querySelectorAll<HTMLElement>('[data-unit-label="nitrate"]').forEach((element) => { element.textContent = displayNitrateUnit; });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function concentrationLevel(value: number): number {
|
|
51
|
+
return Math.min(100, Math.max(0.5, value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function setMarkers(root: HTMLElement, calculation: NitrateCalculation, selectedAfter: number): void {
|
|
55
|
+
root.style.setProperty('--n-current-level', `${concentrationLevel(calculation.currentNitrate)}%`);
|
|
56
|
+
root.style.setProperty('--n-target-level', `${concentrationLevel(calculation.targetNitrate)}%`);
|
|
57
|
+
root.style.setProperty('--n-after-level', `${concentrationLevel(selectedAfter)}%`);
|
|
58
|
+
root.style.setProperty('--n-source-level', `${concentrationLevel(calculation.sourceNitrate)}%`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderScenarioRows(context: RenderContext, calculation: NitrateCalculation): void {
|
|
62
|
+
const list = context.root.querySelector<HTMLOListElement>('[data-scenario-list]');
|
|
63
|
+
if (list === null) return;
|
|
64
|
+
list.replaceChildren();
|
|
65
|
+
calculation.scenarios.forEach((scenario) => {
|
|
66
|
+
const row = document.createElement('li');
|
|
67
|
+
const heading = document.createElement('span');
|
|
68
|
+
const result = document.createElement('strong');
|
|
69
|
+
const detail = document.createElement('small');
|
|
70
|
+
const repeat = scenario.reachesTargetAfter === null ? context.ui.notReached : `${scenario.reachesTargetAfter} ${context.ui.repeatLabel}`;
|
|
71
|
+
heading.textContent = `${scenario.percent}%`;
|
|
72
|
+
result.textContent = `${numberFormat(scenario.afterOne, context.locale)} ${nitrateUnit(context.unitSystem)}`;
|
|
73
|
+
detail.textContent = `${numberFormat(volumeToDisplay(scenario.litres, context.unitSystem), context.locale)} ${volumeUnit(context.unitSystem)}, ${repeat}`;
|
|
74
|
+
row.append(heading, result, detail);
|
|
75
|
+
list.append(row);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderPlan(context: RenderContext, scenario: NitrateCalculation['scenarios'][number] | undefined): void {
|
|
80
|
+
const list = context.root.querySelector<HTMLOListElement>('[data-plan-list]');
|
|
81
|
+
if (list === null) return;
|
|
82
|
+
list.replaceChildren();
|
|
83
|
+
if (scenario === undefined) return;
|
|
84
|
+
const stepCount = scenario.reachesTargetAfter ?? scenario.sequence.length;
|
|
85
|
+
scenario.sequence.slice(0, stepCount).forEach((value, index) => {
|
|
86
|
+
const row = document.createElement('li');
|
|
87
|
+
const label = document.createElement('span');
|
|
88
|
+
const result = document.createElement('strong');
|
|
89
|
+
label.textContent = `${context.ui.planChangeLabel} ${index + 1}`;
|
|
90
|
+
result.textContent = `${numberFormat(value, context.locale)} ${nitrateUnit(context.unitSystem)}`;
|
|
91
|
+
row.append(label, result);
|
|
92
|
+
list.append(row);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderReady(context: RenderContext, calculation: NitrateCalculation): void {
|
|
97
|
+
const evaluation = evaluateNitrateCalculation(calculation, context.scenarioPercent);
|
|
98
|
+
const scenario = calculation.scenarios.find((item) => item.percent === context.scenarioPercent) ?? calculation.scenarios[0];
|
|
99
|
+
const statusText = getStatusText(context, calculation.status);
|
|
100
|
+
const answer = getAnswerText(context, calculation.status);
|
|
101
|
+
setError(context.root, '');
|
|
102
|
+
setUnitLabels(context);
|
|
103
|
+
setText(context.root, 'status', statusText);
|
|
104
|
+
setText(context.root, 'required', `${numberFormat(calculation.requiredPercent, context.locale)}%`);
|
|
105
|
+
setText(context.root, 'required-litres', `${numberFormat(volumeToDisplay(calculation.requiredLitres, context.unitSystem), context.locale)} ${volumeUnit(context.unitSystem)}`);
|
|
106
|
+
setText(context.root, 'after', `${numberFormat(evaluation.selectedAfter, context.locale)} ${nitrateUnit(context.unitSystem)}`);
|
|
107
|
+
setText(context.root, 'answer', answer);
|
|
108
|
+
setText(context.root, 'scenario', `${context.ui.currentScenarioLabel} ${context.scenarioPercent}%`);
|
|
109
|
+
const repeat = scenario?.reachesTargetAfter;
|
|
110
|
+
setText(context.root, 'repeat', repeat === null || repeat === undefined ? context.ui.notReached : `${repeat} ${context.ui.repeatLabel}`);
|
|
111
|
+
renderSafetyNote(context, calculation.requiredPercent);
|
|
112
|
+
setMarkers(context.root, calculation, evaluation.selectedAfter);
|
|
113
|
+
renderScenarioRows(context, calculation);
|
|
114
|
+
renderPlan(context, scenario);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getStatusText(context: RenderContext, status: NitrateCalculation['status']): string {
|
|
118
|
+
if (status === 'ready') return context.ui.targetReached;
|
|
119
|
+
if (status === 'already-below') return context.ui.alreadyBelow;
|
|
120
|
+
return context.ui.unreachable;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getAnswerText(context: RenderContext, status: NitrateCalculation['status']): string {
|
|
124
|
+
if (status === 'unreachable') return context.ui.impossibleHint;
|
|
125
|
+
if (status === 'already-below') return context.ui.belowTargetHint;
|
|
126
|
+
return context.ui.measureAgainHint;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderError(context: RenderContext, result: Extract<NitrateResult, { ok: false }>): void {
|
|
130
|
+
const labels: Record<Extract<NitrateResult, { ok: false }>['error'], string> = {
|
|
131
|
+
volume: context.ui.volumeLabel,
|
|
132
|
+
current: context.ui.currentLabel,
|
|
133
|
+
target: context.ui.targetLabel,
|
|
134
|
+
source: context.ui.sourceLabel,
|
|
135
|
+
};
|
|
136
|
+
setText(context.root, 'status', context.ui.inputsLegend);
|
|
137
|
+
setError(context.root, `${labels[result.error]}: ${context.ui.sourceHint}`);
|
|
138
|
+
context.root.classList.remove('is-high-change', 'is-critical-change');
|
|
139
|
+
const note = context.root.querySelector<HTMLElement>('[data-safety-note]');
|
|
140
|
+
if (note !== null) note.hidden = true;
|
|
141
|
+
context.root.querySelector<HTMLOListElement>('[data-plan-list]')?.replaceChildren();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function renderNitrateResults(context: RenderContext, result: NitrateResult): void {
|
|
145
|
+
setUnitLabels(context);
|
|
146
|
+
if (!result.ok) {
|
|
147
|
+
renderError(context, result);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
renderReady(context, result.value);
|
|
151
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AquariumToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { AquariumNitrateDilutionCalculatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { AquariumNitrateDilutionCalculatorUI };
|
|
5
|
+
export type AquariumNitrateDilutionCalculatorLocaleContent = ToolLocaleContent<AquariumNitrateDilutionCalculatorUI>;
|
|
6
|
+
|
|
7
|
+
export const aquariumNitrateDilutionCalculator: AquariumToolEntry<AquariumNitrateDilutionCalculatorUI> = {
|
|
8
|
+
id: 'aquarium-nitrate-dilution-calculator',
|
|
9
|
+
icons: { bg: 'mdi:water-outline', fg: 'mdi:test-tube' },
|
|
10
|
+
i18n: {
|
|
11
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
12
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
13
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
14
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
15
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
16
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
17
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
18
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
19
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
20
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
21
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
22
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
23
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
24
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
25
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { NitrateCalculation } from './logic';
|
|
2
|
+
|
|
3
|
+
export interface NitrateEvaluation {
|
|
4
|
+
markerPercent: number;
|
|
5
|
+
selectedAfter: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function evaluateNitrateCalculation(
|
|
9
|
+
calculation: NitrateCalculation,
|
|
10
|
+
scenarioPercent: number,
|
|
11
|
+
): NitrateEvaluation {
|
|
12
|
+
const scenario = calculation.scenarios.find((item) => item.percent === scenarioPercent) ?? calculation.scenarios[0];
|
|
13
|
+
return {
|
|
14
|
+
markerPercent: calculation.requiredPercent,
|
|
15
|
+
selectedAfter: scenario?.afterOne ?? calculation.afterRequired,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createAquariumNitrateContent } from '../localized-content';
|
|
2
|
+
|
|
3
|
+
export const content = createAquariumNitrateContent({
|
|
4
|
+
locale: 'de', slug: 'aquarium-nitrat-verduennungs-wasserwechsel-rechner', title: 'Aquarium Nitrat Verdünnungs Rechner für Wasserwechsel', description: 'Berechne den nötigen Wasserwechsel, um den gemessenen Nitratwert mit deinem tatsächlichen Wasservolumen und dem Nitrat im Frischwasser zu senken.',
|
|
5
|
+
faq: [
|
|
6
|
+
{ question: 'Was berechnet dieses Aquarium-Nitratwerkzeug?', answer: 'Es schätzt den Anteil und die Wassermenge, die ersetzt werden müssen, damit ein gemessener Nitratwert ein Ziel erreicht. Dabei wird auch Nitrat im Ersatzwasser berücksichtigt.' },
|
|
7
|
+
{ question: 'Warum ist der Nitratwert des neuen Wassers wichtig?', answer: 'Wenn das Ersatzwasser selbst Nitrat enthält, kann eine Verdünnung den Zielwert nicht unterschreiten. Der Wert gehört deshalb zur Rechnung.' },
|
|
8
|
+
{ question: 'Was zeigt der Schrittplan?', answer: 'Er zeigt den erwarteten Restwert nach mehreren gleichen Wasserwechseln. Das ist ein Rechenmodell und kein fester Pflegekalender.' },
|
|
9
|
+
{ question: 'Was bedeutet ein Wechsel über 50 Prozent?', answer: 'Ein sehr großer einzelner Wechsel kann Tiere und Wasserchemie belasten. Teile ihn bei Bedarf auf und prüfe Temperatur und Wasserwerte.' },
|
|
10
|
+
{ question: 'Werden meine Werte übertragen?', answer: 'Nein. Die Berechnung läuft im Browser; die letzten Eingaben können lokal in diesem Browser gespeichert werden.' },
|
|
11
|
+
],
|
|
12
|
+
howTo: [
|
|
13
|
+
{ name: 'Tatsächliches Wasservolumen eingeben', text: 'Verwende das Wasser, das wirklich im Becken ist, nicht die Herstellerangabe. Ziehe den Raum für Bodengrund und Dekoration gedanklich ab.' },
|
|
14
|
+
{ name: 'Drei Nitratwerte eintragen', text: 'Gib den aktuellen Wert, dein Ziel und den Nitratwert des neuen Wassers ein. Verwende überall dieselbe Einheit.' },
|
|
15
|
+
{ name: 'Vorgeschlagenen Wechsel prüfen', text: 'Lies Prozent und Wassermenge für den theoretisch nötigen Einzelwechsel ab. Bei großen Werten erscheint ein Sicherheitshinweis.' },
|
|
16
|
+
{ name: 'Praktisches Szenario auswählen', text: 'Wähle 25, 40, 50 oder 75 Prozent und öffne den Schrittplan, um die erwarteten Restwerte zu vergleichen.' },
|
|
17
|
+
],
|
|
18
|
+
seo: [
|
|
19
|
+
{ type: 'title', text: 'Nitratwerte in eine Wasserwechselentscheidung übersetzen', level: 2 },
|
|
20
|
+
{ type: 'paragraph', html: 'Ein Prozentwert allein sagt wenig aus. Dieses Werkzeug verbindet den aktuellen Messwert, den Zielwert, das tatsächliche Wasservolumen und Nitrat im neuen Wasser zu einer nachvollziehbaren Wassermenge.' },
|
|
21
|
+
{ type: 'title', text: 'Warum das Ersatzwasser mitgerechnet wird', level: 2 },
|
|
22
|
+
{ type: 'paragraph', html: 'Die Rechnung behandelt das Becken als gut durchmischtes Volumen. Enthält das neue Wasser bereits Nitrat, bleibt nach dem Wechsel ein entsprechender Restwert zurück.' },
|
|
23
|
+
{ type: 'list', items: ['Miss das Arbeitsvolumen statt nur die Aufschrift des Beckens zu übernehmen.', 'Teste das Ersatzwasser aus derselben Quelle, die du verwenden wirst.', 'Vergleiche einen großen Einzelwechsel mit mehreren moderaten Wechseln.', 'Messe nach dem Durchmischen erneut.'] },
|
|
24
|
+
{ type: 'tip', title: 'Große Wechsel aufteilen', html: '<p>Bei einem Ergebnis über 50 Prozent kann ein gestufter Ablauf schonender sein. Temperatur und Wasserchemie müssen trotzdem zu den Tieren passen.</p>' },
|
|
25
|
+
{ type: 'table', headers: ['Ausgabe', 'Bedeutung'], rows: [['Erforderlicher Prozentsatz', 'Theoretischer Anteil des Arbeitsvolumens'], ['Zu ersetzendes Wasser', 'Menge für den errechneten Einzelwechsel'], ['Nach gewähltem Wechsel', 'Prognostizierter Nitratwert nach einem Szenario'], ['Schrittplan', 'Restwert nach mehreren gleichen Wechseln']] },
|
|
26
|
+
],
|
|
27
|
+
ui: {
|
|
28
|
+
toolKicker: 'Nitratwechsel-Rezept', inputInstruction: 'Trage ein, was jetzt im Becken ist. Das Rezept übersetzt dein Ziel in eine Wassermenge.', unitSystemLabel: 'Anzeigen in', metricLabel: 'Metrisch', imperialLabel: 'Imperial', metricUnitSummary: 'L / mg/L', imperialUnitSummary: 'US gal / ppm', volumeHint: 'Tatsächliches Wasser, nicht Beckenangabe', readingHint: 'Aus deinem Test', readingsTitle: 'Deine Wasserprobe', decisionTitle: 'Der nötige Wechsel', actionLabel: 'Ersetzen', sceneCaption: 'Mischen und erneut testen', newWaterLabel: 'Neues Wasser', inputsLegend: 'Ein Volumen und drei Nitratwerte verwenden', volumeLabel: 'Arbeitsvolumen', volumeUnit: 'L', currentLabel: 'Aktuelles Nitrat', targetLabel: 'Ziel-Nitrat', sourceLabel: 'Nitrat im neuen Wasser', nitrateUnit: 'mg/L NO3', sourceHint: 'Verwende für alle Werte dieselbe Nitrat-Einheit. Teste das Ersatzwasser, wenn du seinen Wert nicht kennst.', currentMarker: 'Aktuell', targetMarker: 'Ziel', targetReached: 'Ziel erreichbar', alreadyBelow: 'Bereits unter dem Ziel', unreachable: 'Durch Verdünnung nicht erreichbar', requiredChangeLabel: 'des Arbeitsvolumens', requiredLitresLabel: 'Zu ersetzendes Wasser', afterChangeLabel: 'Nach gewähltem Wechsel', safetyLabel: 'Wechsel aufteilen', safetyHint: 'Ein einzelner Wechsel über 50 Prozent kann Aquarienbewohner belasten. Prüfe Temperatur und Wasserchemie und teile ihn bei Bedarf auf kleinere Wechsel auf.', criticalSafetyLabel: 'Keinen großen Einzelwechsel machen', criticalSafetyHint: 'Die Schätzung liegt über 75 Prozent. Verwende gestufte Wechsel und teste dazwischen erneut, statt fast das gesamte Wasser auf einmal zu ersetzen.', scenarioLabel: 'Wechselszenario', scenarioHint: 'Wähle einen praktischen Prozentsatz', planLabel: 'Schritt-für-Schritt-Plan', planHint: 'Das Modell zeigt den Restwert nach jedem gleichen Wechsel. Es ist kein Kalender und keine Garantie.', planChangeLabel: 'Nach Wechsel', biologyHint: 'Das Aquarium produziert zwischen den Wechseln weiter Nitrat. Deshalb können echte Messwerte von diesem reinen Verdünnungsmodell abweichen. Nach dem Mischen erneut testen.', impossibleHint: 'Das neue Wasser enthält zu viel Nitrat, um dieses Ziel allein durch Verdünnung zu erreichen.', belowTargetHint: 'Der aktuelle Messwert liegt bereits am oder unter dem Ziel. Nutze ihn als Referenz und teste nach deinem normalen Pflegeplan erneut.', measureAgainHint: 'Das ist eine Mischschätzung. Teste nach dem Durchmischen erneut, weil sich das Aquarium zwischen den Messungen weiter verändert.', resetLabel: 'Werte zurücksetzen', currentScenarioLabel: 'Gewählter Wechsel', repeatLabel: 'Wechsel', notReached: 'Nicht erreicht', scenarioTwentyFive: '25%', scenarioForty: '40%', scenarioFifty: '50%', scenarioSeventyFive: '75%',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
2
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
3
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
4
|
+
import type { AquariumNitrateDilutionCalculatorUI } from '../ui';
|
|
5
|
+
|
|
6
|
+
const slug = 'aquarium-nitrate-dilution-calculator';
|
|
7
|
+
const title = 'Aquarium Nitrate Dilution Water Change Calculator';
|
|
8
|
+
const description = 'Calculate the aquarium water change needed to move a measured nitrate level toward your target, using your tank volume and fresh water nitrate reading.';
|
|
9
|
+
|
|
10
|
+
const faq = [
|
|
11
|
+
{ question: 'What does this aquarium nitrate calculator calculate?', answer: 'It estimates the fraction and litres of aquarium water to replace so a measured nitrate concentration reaches a target, assuming the replacement water mixes evenly and has the nitrate value you enter.' },
|
|
12
|
+
{ question: 'Why does the nitrate level of replacement water matter?', answer: 'A water change mixes tank water with new water. If the new water already contains nitrate, the final concentration cannot fall below that source concentration through dilution alone.' },
|
|
13
|
+
{ question: 'What does the repeated change scenario show?', answer: 'It applies the same percentage change more than once while keeping the same source-water reading. The step-by-step panel shows the predicted residual nitrate after each change; it is a model, not a maintenance schedule.' },
|
|
14
|
+
{ question: 'Does this tell me what nitrate level is safe for my fish?', answer: 'No. The target is your chosen planning value. Species, life stage, salinity, acclimation and the rest of the water chemistry need separate husbandry guidance.' },
|
|
15
|
+
{ question: 'Should I measure again after changing water?', answer: 'Yes. Test the aquarium after the new water has mixed. The result assumes the measured values are accurate and ignores nitrate production, plant uptake and other changes between tests.' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const howTo = [
|
|
19
|
+
{ name: 'Enter the working water volume', text: 'Use the water volume you are actually changing, not the advertised tank capacity. Include your best estimate after substrate and hardscape displacement.' },
|
|
20
|
+
{ name: 'Add three nitrate readings', text: 'Enter the current aquarium reading, your target reading and the nitrate concentration in the replacement water. Keep the unit consistent across all three values.' },
|
|
21
|
+
{ name: 'Read the required change', text: 'The calculator solves the mixing equation and shows the percentage and litres required for one theoretical change.' },
|
|
22
|
+
{ name: 'Compare practical scenarios', text: 'Select a 25%, 40%, 50% or 75% change to see its first result, how many repeated changes the model needs and the residual nitrate after each step.' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
26
|
+
'@context': 'https://schema.org',
|
|
27
|
+
'@type': 'FAQPage',
|
|
28
|
+
mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const howToSchema: WithContext<HowTo> = {
|
|
32
|
+
'@context': 'https://schema.org',
|
|
33
|
+
'@type': 'HowTo',
|
|
34
|
+
name: title,
|
|
35
|
+
description,
|
|
36
|
+
step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
40
|
+
'@context': 'https://schema.org',
|
|
41
|
+
'@type': 'SoftwareApplication',
|
|
42
|
+
name: title,
|
|
43
|
+
description,
|
|
44
|
+
applicationCategory: 'UtilityApplication',
|
|
45
|
+
operatingSystem: 'All',
|
|
46
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
47
|
+
inLanguage: 'en',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const content: ToolLocaleContent<AquariumNitrateDilutionCalculatorUI> = {
|
|
51
|
+
slug,
|
|
52
|
+
title,
|
|
53
|
+
description,
|
|
54
|
+
faq,
|
|
55
|
+
bibliography,
|
|
56
|
+
howTo,
|
|
57
|
+
schemas: [faqSchema, howToSchema, appSchema],
|
|
58
|
+
seo: [
|
|
59
|
+
{ type: 'title', text: 'Turn a nitrate reading into a water change decision', level: 2 },
|
|
60
|
+
{ type: 'paragraph', html: 'A water change percentage is only useful when it is connected to a measured starting value, a target and the nitrate already present in the replacement water. This calculator keeps those three concentrations visible and turns them into a practical volume for your aquarium.' },
|
|
61
|
+
{ type: 'title', text: 'The mixing idea behind the estimate', level: 2 },
|
|
62
|
+
{ type: 'paragraph', html: 'The model treats the aquarium as a well-mixed volume. After replacing a fraction of the water, the predicted concentration is the remaining tank concentration plus the replacement-water concentration, each weighted by its share of the final volume. This is the same conservation-of-concentration idea used in standard dilution calculations.' },
|
|
63
|
+
{ type: 'list', items: ['Measure the aquarium before the change.', 'Use a replacement-water nitrate result from the same source you will add.', 'Compare the required percentage with a practical change size.', 'Test again after mixing and use the new reading for the next decision.'] },
|
|
64
|
+
{ type: 'title', text: 'Why the source-water reading can stop the calculation', level: 2 },
|
|
65
|
+
{ type: 'paragraph', html: 'If replacement water contains nitrate at or above your target, dilution alone cannot reach a lower target. The tool marks that boundary instead of returning a deceptively precise percentage. You may need a different water source or a separate nitrate-management approach, but this calculator does not prescribe one.' },
|
|
66
|
+
{ type: 'title', text: 'How to read the repeated-change strip', level: 2 },
|
|
67
|
+
{ type: 'paragraph', html: 'The scenario strip shows the first predicted reading for each change size. Open the step-by-step panel for the residual nitrate after each repeated change, up to eight steps. Repeated changes compound because each change acts on the lower concentration left by the previous one. Real aquariums may move differently as waste production, plants, substrate and timing change.' },
|
|
68
|
+
{ type: 'tip', title: 'Use the result as a testable plan', html: '<p>Do not treat the output as a guarantee. Match temperature and other relevant water properties, make the change safely, allow the tank to mix, and measure again. The follow-up reading is the evidence that matters.</p>' },
|
|
69
|
+
{ type: 'table', headers: ['Output', 'Meaning'], rows: [['Required percentage', 'The theoretical fraction of the working water volume to replace'], ['Required litres', 'The same fraction expressed using the volume you entered'], ['After selected change', 'The predicted nitrate reading after one chosen scenario'], ['Repeated changes', 'How many identical scenario changes the model needs, up to eight']] },
|
|
70
|
+
],
|
|
71
|
+
ui: {
|
|
72
|
+
toolKicker: 'Nitrate change recipe',
|
|
73
|
+
inputInstruction: 'Enter what is in the tank now. The recipe turns your target into a water-change amount.',
|
|
74
|
+
unitSystemLabel: 'Display units',
|
|
75
|
+
metricLabel: 'Metric',
|
|
76
|
+
imperialLabel: 'Imperial',
|
|
77
|
+
metricUnitSummary: 'L / mg/L',
|
|
78
|
+
imperialUnitSummary: 'US gal / ppm',
|
|
79
|
+
volumeHint: 'Actual water, not tank label',
|
|
80
|
+
readingHint: 'From your test',
|
|
81
|
+
readingsTitle: 'Your water sample',
|
|
82
|
+
decisionTitle: 'The change to make',
|
|
83
|
+
actionLabel: 'Replace',
|
|
84
|
+
sceneCaption: 'Mix and re-test',
|
|
85
|
+
newWaterLabel: 'New water',
|
|
86
|
+
inputsLegend: 'Use one volume and three nitrate readings',
|
|
87
|
+
volumeLabel: 'Working water volume',
|
|
88
|
+
volumeUnit: 'L',
|
|
89
|
+
currentLabel: 'Current nitrate',
|
|
90
|
+
targetLabel: 'Target nitrate',
|
|
91
|
+
sourceLabel: 'New water nitrate',
|
|
92
|
+
nitrateUnit: 'mg/L NO3',
|
|
93
|
+
sourceHint: 'Use the same nitrate unit for all readings. Test the replacement water if you do not know its value.',
|
|
94
|
+
currentMarker: 'Current',
|
|
95
|
+
targetMarker: 'Target',
|
|
96
|
+
targetReached: 'Target reachable',
|
|
97
|
+
alreadyBelow: 'Already below target',
|
|
98
|
+
unreachable: 'Target not reachable by dilution',
|
|
99
|
+
requiredChangeLabel: 'of the working water',
|
|
100
|
+
requiredLitresLabel: 'Water to replace',
|
|
101
|
+
afterChangeLabel: 'After selected change',
|
|
102
|
+
safetyLabel: 'Stage this change',
|
|
103
|
+
safetyHint: 'A single change above 50% can stress aquarium inhabitants. Match temperature and water chemistry, and consider splitting it into smaller changes.',
|
|
104
|
+
criticalSafetyLabel: 'Avoid one large change',
|
|
105
|
+
criticalSafetyHint: 'This estimate is above 75%. Use staged changes and re-test between them rather than replacing almost all the water at once.',
|
|
106
|
+
scenarioLabel: 'Change scenario',
|
|
107
|
+
scenarioHint: 'Choose a practical percentage to inspect',
|
|
108
|
+
planLabel: 'Step-by-step plan',
|
|
109
|
+
planHint: 'The model below shows the residual nitrate after each identical change. It is not a calendar or a guarantee.',
|
|
110
|
+
planChangeLabel: 'After change',
|
|
111
|
+
biologyHint: 'Nitrate production continues between changes, so real readings will drift from this dilution-only model. Re-test after the water has mixed.',
|
|
112
|
+
impossibleHint: 'The new water is too high in nitrate to reach this target by dilution alone.',
|
|
113
|
+
belowTargetHint: 'Your current reading is already at or below the target. Keep the result as a reference, then test again on your normal schedule.',
|
|
114
|
+
measureAgainHint: 'This is a mixing estimate. Re-test after the water has mixed because the aquarium keeps changing between readings.',
|
|
115
|
+
resetLabel: 'Reset values',
|
|
116
|
+
currentScenarioLabel: 'Selected change',
|
|
117
|
+
repeatLabel: 'changes',
|
|
118
|
+
notReached: 'Not reached',
|
|
119
|
+
scenarioTwentyFive: '25%',
|
|
120
|
+
scenarioForty: '40%',
|
|
121
|
+
scenarioFifty: '50%',
|
|
122
|
+
scenarioSeventyFive: '75%',
|
|
123
|
+
},
|
|
124
|
+
};
|