@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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +2 -0
  5. package/src/tests/i18n_coverage.test.ts +1 -1
  6. package/src/tests/registry_contract.test.ts +67 -0
  7. package/src/tests/tool_validation.test.ts +4 -3
  8. package/src/tool/aquariumEvaporationTopOffPlanner/aquarium-evaporation-top-off-planner.css +490 -0
  9. package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.astro +6 -0
  10. package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.ts +10 -0
  11. package/src/tool/aquariumEvaporationTopOffPlanner/component.astro +61 -0
  12. package/src/tool/aquariumEvaporationTopOffPlanner/controller.ts +232 -0
  13. package/src/tool/aquariumEvaporationTopOffPlanner/dom-views.ts +39 -0
  14. package/src/tool/aquariumEvaporationTopOffPlanner/entry.ts +27 -0
  15. package/src/tool/aquariumEvaporationTopOffPlanner/evaluator.ts +9 -0
  16. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/de.ts +3 -0
  17. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/en.ts +179 -0
  18. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/es.ts +3 -0
  19. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/fr.ts +3 -0
  20. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/id.ts +3 -0
  21. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/it.ts +3 -0
  22. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ja.ts +3 -0
  23. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ko.ts +3 -0
  24. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/nl.ts +3 -0
  25. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pl.ts +3 -0
  26. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pt.ts +3 -0
  27. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ru.ts +3 -0
  28. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/shared.ts +79 -0
  29. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/sv.ts +3 -0
  30. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/tr.ts +3 -0
  31. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/translations.ts +509 -0
  32. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/zh.ts +3 -0
  33. package/src/tool/aquariumEvaporationTopOffPlanner/index.ts +11 -0
  34. package/src/tool/aquariumEvaporationTopOffPlanner/logic.test.ts +61 -0
  35. package/src/tool/aquariumEvaporationTopOffPlanner/logic.ts +102 -0
  36. package/src/tool/aquariumEvaporationTopOffPlanner/seo.astro +13 -0
  37. package/src/tool/aquariumEvaporationTopOffPlanner/storage.ts +28 -0
  38. package/src/tool/aquariumEvaporationTopOffPlanner/ui.ts +49 -0
  39. package/src/tool/aquariumLightingPhotoperiodPlanner/aquarium-lighting-photoperiod-planner.css +9 -9
  40. package/src/tool/aquariumLightingPhotoperiodPlanner/component.astro +3 -1
  41. package/src/tool/aquariumLightingPhotoperiodPlanner/controller.ts +21 -9
  42. package/src/tool/aquariumLightingPhotoperiodPlanner/dom-views.test.ts +2 -1
  43. package/src/tool/aquariumLightingPhotoperiodPlanner/dom-views.ts +20 -8
  44. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/de.ts +2 -0
  45. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/en.ts +2 -0
  46. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/es.ts +2 -0
  47. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/fr.ts +2 -0
  48. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/id.ts +2 -0
  49. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/it.ts +2 -0
  50. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ja.ts +2 -0
  51. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ko.ts +2 -0
  52. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/nl.ts +2 -0
  53. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/pl.ts +2 -0
  54. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/pt.ts +2 -0
  55. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/ru.ts +2 -0
  56. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/sv.ts +2 -0
  57. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/tr.ts +2 -0
  58. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/zh.ts +4 -2
  59. package/src/tool/aquariumLightingPhotoperiodPlanner/logic.test.ts +10 -0
  60. package/src/tool/aquariumLightingPhotoperiodPlanner/logic.ts +10 -0
  61. package/src/tool/aquariumLightingPhotoperiodPlanner/storage.ts +12 -8
  62. package/src/tool/aquariumLightingPhotoperiodPlanner/ui.ts +2 -0
  63. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/fr.ts +1 -1
  64. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/it.ts +1 -1
  65. package/src/tools.ts +6 -0
@@ -0,0 +1,3 @@
1
+ import { localizedContents } from './translations';
2
+
3
+ export const content = localizedContents.zh;
@@ -0,0 +1,11 @@
1
+ import { aquariumEvaporationTopOffPlanner } from './entry';
2
+ import type { ToolDefinition } from '../../types';
3
+
4
+ export * from './entry';
5
+
6
+ export const AQUARIUM_EVAPORATION_TOP_OFF_PLANNER_TOOL: ToolDefinition = {
7
+ entry: aquariumEvaporationTopOffPlanner,
8
+ Component: () => import('./component.astro'),
9
+ SEOComponent: () => import('./seo.astro'),
10
+ BibliographyComponent: () => import('./bibliography.astro'),
11
+ };
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateEvaporation } from './logic';
3
+
4
+ describe('calculateEvaporation', () => {
5
+ it('converts an observed loss into daily and scheduled top off volumes', () => {
6
+ const result = calculateEvaporation({
7
+ tankVolumeLitres: 120,
8
+ measuredLossMillilitres: 1200,
9
+ observationDays: 7,
10
+ refillIntervalDays: 3,
11
+ });
12
+ expect(result?.dailyLossLitres).toBeCloseTo(0.1714286, 6);
13
+ expect(result?.nextRefillLitres).toBeCloseTo(0.5142857, 6);
14
+ expect(result?.thirtyDayLossLitres).toBeCloseTo(5.142857, 5);
15
+ expect(result?.levelAfterIntervalPercent).toBeCloseTo(99.5714286, 6);
16
+ });
17
+
18
+ it('shows the level before refill and the amount added on refill days', () => {
19
+ const result = calculateEvaporation({
20
+ tankVolumeLitres: 100,
21
+ measuredLossMillilitres: 700,
22
+ observationDays: 7,
23
+ refillIntervalDays: 2,
24
+ });
25
+ expect(result).not.toBeNull();
26
+ if (result === null) return;
27
+ expect(result.schedule[1]?.levelPercent).toBeCloseTo(99.9, 6);
28
+ expect(result.schedule[2]?.levelPercent).toBeCloseTo(99.8, 6);
29
+ expect(result.schedule[2]?.refillLitres).toBeCloseTo(0.2, 6);
30
+ expect(result.schedule[3]?.levelPercent).toBeCloseTo(99.9, 6);
31
+ });
32
+
33
+ it('estimates reservoir autonomy, marine concentration and critical swings', () => {
34
+ const result = calculateEvaporation({
35
+ tankVolumeLitres: 100,
36
+ measuredLossMillilitres: 3000,
37
+ observationDays: 10,
38
+ refillIntervalDays: 10,
39
+ reservoirCapacityLitres: 12,
40
+ startingSpecificGravity: 1.025,
41
+ });
42
+ expect(result?.reservoirDays).toBeCloseTo(40, 6);
43
+ expect(result?.salinityAfterInterval).toBeCloseTo(1.025773, 6);
44
+ expect(result?.isCritical).toBe(true);
45
+ });
46
+
47
+ it('rejects impossible or unsupported values', () => {
48
+ expect(calculateEvaporation({
49
+ tankVolumeLitres: 0,
50
+ measuredLossMillilitres: 500,
51
+ observationDays: 7,
52
+ refillIntervalDays: 3,
53
+ })).toBeNull();
54
+ expect(calculateEvaporation({
55
+ tankVolumeLitres: 100,
56
+ measuredLossMillilitres: 500,
57
+ observationDays: 91,
58
+ refillIntervalDays: 3,
59
+ })).toBeNull();
60
+ });
61
+ });
@@ -0,0 +1,102 @@
1
+ export interface EvaporationInputs {
2
+ tankVolumeLitres: number;
3
+ measuredLossMillilitres: number;
4
+ observationDays: number;
5
+ refillIntervalDays: number;
6
+ reservoirCapacityLitres?: number;
7
+ startingSpecificGravity?: number;
8
+ }
9
+
10
+ export interface SchedulePoint {
11
+ day: number;
12
+ levelPercent: number;
13
+ lostLitres: number;
14
+ refillLitres: number;
15
+ }
16
+
17
+ export interface EvaporationResult {
18
+ tankVolumeLitres: number;
19
+ dailyLossLitres: number;
20
+ dailyRatePercent: number;
21
+ nextRefillLitres: number;
22
+ thirtyDayLossLitres: number;
23
+ levelAfterIntervalPercent: number;
24
+ twoPercentDays: number;
25
+ reservoirDays?: number;
26
+ salinityAfterInterval?: number;
27
+ isCritical: boolean;
28
+ schedule: SchedulePoint[];
29
+ }
30
+
31
+ function isPositive(value: number): boolean {
32
+ return Number.isFinite(value) && value > 0;
33
+ }
34
+
35
+ function validLimits(input: EvaporationInputs): boolean {
36
+ return input.observationDays <= 90 && input.refillIntervalDays <= 30;
37
+ }
38
+
39
+ function validOptionalFields(input: EvaporationInputs): boolean {
40
+ const validReservoir = input.reservoirCapacityLitres === undefined || isPositive(input.reservoirCapacityLitres);
41
+ const validSalinity = input.startingSpecificGravity === undefined || (input.startingSpecificGravity >= 1 && input.startingSpecificGravity <= 1.04);
42
+ return validReservoir && validSalinity;
43
+ }
44
+
45
+ function isValidInput(input: EvaporationInputs): boolean {
46
+ return isPositive(input.tankVolumeLitres)
47
+ && isPositive(input.measuredLossMillilitres)
48
+ && isPositive(input.observationDays)
49
+ && isPositive(input.refillIntervalDays)
50
+ && validLimits(input)
51
+ && validOptionalFields(input);
52
+ }
53
+
54
+ function daysSinceRefill(day: number, intervalDays: number): number {
55
+ if (day === 0) return 0;
56
+ const remainder = day % intervalDays;
57
+ return remainder === 0 ? intervalDays : remainder;
58
+ }
59
+
60
+ function createSchedule(
61
+ dailyLossLitres: number,
62
+ tankVolumeLitres: number,
63
+ intervalDays: number,
64
+ ): SchedulePoint[] {
65
+ const schedule: SchedulePoint[] = [];
66
+ for (let day = 0; day <= 14; day += 1) {
67
+ const daySinceRefill = daysSinceRefill(day, intervalDays);
68
+ const refillDue = day > 0 && day % intervalDays === 0;
69
+ const lostLitres = daySinceRefill * dailyLossLitres;
70
+ schedule.push({
71
+ day,
72
+ levelPercent: Math.max(0, 100 - (lostLitres / tankVolumeLitres) * 100),
73
+ lostLitres,
74
+ refillLitres: refillDue ? intervalDays * dailyLossLitres : 0,
75
+ });
76
+ }
77
+ return schedule;
78
+ }
79
+
80
+ export function calculateEvaporation(input: EvaporationInputs): EvaporationResult | null {
81
+ if (!isValidInput(input)) return null;
82
+ const dailyLossLitres = input.measuredLossMillilitres / 1000 / input.observationDays;
83
+ const dailyRatePercent = (dailyLossLitres / input.tankVolumeLitres) * 100;
84
+ const nextRefillLitres = dailyLossLitres * input.refillIntervalDays;
85
+ const remainingVolumeLitres = input.tankVolumeLitres - nextRefillLitres;
86
+ const result: EvaporationResult = {
87
+ tankVolumeLitres: input.tankVolumeLitres,
88
+ dailyLossLitres,
89
+ dailyRatePercent,
90
+ nextRefillLitres,
91
+ thirtyDayLossLitres: dailyLossLitres * 30,
92
+ levelAfterIntervalPercent: Math.max(0, 100 - dailyRatePercent * input.refillIntervalDays),
93
+ twoPercentDays: 2 / dailyRatePercent,
94
+ isCritical: dailyRatePercent * input.refillIntervalDays >= 2,
95
+ schedule: createSchedule(dailyLossLitres, input.tankVolumeLitres, input.refillIntervalDays),
96
+ };
97
+ if (input.reservoirCapacityLitres !== undefined) result.reservoirDays = input.reservoirCapacityLitres / dailyLossLitres;
98
+ if (input.startingSpecificGravity !== undefined && remainingVolumeLitres > 0) {
99
+ result.salinityAfterInterval = 1 + ((input.startingSpecificGravity - 1) * input.tankVolumeLitres) / remainingVolumeLitres;
100
+ }
101
+ return result;
102
+ }
@@ -0,0 +1,13 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { aquariumEvaporationTopOffPlanner } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props { locale?: KnownLocale; }
7
+
8
+ const { locale = 'en' } = Astro.props;
9
+ const loader = aquariumEvaporationTopOffPlanner.i18n[locale] ?? aquariumEvaporationTopOffPlanner.i18n.en;
10
+ const content = loader === undefined ? undefined : await loader();
11
+ ---
12
+
13
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,28 @@
1
+ import type { EvaporationInputs } from './logic';
2
+
3
+ const STORAGE_KEY = 'aquarium-evaporation-top-off-planner';
4
+
5
+ export interface StoredEvaporationState {
6
+ units: 'metric' | 'imperial';
7
+ lossUnit?: 'millilitres' | 'litres' | 'fluidOunces';
8
+ waterType?: 'freshwater' | 'marine';
9
+ input: EvaporationInputs;
10
+ }
11
+
12
+ export function readEvaporationState(): StoredEvaporationState | null {
13
+ if (typeof window === 'undefined') return null;
14
+ try {
15
+ const raw = window.localStorage.getItem(STORAGE_KEY);
16
+ if (raw === null) return null;
17
+ return JSON.parse(raw) as StoredEvaporationState;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function writeEvaporationState(state: StoredEvaporationState): void {
24
+ if (typeof window === 'undefined') return;
25
+ try {
26
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
27
+ } catch {}
28
+ }
@@ -0,0 +1,49 @@
1
+ export interface AquariumEvaporationTopOffPlannerUI {
2
+ [key: string]: string;
3
+ unitsLabel: string;
4
+ metricLabel: string;
5
+ imperialLabel: string;
6
+ tankVolumeLabel: string;
7
+ measuredLossLabel: string;
8
+ lossUnitLabel: string;
9
+ observationDaysLabel: string;
10
+ refillIntervalLabel: string;
11
+ reservoirCapacityLabel: string;
12
+ advancedLabel: string;
13
+ waterTypeLabel: string;
14
+ freshwaterLabel: string;
15
+ marineLabel: string;
16
+ startingSalinityLabel: string;
17
+ estimatedSalinityLabel: string;
18
+ reservoirDaysLabel: string;
19
+ criticalAlert: string;
20
+ volumeHint: string;
21
+ lossHint: string;
22
+ intervalHint: string;
23
+ resultTitle: string;
24
+ dailyLossLabel: string;
25
+ dailyRateLabel: string;
26
+ nextRefillLabel: string;
27
+ thirtyDayLabel: string;
28
+ levelAfterIntervalLabel: string;
29
+ twoPercentDaysLabel: string;
30
+ scheduleTitle: string;
31
+ dayLabel: string;
32
+ topOffLabel: string;
33
+ everyLabel: string;
34
+ levelLabel: string;
35
+ stableStatus: string;
36
+ watchStatus: string;
37
+ frequentStatus: string;
38
+ emptyState: string;
39
+ invalidState: string;
40
+ measurementNote: string;
41
+ saltwaterNote: string;
42
+ resetLabel: string;
43
+ litreUnit: string;
44
+ gallonUnit: string;
45
+ millilitreUnit: string;
46
+ fluidOunceUnit: string;
47
+ dayUnit: string;
48
+ percentUnit: string;
49
+ }
@@ -259,7 +259,7 @@
259
259
 
260
260
  .aqlp-summary-row {
261
261
  display: grid;
262
- grid-template-columns: repeat(2, minmax(0, 1fr));
262
+ grid-template-columns: repeat(3, minmax(0, 1fr));
263
263
  gap: 24px;
264
264
  margin-top: 22px;
265
265
  padding: 14px 0;
@@ -617,6 +617,14 @@
617
617
  gap: 5px;
618
618
  }
619
619
 
620
+ .aqlp-summary-row {
621
+ gap: 12px;
622
+ }
623
+
624
+ .aqlp-summary-row div + div {
625
+ padding-left: 12px;
626
+ }
627
+
620
628
  .aqlp-event-row span {
621
629
  display: grid;
622
630
  grid-template-columns: auto minmax(0, 1fr);
@@ -633,14 +641,6 @@
633
641
  text-align: right;
634
642
  }
635
643
 
636
- .aqlp-summary-row {
637
- gap: 12px;
638
- }
639
-
640
- .aqlp-summary-row div + div {
641
- padding-left: 12px;
642
- }
643
-
644
644
  .aqlp-co2-row {
645
645
  grid-template-columns: auto minmax(0, 1fr);
646
646
  }
@@ -40,7 +40,7 @@ const initialFullPhase = initial.phases.find((phase) => phase.phase === 'full');
40
40
  <path class="aqlp-chart-fill" data-chart-fill d={initialChart.fill}></path>
41
41
  <path class="aqlp-chart-line" data-chart-line d={initialChart.line}></path>
42
42
  <circle class="aqlp-chart-marker aqlp-chart-marker-edge" data-chart-marker-start cx={(initialMarkers.start / 1440) * 960} cy="220" r="8"></circle>
43
- <circle class="aqlp-chart-marker aqlp-chart-marker-full" data-chart-marker-full cx={(initialMarkers.full / 1440) * 960} cy="40" r="8"></circle>
43
+ <circle class="aqlp-chart-marker aqlp-chart-marker-full" data-chart-marker-full cx={(initialMarkers.full / 1440) * 960} cy={220 - (initial.input.intensityPercent / 100) * 180} r="8"></circle>
44
44
  <circle class="aqlp-chart-marker aqlp-chart-marker-edge" data-chart-marker-end cx={(initialMarkers.end / 1440) * 960} cy="220" r="8"></circle>
45
45
  <text class="aqlp-chart-y-label" x="12" y="27">100%</text>
46
46
  <text class="aqlp-chart-y-label" x="12" y="126">50%</text>
@@ -62,6 +62,7 @@ const initialFullPhase = initial.phases.find((phase) => phase.phase === 'full');
62
62
  <div class="aqlp-summary-row">
63
63
  <div><span>{ui.targetBandLabel}</span><strong data-target-band>7 to 9 {ui.hoursUnit}</strong></div>
64
64
  <div><span>{ui.fullIntensityLabel}</span><strong data-full-intensity>7 {ui.hoursUnit}</strong></div>
65
+ <div><span>{ui.fixtureLevelLabel}</span><strong data-intensity>{initial.input.intensityPercent}%</strong></div>
65
66
  </div>
66
67
  <div class="aqlp-co2-row" data-co2-row><span class="aqlp-co2-mark">CO₂</span><span><strong>{ui.co2Label}</strong><small>{ui.co2Hint}</small></span><strong data-co2-window>07:00 → 15:00</strong></div>
67
68
  <div class="aqlp-signal" aria-live="polite"><span class="aqlp-signal-mark" aria-hidden="true"></span><p data-adjustment>{ui.adjustmentInside}</p></div>
@@ -76,6 +77,7 @@ const initialFullPhase = initial.phases.find((phase) => phase.phase === 'full');
76
77
  <fieldset class="aqlp-control aqlp-choice-control"><legend>{ui.plantProfileLabel}</legend><div class="aqlp-choice-group" data-choice-group="plantProfile" role="group" aria-label={ui.plantProfileLabel}><button type="button" class="aqlp-choice" data-choice="plantProfile" data-value="none" aria-pressed="false">{ui.plantProfileNone}</button><button type="button" class="aqlp-choice" data-choice="plantProfile" data-value="low" aria-pressed="true">{ui.plantProfileLow}</button><button type="button" class="aqlp-choice" data-choice="plantProfile" data-value="high" aria-pressed="false">{ui.plantProfileHigh}</button></div></fieldset>
77
78
  <div class="aqlp-control"><label for="aqlp-start-time">{ui.startTimeLabel}</label><input id="aqlp-start-time" class="aqlp-input" data-field="startTime" type="time" value={initial.input.startTime} /></div>
78
79
  <div class="aqlp-control aqlp-range-control"><div class="aqlp-label-line"><label for="aqlp-photoperiod">{ui.photoperiodLabel}</label><output data-output="photoperiodHours">8 {ui.hoursUnit}</output></div><input id="aqlp-photoperiod" data-field="photoperiodHours" data-unit={ui.hoursUnit} type="range" min="6" max="12" step="0.5" value={initial.input.photoperiodHours} /></div>
80
+ <div class="aqlp-control aqlp-range-control"><div class="aqlp-label-line"><label for="aqlp-intensity">{ui.intensityLabel}</label><output data-output="intensityPercent">70%</output></div><input id="aqlp-intensity" data-field="intensityPercent" data-unit="%" type="range" min="10" max="100" step="5" value={initial.input.intensityPercent} /></div>
79
81
  </div>
80
82
 
81
83
  <details class="aqlp-advanced"><summary class="aqlp-details-summary">{ui.optionalRampsLabel}<span>{ui.optionalRampsSummary}</span></summary><div class="aqlp-ramp-grid"><div class="aqlp-control aqlp-range-control"><div class="aqlp-label-line"><label for="aqlp-ramp-up">{ui.rampUpLabel}</label><output data-output="rampUpMinutes">30 {ui.minutesUnit}</output></div><input id="aqlp-ramp-up" data-field="rampUpMinutes" data-unit={ui.minutesUnit} type="range" min="0" max="180" step="15" value={initial.input.rampUpMinutes} /></div><div class="aqlp-control aqlp-range-control"><div class="aqlp-label-line"><label for="aqlp-ramp-down">{ui.rampDownLabel}</label><output data-output="rampDownMinutes">30 {ui.minutesUnit}</output></div><input id="aqlp-ramp-down" data-field="rampDownMinutes" data-unit={ui.minutesUnit} type="range" min="0" max="180" step="15" value={initial.input.rampDownMinutes} /></div></div></details>
@@ -21,24 +21,29 @@ function readNumber(
21
21
  return Number.isFinite(value) ? value : fallback;
22
22
  }
23
23
 
24
+ function readChoice(root: ControllerRoot, field: string): string | undefined {
25
+ return root.querySelector<HTMLButtonElement>(
26
+ `[data-choice-group="${field}"] [aria-pressed="true"]`,
27
+ )?.dataset.value;
28
+ }
29
+
24
30
  function readInput(root: ControllerRoot): LightingInput {
25
- const aquariumType = root.querySelector<HTMLButtonElement>(
26
- '[data-choice-group="aquariumType"] [aria-pressed="true"]',
27
- )?.dataset.value as AquariumType | undefined;
28
- const plantProfile = root.querySelector<HTMLButtonElement>(
29
- '[data-choice-group="plantProfile"] [aria-pressed="true"]',
30
- )?.dataset.value as PlantProfile | undefined;
31
+ const aquariumType = readChoice(root, "aquariumType") as AquariumType | undefined;
32
+ const plantProfile = readChoice(root, "plantProfile") as PlantProfile | undefined;
31
33
  return {
32
34
  aquariumType: aquariumType ?? DEFAULT_INPUT.aquariumType,
33
35
  plantProfile: plantProfile ?? DEFAULT_INPUT.plantProfile,
34
- startTime:
35
- root.querySelector<HTMLInputElement>('[data-field="startTime"]')?.value ??
36
- DEFAULT_INPUT.startTime,
36
+ startTime: root.querySelector<HTMLInputElement>('[data-field="startTime"]')?.value ?? DEFAULT_INPUT.startTime,
37
37
  photoperiodHours: readNumber(
38
38
  root,
39
39
  "photoperiodHours",
40
40
  DEFAULT_INPUT.photoperiodHours,
41
41
  ),
42
+ intensityPercent: readNumber(
43
+ root,
44
+ "intensityPercent",
45
+ DEFAULT_INPUT.intensityPercent,
46
+ ),
42
47
  rampUpMinutes: readNumber(
43
48
  root,
44
49
  "rampUpMinutes",
@@ -66,6 +71,7 @@ function setFields(root: ControllerRoot, input: LightingInput): void {
66
71
  const fields = [
67
72
  "startTime",
68
73
  "photoperiodHours",
74
+ "intensityPercent",
69
75
  "rampUpMinutes",
70
76
  "rampDownMinutes",
71
77
  ] as const;
@@ -88,6 +94,12 @@ function setSliderOutputs(
88
94
  String(input.photoperiodHours),
89
95
  ui.hoursUnit,
90
96
  );
97
+ updateSliderOutput(
98
+ root,
99
+ "intensityPercent",
100
+ String(input.intensityPercent),
101
+ "%",
102
+ );
91
103
  updateSliderOutput(
92
104
  root,
93
105
  "rampUpMinutes",
@@ -19,7 +19,8 @@ describe("aquarium lighting photoperiod planner chart", () => {
19
19
 
20
20
  expect(paths.line).toMatch(/^M 0\.00 220\.00/);
21
21
  expect(paths.line).toContain("320.00 220.00");
22
- expect(paths.line).toContain("340.00 40.00");
22
+ expect(paths.line).toContain("340.00 94.00");
23
+ expect(paths.line).toContain("620.00 94.00");
23
24
  expect(paths.line).toContain("640.00 220.00");
24
25
  expect(paths.fill).toMatch(/L 960 220 L 0 220 Z$/);
25
26
  });
@@ -18,22 +18,27 @@ function clockMinutes(value: string): number {
18
18
  return hours * 60 + minutes;
19
19
  }
20
20
 
21
- function levelForPhase(phase: PhaseSegment["phase"], progress: number): number {
22
- if (phase === "ramp-up") return progress;
23
- if (phase === "ramp-down") return 1 - progress;
24
- if (phase === "full") return 1;
21
+ function levelForPhase(
22
+ phase: PhaseSegment["phase"],
23
+ progress: number,
24
+ peak: number,
25
+ ): number {
26
+ if (phase === "ramp-up") return progress * peak;
27
+ if (phase === "ramp-down") return (1 - progress) * peak;
28
+ if (phase === "full") return peak;
25
29
  return 0;
26
30
  }
27
31
 
28
32
  function chartSegment(
29
33
  phase: PhaseSegment,
34
+ peak: number,
30
35
  ): Array<{ time: number; level: number }> {
31
36
  const start = clockMinutes(phase.startTime);
32
37
  const firstDuration = Math.min(phase.duration, 1440 - start);
33
38
  const end = start + firstDuration;
34
- const startLevel = levelForPhase(phase.phase, 0);
39
+ const startLevel = levelForPhase(phase.phase, 0, peak);
35
40
  const endProgress = phase.duration ? firstDuration / phase.duration : 1;
36
- const endLevel = levelForPhase(phase.phase, endProgress);
41
+ const endLevel = levelForPhase(phase.phase, endProgress, peak);
37
42
  const points = [
38
43
  { time: start, level: startLevel },
39
44
  { time: end, level: endLevel },
@@ -42,7 +47,7 @@ function chartSegment(
42
47
  if (secondDuration > 0) {
43
48
  points.push(
44
49
  { time: 0, level: endLevel },
45
- { time: secondDuration, level: levelForPhase(phase.phase, 1) },
50
+ { time: secondDuration, level: levelForPhase(phase.phase, 1, peak) },
46
51
  );
47
52
  }
48
53
  return points;
@@ -58,8 +63,9 @@ export function getIntensityChartPaths(schedule: LightingSchedule): {
58
63
  fill: string;
59
64
  line: string;
60
65
  } {
66
+ const peak = schedule.input.intensityPercent / 100;
61
67
  const points = schedule.phases
62
- .flatMap(chartSegment)
68
+ .flatMap((phase) => chartSegment(phase, peak))
63
69
  .sort((left, right) => left.time - right.time);
64
70
  const unique = points.filter(
65
71
  (point, index) => index === 0 || point.time !== points[index - 1]?.time,
@@ -115,6 +121,7 @@ function updateAnswer(
115
121
  "[data-full-intensity]",
116
122
  `${formatHours(schedule.fullIntensityMinutes / 60)} ${ui.hoursUnit}`,
117
123
  );
124
+ updateText(root, "[data-intensity]", `${schedule.input.intensityPercent}%`);
118
125
  updateText(root, "[data-adjustment]", evaluation.adjustment);
119
126
  updateText(
120
127
  root,
@@ -171,6 +178,11 @@ function updateChart(root: RenderRoot, schedule: LightingSchedule): void {
171
178
  startMarker.setAttribute("cx", String((markers.start / 1440) * 960));
172
179
  if (fullMarker)
173
180
  fullMarker.setAttribute("cx", String((markers.full / 1440) * 960));
181
+ if (fullMarker)
182
+ fullMarker.setAttribute(
183
+ "cy",
184
+ String(220 - (schedule.input.intensityPercent / 100) * 180),
185
+ );
174
186
  if (endMarker)
175
187
  endMarker.setAttribute("cx", String((markers.end / 1440) * 960));
176
188
  }
@@ -44,6 +44,7 @@ const ui = {
44
44
  plantProfileHigh: 'Lichtintensive Pflanzen',
45
45
  startTimeLabel: 'Startzeit des Tageszyklus',
46
46
  photoperiodLabel: 'Photoperiode Gesamtdauer',
47
+ intensityLabel: 'Einstellung der Lampenleistung',
47
48
  rampUpLabel: 'Sonnenaufgang Aufdimmen',
48
49
  rampDownLabel: 'Sonnenuntergang Abdimmen',
49
50
  optionalRampsLabel: 'Optionale Dimmphasen',
@@ -56,6 +57,7 @@ const ui = {
56
57
  targetBandLabel: 'Orientierungsrahmen',
57
58
  lightWindowLabel: 'Beleuchtungsfenster',
58
59
  fullIntensityLabel: 'Volle Lichtintensität',
60
+ fixtureLevelLabel: 'Lampenstufe',
59
61
  lightsOnLabel: 'Licht an',
60
62
  fullLightLabel: 'Volllicht',
61
63
  lightsOffLabel: 'Licht aus',
@@ -44,6 +44,7 @@ const ui = {
44
44
  plantProfileHigh: 'High light plants',
45
45
  startTimeLabel: 'Cycle start time',
46
46
  photoperiodLabel: 'Photoperiod',
47
+ intensityLabel: 'Fixture output setting',
47
48
  rampUpLabel: 'Ramp up',
48
49
  rampDownLabel: 'Ramp down',
49
50
  optionalRampsLabel: 'Optional dimming ramps',
@@ -56,6 +57,7 @@ const ui = {
56
57
  targetBandLabel: 'Planning window',
57
58
  lightWindowLabel: 'Light window',
58
59
  fullIntensityLabel: 'Full intensity',
60
+ fixtureLevelLabel: 'Fixture level',
59
61
  lightsOnLabel: 'lights on',
60
62
  fullLightLabel: 'full light',
61
63
  lightsOffLabel: 'lights off',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Plantas exigentes en luz',
30
30
  startTimeLabel: 'Hora de inicio del ciclo',
31
31
  photoperiodLabel: 'Fotoperiodo total',
32
+ intensityLabel: 'Potencia configurada de la pantalla',
32
33
  rampUpLabel: 'Rampa de encendido amanecer',
33
34
  rampDownLabel: 'Rampa de apagado atardecer',
34
35
  optionalRampsLabel: 'Rampas de regulación opcionales',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Banda de planificación',
42
43
  lightWindowLabel: 'Ventana de iluminación',
43
44
  fullIntensityLabel: 'Intensidad completa',
45
+ fixtureLevelLabel: 'Nivel de la pantalla',
44
46
  lightsOnLabel: 'encendido',
45
47
  fullLightLabel: 'luz completa',
46
48
  lightsOffLabel: 'apagado',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Plantes exigeantes en lumiere',
30
30
  startTimeLabel: 'Heure de début du cycle',
31
31
  photoperiodLabel: 'Photopériode totale',
32
+ intensityLabel: 'Réglage de puissance de la rampe',
32
33
  rampUpLabel: 'Rampe d allumage lever',
33
34
  rampDownLabel: 'Rampe d extinction coucher',
34
35
  optionalRampsLabel: 'Rampes de variation optionnelles',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Plage de référence',
42
43
  lightWindowLabel: 'Fenêtre d eclairage',
43
44
  fullIntensityLabel: 'Pleine intensité',
45
+ fixtureLevelLabel: 'Niveau de la rampe',
44
46
  lightsOnLabel: 'allumage',
45
47
  fullLightLabel: 'pleine lumiere',
46
48
  lightsOffLabel: 'extinction',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Tanaman cahaya tinggi',
30
30
  startTimeLabel: 'Waktu mulai siklus',
31
31
  photoperiodLabel: 'Total fotoperiode',
32
+ intensityLabel: 'Tingkat daya lampu',
32
33
  rampUpLabel: 'Transisi redup terbit',
33
34
  rampDownLabel: 'Transisi redup terbenam',
34
35
  optionalRampsLabel: 'Transisi redup opsional',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Rentang acuan',
42
43
  lightWindowLabel: 'Jendela pencahayaan',
43
44
  fullIntensityLabel: 'Intensitas penuh',
45
+ fixtureLevelLabel: 'Level lampu',
44
46
  lightsOnLabel: 'lampu menyala',
45
47
  fullLightLabel: 'cahaya penuh',
46
48
  lightsOffLabel: 'lampu padam',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Piante esigenti di luce',
30
30
  startTimeLabel: 'Ora di inizio del ciclo',
31
31
  photoperiodLabel: 'Fotoperiodo totale',
32
+ intensityLabel: 'Potenza impostata della lampada',
32
33
  rampUpLabel: 'Rampa di alba accensione',
33
34
  rampDownLabel: 'Rampa di tramonto spegnimento',
34
35
  optionalRampsLabel: 'Rampe di dimmerazione opzionali',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Fascia di riferimento',
42
43
  lightWindowLabel: 'Finestra di illuminazione',
43
44
  fullIntensityLabel: 'Piena intensità',
45
+ fixtureLevelLabel: 'Livello della lampada',
44
46
  lightsOnLabel: 'accensione',
45
47
  fullLightLabel: 'piena luce',
46
48
  lightsOffLabel: 'spegnimento',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: '高光量(有茎草など)',
30
30
  startTimeLabel: '点灯開始時間',
31
31
  photoperiodLabel: '合計照射時間',
32
+ intensityLabel: '照明の出力設定',
32
33
  rampUpLabel: '日の出(点灯調光)',
33
34
  rampDownLabel: '日の入り(消灯調光)',
34
35
  optionalRampsLabel: '調光設定(オプション)',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: '推奨照射範囲',
42
43
  lightWindowLabel: '照明タイマースケジュール',
43
44
  fullIntensityLabel: '全灯点灯時間',
45
+ fixtureLevelLabel: '出力レベル',
44
46
  lightsOnLabel: '点灯開始',
45
47
  fullLightLabel: '全灯照射',
46
48
  lightsOffLabel: '完全消灯',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: '양성 수초 (고광량)',
30
30
  startTimeLabel: '조명 시작 시간',
31
31
  photoperiodLabel: '총 조명 시간',
32
+ intensityLabel: '조명 출력 설정',
32
33
  rampUpLabel: '일출 (점등 전환)',
33
34
  rampDownLabel: '일몰 (소등 전환)',
34
35
  optionalRampsLabel: '디밍 전환 설정 (선택)',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: '권장 시간 범위',
42
43
  lightWindowLabel: '조명 일정 시간표',
43
44
  fullIntensityLabel: '최대 광량 시간',
45
+ fixtureLevelLabel: '조명 단계',
44
46
  lightsOnLabel: '점등 시작',
45
47
  fullLightLabel: '최대 광량',
46
48
  lightsOffLabel: '완전 소등',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Lichtbehoeftige planten',
30
30
  startTimeLabel: 'Starttijd van de cyclus',
31
31
  photoperiodLabel: 'Totale fotoperiode',
32
+ intensityLabel: 'Ingesteld lampvermogen',
32
33
  rampUpLabel: 'Zonsopkomst (indimmen)',
33
34
  rampDownLabel: 'Zonsondergang (uitdimmen)',
34
35
  optionalRampsLabel: 'Optionele dimfasen',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Richtlijn tijdsduur',
42
43
  lightWindowLabel: 'Verlichtingsvenster',
43
44
  fullIntensityLabel: 'Volle lichtsterkte',
45
+ fixtureLevelLabel: 'Lampniveau',
44
46
  lightsOnLabel: 'licht aan',
45
47
  fullLightLabel: 'vol licht',
46
48
  lightsOffLabel: 'licht uit',
@@ -29,6 +29,7 @@ const ui = {
29
29
  plantProfileHigh: 'Rosliny swiatlolubne',
30
30
  startTimeLabel: 'Czas startu cyklu',
31
31
  photoperiodLabel: 'Calkowity fotoperiod',
32
+ intensityLabel: 'Ustawiona moc lampy',
32
33
  rampUpLabel: 'Swit rozjasnianie',
33
34
  rampDownLabel: 'Zmierzch sciemnianie',
34
35
  optionalRampsLabel: 'Opcjonalne fazy sciemniania',
@@ -41,6 +42,7 @@ const ui = {
41
42
  targetBandLabel: 'Zakres docelowy',
42
43
  lightWindowLabel: 'Okno oswietlenia',
43
44
  fullIntensityLabel: 'Pelna moc oswietlenia',
45
+ fixtureLevelLabel: 'Poziom lampy',
44
46
  lightsOnLabel: 'wlaczanie',
45
47
  fullLightLabel: 'pelne swiatlo',
46
48
  lightsOffLabel: 'wylaczanie',