@jjlmoya/utils-aquarium 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +56 -0
- package/scripts/postinstall.mjs +27 -0
- package/src/category/i18n/de.ts +13 -0
- package/src/category/i18n/en.ts +13 -0
- package/src/category/i18n/es.ts +13 -0
- package/src/category/i18n/fr.ts +13 -0
- package/src/category/i18n/id.ts +13 -0
- package/src/category/i18n/it.ts +13 -0
- package/src/category/i18n/ja.ts +13 -0
- package/src/category/i18n/ko.ts +13 -0
- package/src/category/i18n/nl.ts +13 -0
- package/src/category/i18n/pl.ts +13 -0
- package/src/category/i18n/pt.ts +13 -0
- package/src/category/i18n/ru.ts +13 -0
- package/src/category/i18n/sv.ts +13 -0
- package/src/category/i18n/tr.ts +13 -0
- package/src/category/i18n/zh.ts +13 -0
- package/src/category/index.ts +24 -0
- package/src/category/seo.astro +12 -0
- package/src/components/PreviewNavSidebar.astro +116 -0
- package/src/components/PreviewToolbar.astro +143 -0
- package/src/data.ts +14 -0
- package/src/entries.ts +7 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +20 -0
- package/src/layouts/PreviewLayout.astro +117 -0
- package/src/pages/[locale]/[slug].astro +65 -0
- package/src/pages/[locale].astro +69 -0
- package/src/pages/index.astro +4 -0
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/faq_count.test.ts +13 -0
- package/src/tests/i18n_coverage.test.ts +14 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/locale_completeness.test.ts +29 -0
- package/src/tests/mocks/astro_mock.js +2 -0
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/no_h1_in_components.test.ts +48 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/schemas_fulfillment.test.ts +23 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tests/seo_length.test.ts +26 -0
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/shared-test-helpers.ts +56 -0
- package/src/tests/slug_language_code_format.test.ts +23 -0
- package/src/tests/slug_uniqueness.test.ts +81 -0
- package/src/tests/spanish_leakage.test.ts +175 -0
- package/src/tests/title_quality.test.ts +55 -0
- package/src/tests/tool_exports.test.ts +34 -0
- package/src/tests/tool_validation.test.ts +11 -0
- package/src/tests/translation_copy.test.ts +123 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/aquarium-tank-volume-water-change-calculator.css +391 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/bibliography.astro +6 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/bibliography.ts +14 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/component.astro +93 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/controller.ts +147 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/dom-views.ts +64 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/entry.ts +13 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/evaluator.ts +19 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/en.ts +186 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/index.ts +11 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/logic.test.ts +60 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/logic.ts +132 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/seo.astro +13 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/storage.ts +30 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/ui.ts +41 -0
- package/src/tools.ts +9 -0
- package/src/types.ts +54 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AquariumTankVolumeWaterChangeCalculatorUI } from './ui';
|
|
2
|
+
import type { TankVolume, UnitSystem } from './logic';
|
|
3
|
+
import { getSubstrateShare, getTankScale } from './evaluator';
|
|
4
|
+
import { kilogramsToPounds, litresToGallons } from './logic';
|
|
5
|
+
|
|
6
|
+
function getElement<T extends HTMLElement>(root: HTMLElement, selector: string): T | null {
|
|
7
|
+
return root.querySelector<T>(selector);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function setText(root: HTMLElement, selector: string, text: string): void {
|
|
11
|
+
const element = getElement<HTMLElement>(root, selector);
|
|
12
|
+
if (element !== null) element.textContent = text;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatNumber(value: number, locale: string): string {
|
|
16
|
+
return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function displayVolume(value: number, unit: UnitSystem, locale: string): string {
|
|
20
|
+
const amount = unit === 'metric' ? value : litresToGallons(value);
|
|
21
|
+
const label = unit === 'metric' ? 'L' : 'US gal';
|
|
22
|
+
return formatNumber(amount, locale) + ' ' + label;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function displayMass(value: number, unit: UnitSystem, locale: string): string {
|
|
26
|
+
const amount = unit === 'metric' ? value : kilogramsToPounds(value);
|
|
27
|
+
const label = unit === 'metric' ? 'kg' : 'lb';
|
|
28
|
+
return formatNumber(amount, locale) + ' ' + label;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RenderOptions {
|
|
32
|
+
result: TankVolume;
|
|
33
|
+
unit: UnitSystem;
|
|
34
|
+
locale: string;
|
|
35
|
+
ui: AquariumTankVolumeWaterChangeCalculatorUI;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function renderResults(root: HTMLElement, options: RenderOptions): void {
|
|
39
|
+
const { result, unit, locale, ui } = options;
|
|
40
|
+
setText(root, '[data-result="gross"]', displayVolume(result.grossLitres, unit, locale));
|
|
41
|
+
setText(root, '[data-result="substrate"]', displayVolume(result.substrateLitres, unit, locale));
|
|
42
|
+
setText(root, '[data-result="net"]', displayVolume(result.netLitres, unit, locale));
|
|
43
|
+
setText(root, '[data-result="change"]', displayVolume(result.changeLitres, unit, locale));
|
|
44
|
+
setText(root, '[data-result="mass"]', displayMass(result.waterMassKg, unit, locale));
|
|
45
|
+
setText(root, '[data-result="footprint"]', formatNumber(result.footprintCm2 / 10000, locale) + ' m²');
|
|
46
|
+
setText(root, '[data-result="share"]', formatNumber(getSubstrateShare(result), locale) + '%');
|
|
47
|
+
setText(root, '[data-result="scale"]', ui[getTankScale(result.netLitres)] ?? '');
|
|
48
|
+
const water = getElement<HTMLElement>(root, '[data-water]');
|
|
49
|
+
if (water !== null) water.style.setProperty('--water-level', String(Math.min(92, Math.max(20, result.netLitres / 4))));
|
|
50
|
+
root.classList.add('has-results');
|
|
51
|
+
root.classList.remove('has-error');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function renderError(root: HTMLElement, message: string): void {
|
|
55
|
+
setText(root, '[data-error]', message);
|
|
56
|
+
root.classList.remove('has-results');
|
|
57
|
+
root.classList.add('has-error');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderEmpty(root: HTMLElement, message: string): void {
|
|
61
|
+
setText(root, '[data-empty]', message);
|
|
62
|
+
root.classList.remove('has-results');
|
|
63
|
+
root.classList.remove('has-error');
|
|
64
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AquariumToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { AquariumTankVolumeWaterChangeCalculatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { AquariumTankVolumeWaterChangeCalculatorUI };
|
|
5
|
+
export type AquariumTankVolumeWaterChangeCalculatorLocaleContent = ToolLocaleContent<AquariumTankVolumeWaterChangeCalculatorUI>;
|
|
6
|
+
|
|
7
|
+
export const aquariumTankVolumeWaterChangeCalculator: AquariumToolEntry<AquariumTankVolumeWaterChangeCalculatorUI> = {
|
|
8
|
+
id: 'aquarium-tank-volume-water-change-calculator',
|
|
9
|
+
icons: { bg: 'mdi:fishbowl-outline', fg: 'mdi:water-opacity' },
|
|
10
|
+
i18n: {
|
|
11
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TankVolume } from './logic';
|
|
2
|
+
|
|
3
|
+
export type TankScale = 'nano' | 'compact' | 'roomy' | 'showcase';
|
|
4
|
+
|
|
5
|
+
export function getTankScale(litres: number): TankScale {
|
|
6
|
+
if (litres < 20) return 'nano';
|
|
7
|
+
if (litres < 100) return 'compact';
|
|
8
|
+
if (litres < 300) return 'roomy';
|
|
9
|
+
return 'showcase';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getSubstrateShare(volume: TankVolume): number {
|
|
13
|
+
if (volume.grossLitres <= 0) return 0;
|
|
14
|
+
return volume.substrateLitres / volume.grossLitres * 100;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function clampPercentage(value: number): number {
|
|
18
|
+
return Math.min(100, Math.max(0, value));
|
|
19
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
2
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
3
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
4
|
+
import type { AquariumTankVolumeWaterChangeCalculatorUI } from '../ui';
|
|
5
|
+
|
|
6
|
+
const slug = 'aquarium-tank-volume-water-change-calculator';
|
|
7
|
+
const title = 'Aquarium Tank Volume and Water Change Calculator';
|
|
8
|
+
const description = 'Calculate usable aquarium volume, substrate displacement, water change litres and water weight from your own tank dimensions. Works offline with Metric and Imperial units.';
|
|
9
|
+
|
|
10
|
+
const faq = [
|
|
11
|
+
{
|
|
12
|
+
question: 'Does this calculator use the advertised tank capacity?',
|
|
13
|
+
answer: 'No. It subtracts the substrate depth from the geometric tank volume, so the result is an estimate of usable water volume rather than a manufacturer capacity claim.',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
question: 'How does the bow front option work?',
|
|
17
|
+
answer: 'It adds a two thirds circular segment to the rectangular footprint. Treat that result as an approximation because real curved glass profiles vary by tank.',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
question: 'Which imperial units are shown?',
|
|
21
|
+
answer: 'Imperial mode shows US gallons for volume, pounds for water mass and inches for dimensions.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
question: 'Does this recommend a water change schedule?',
|
|
25
|
+
answer: 'No. It only calculates the amount represented by the percentage you enter. It does not assess water quality, stocking, species or husbandry.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
question: 'Does my data leave the browser?',
|
|
29
|
+
answer: 'No network request is needed for the calculation. Your last local settings may be saved in this browser so the form can reopen with the same values.',
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const howTo = [
|
|
34
|
+
{
|
|
35
|
+
name: 'Choose the tank shape',
|
|
36
|
+
text: 'Select rectangular, cylindrical or bow front. The form shows only the dimensions needed for that shape.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'Enter inside dimensions',
|
|
40
|
+
text: 'Enter the tank length, width and height, or the diameter and height for a cylinder. Use inside measurements when possible.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'Account for substrate',
|
|
44
|
+
text: 'Enter the average substrate depth. The calculator subtracts that layer from the gross geometric volume.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'Set the water change percentage',
|
|
48
|
+
text: 'Choose the percentage you plan to replace. The result gives the corresponding volume and approximate water mass.',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
53
|
+
'@context': 'https://schema.org',
|
|
54
|
+
'@type': 'FAQPage',
|
|
55
|
+
mainEntity: faq.map((item) => ({
|
|
56
|
+
'@type': 'Question',
|
|
57
|
+
name: item.question,
|
|
58
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const howToSchema: WithContext<HowTo> = {
|
|
63
|
+
'@context': 'https://schema.org',
|
|
64
|
+
'@type': 'HowTo',
|
|
65
|
+
name: title,
|
|
66
|
+
description,
|
|
67
|
+
step: howTo.map((step, index) => ({
|
|
68
|
+
'@type': 'HowToStep',
|
|
69
|
+
position: index + 1,
|
|
70
|
+
name: step.name,
|
|
71
|
+
text: step.text,
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
76
|
+
'@context': 'https://schema.org',
|
|
77
|
+
'@type': 'SoftwareApplication',
|
|
78
|
+
name: title,
|
|
79
|
+
description,
|
|
80
|
+
applicationCategory: 'UtilityApplication',
|
|
81
|
+
operatingSystem: 'All',
|
|
82
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
83
|
+
inLanguage: 'en',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const content: ToolLocaleContent<AquariumTankVolumeWaterChangeCalculatorUI> = {
|
|
87
|
+
slug,
|
|
88
|
+
title,
|
|
89
|
+
description,
|
|
90
|
+
faq,
|
|
91
|
+
bibliography,
|
|
92
|
+
howTo,
|
|
93
|
+
schemas: [faqSchema, howToSchema, appSchema],
|
|
94
|
+
seo: [
|
|
95
|
+
{
|
|
96
|
+
type: 'title',
|
|
97
|
+
text: 'Know the Water You Really Have',
|
|
98
|
+
level: 2,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: 'paragraph',
|
|
102
|
+
html: 'A tank may be sold with a headline capacity that does not match the water you can actually fill. This calculator turns inside dimensions into a clearer working estimate, then removes the substrate layer before it tells you how much a chosen water change represents.',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: 'title',
|
|
106
|
+
text: 'Three Tank Shapes in One Quiet Lab',
|
|
107
|
+
level: 2,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: 'list',
|
|
111
|
+
items: [
|
|
112
|
+
'<strong>Rectangular:</strong> length multiplied by width multiplied by height.',
|
|
113
|
+
'<strong>Cylindrical:</strong> pi multiplied by radius squared multiplied by height.',
|
|
114
|
+
'<strong>Bow front:</strong> rectangular footprint plus a two thirds circular segment approximation.',
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
type: 'title',
|
|
119
|
+
text: 'From Glass to Gravel to Water',
|
|
120
|
+
level: 2,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: 'paragraph',
|
|
124
|
+
html: 'The working volume is the geometric volume minus the volume occupied by the substrate layer. The water change result is that working volume multiplied by your chosen percentage. Water mass uses a rounded density of 1 kilogram per litre, so temperature, salinity and dissolved material can shift the real value slightly.',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: 'tip',
|
|
128
|
+
title: 'Measure inside the tank',
|
|
129
|
+
html: '<p>Use internal dimensions and the average substrate depth. A ruler and a quick three-point average will usually be more useful than relying on a product box capacity.</p>',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
type: 'table',
|
|
133
|
+
headers: ['Output', 'What it means'],
|
|
134
|
+
rows: [
|
|
135
|
+
['Gross volume', 'The geometric volume before substrate displacement'],
|
|
136
|
+
['Substrate volume', 'The estimated space occupied by the substrate layer'],
|
|
137
|
+
['Net water volume', 'The working water estimate after displacement'],
|
|
138
|
+
['Water change', 'The entered percentage of the net water volume'],
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
ui: {
|
|
143
|
+
toolTitle: 'Tank volume lab',
|
|
144
|
+
toolSubtitle: 'Shape, substrate and water change in one calm view.',
|
|
145
|
+
dimensionsTitle: 'Tank dimensions',
|
|
146
|
+
shapeLabel: 'Tank shape',
|
|
147
|
+
rectangularShape: 'Rectangular',
|
|
148
|
+
cylindricalShape: 'Cylindrical',
|
|
149
|
+
bowShape: 'Bow front',
|
|
150
|
+
lengthLabel: 'Inside length',
|
|
151
|
+
widthLabel: 'Inside width',
|
|
152
|
+
heightLabel: 'Inside height',
|
|
153
|
+
diameterLabel: 'Inside diameter',
|
|
154
|
+
bowDepthLabel: 'Front curve depth',
|
|
155
|
+
substrateLabel: 'Average substrate depth',
|
|
156
|
+
changeLabel: 'Water change',
|
|
157
|
+
unitLabel: 'Units',
|
|
158
|
+
metricLabel: 'Metric',
|
|
159
|
+
imperialLabel: 'Imperial',
|
|
160
|
+
calculateLabel: 'Calculate volume',
|
|
161
|
+
resetLabel: 'Reset',
|
|
162
|
+
grossVolumeLabel: 'Gross volume',
|
|
163
|
+
substrateVolumeLabel: 'Substrate displacement',
|
|
164
|
+
netVolumeLabel: 'Net water volume',
|
|
165
|
+
changeVolumeLabel: 'Water to replace',
|
|
166
|
+
waterWeightLabel: 'Water weight',
|
|
167
|
+
emptyState: 'Adjust the dimensions to see the tank fill.',
|
|
168
|
+
invalidState: 'Check the dimensions: use positive values, keep substrate below the tank height and keep the change between 0 and 100 percent.',
|
|
169
|
+
litreUnit: 'L',
|
|
170
|
+
gallonUnit: 'US gal',
|
|
171
|
+
kilogramUnit: 'kg',
|
|
172
|
+
poundUnit: 'lb',
|
|
173
|
+
lengthUnitMetric: 'cm',
|
|
174
|
+
lengthUnitImperial: 'in',
|
|
175
|
+
shapeHint: 'Use inside measurements for a more useful estimate.',
|
|
176
|
+
bowHint: 'Bow front uses a transparent two thirds segment approximation.',
|
|
177
|
+
substrateHint: 'This layer is subtracted from the gross volume.',
|
|
178
|
+
resultHint: 'Your result updates locally as you type.',
|
|
179
|
+
approximationLabel: 'Substrate share',
|
|
180
|
+
footprintLabel: 'Footprint',
|
|
181
|
+
nano: 'Nano',
|
|
182
|
+
compact: 'Compact',
|
|
183
|
+
roomy: 'Roomy',
|
|
184
|
+
showcase: 'Showcase',
|
|
185
|
+
},
|
|
186
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { aquariumTankVolumeWaterChangeCalculator } from './entry';
|
|
2
|
+
import type { ToolDefinition } from '../../types';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const AQUARIUM_TANK_VOLUME_WATER_CHANGE_CALCULATOR_TOOL: ToolDefinition = {
|
|
7
|
+
entry: aquariumTankVolumeWaterChangeCalculator,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_TANK_INPUT,
|
|
4
|
+
calculateTankVolume,
|
|
5
|
+
centimetresToInches,
|
|
6
|
+
inchesToCentimetres,
|
|
7
|
+
kilogramsToPounds,
|
|
8
|
+
litresToGallons,
|
|
9
|
+
} from './logic';
|
|
10
|
+
|
|
11
|
+
describe('aquarium tank volume logic', () => {
|
|
12
|
+
it('calculates a rectangular tank after substrate displacement', () => {
|
|
13
|
+
const result = calculateTankVolume(DEFAULT_TANK_INPUT);
|
|
14
|
+
expect(result.ok).toBe(true);
|
|
15
|
+
if (result.ok) {
|
|
16
|
+
expect(result.value.grossLitres).toBeCloseTo(63, 8);
|
|
17
|
+
expect(result.value.substrateLitres).toBeCloseTo(7.2, 8);
|
|
18
|
+
expect(result.value.netLitres).toBeCloseTo(55.8, 8);
|
|
19
|
+
expect(result.value.changeLitres).toBeCloseTo(13.95, 8);
|
|
20
|
+
expect(result.value.waterMassKg).toBeCloseTo(55.8, 8);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('calculates a cylindrical tank with no substrate', () => {
|
|
25
|
+
const result = calculateTankVolume({
|
|
26
|
+
...DEFAULT_TANK_INPUT,
|
|
27
|
+
shape: 'cylindrical',
|
|
28
|
+
diameterCm: 40,
|
|
29
|
+
heightCm: 50,
|
|
30
|
+
substrateDepthCm: 0,
|
|
31
|
+
changePercent: 50,
|
|
32
|
+
});
|
|
33
|
+
expect(result.ok).toBe(true);
|
|
34
|
+
if (result.ok) {
|
|
35
|
+
expect(result.value.grossLitres).toBeCloseTo(62.831853, 5);
|
|
36
|
+
expect(result.value.changeLitres).toBeCloseTo(31.415926, 5);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('uses the bow front approximation and rejects impossible substrate', () => {
|
|
41
|
+
const bowResult = calculateTankVolume({ ...DEFAULT_TANK_INPUT, shape: 'bow', bowDepthCm: 6 });
|
|
42
|
+
expect(bowResult.ok).toBe(true);
|
|
43
|
+
const invalid = calculateTankVolume({ ...DEFAULT_TANK_INPUT, substrateDepthCm: 35 });
|
|
44
|
+
expect(invalid).toEqual({ ok: false, error: 'substrateDepth' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects non-finite, negative, oversized and impossible values', () => {
|
|
48
|
+
expect(calculateTankVolume({ ...DEFAULT_TANK_INPUT, lengthCm: Number.NaN })).toEqual({ ok: false, error: 'dimensions' });
|
|
49
|
+
expect(calculateTankVolume({ ...DEFAULT_TANK_INPUT, widthCm: -1 })).toEqual({ ok: false, error: 'dimensions' });
|
|
50
|
+
expect(calculateTankVolume({ ...DEFAULT_TANK_INPUT, heightCm: 501 })).toEqual({ ok: false, error: 'dimensions' });
|
|
51
|
+
expect(calculateTankVolume({ ...DEFAULT_TANK_INPUT, changePercent: 101 })).toEqual({ ok: false, error: 'changePercent' });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('converts display units using reversible factors', () => {
|
|
55
|
+
expect(inchesToCentimetres(10)).toBeCloseTo(25.4, 8);
|
|
56
|
+
expect(centimetresToInches(25.4)).toBeCloseTo(10, 8);
|
|
57
|
+
expect(litresToGallons(3.785411784)).toBeCloseTo(1, 8);
|
|
58
|
+
expect(kilogramsToPounds(1)).toBeCloseTo(2.2046226218, 8);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export type TankShape = 'rectangular' | 'cylindrical' | 'bow';
|
|
2
|
+
export type UnitSystem = 'metric' | 'imperial';
|
|
3
|
+
|
|
4
|
+
export interface TankInput {
|
|
5
|
+
shape: TankShape;
|
|
6
|
+
lengthCm: number;
|
|
7
|
+
widthCm: number;
|
|
8
|
+
heightCm: number;
|
|
9
|
+
diameterCm: number;
|
|
10
|
+
bowDepthCm: number;
|
|
11
|
+
substrateDepthCm: number;
|
|
12
|
+
changePercent: number;
|
|
13
|
+
waterDensityKgPerL: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TankVolume {
|
|
17
|
+
footprintCm2: number;
|
|
18
|
+
grossLitres: number;
|
|
19
|
+
substrateLitres: number;
|
|
20
|
+
netLitres: number;
|
|
21
|
+
changeLitres: number;
|
|
22
|
+
waterMassKg: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CalculationResult =
|
|
26
|
+
| { ok: true; value: TankVolume }
|
|
27
|
+
| { ok: false; error: string };
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_TANK_INPUT: TankInput = {
|
|
30
|
+
shape: 'rectangular',
|
|
31
|
+
lengthCm: 60,
|
|
32
|
+
widthCm: 30,
|
|
33
|
+
heightCm: 35,
|
|
34
|
+
diameterCm: 40,
|
|
35
|
+
bowDepthCm: 5,
|
|
36
|
+
substrateDepthCm: 4,
|
|
37
|
+
changePercent: 25,
|
|
38
|
+
waterDensityKgPerL: 1,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const MAX_DIMENSION_CM = 500;
|
|
42
|
+
const BOW_AREA_FACTOR = 2 / 3;
|
|
43
|
+
|
|
44
|
+
function isFinitePositive(value: number): boolean {
|
|
45
|
+
return Number.isFinite(value) && value > 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isValidDimension(value: number): boolean {
|
|
49
|
+
return isFinitePositive(value) && value <= MAX_DIMENSION_CM;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function calculateFootprint(input: TankInput): number {
|
|
53
|
+
if (input.shape === 'cylindrical') return Math.PI * (input.diameterCm / 2) ** 2;
|
|
54
|
+
const base = input.lengthCm * input.widthCm;
|
|
55
|
+
if (input.shape === 'bow') return base + BOW_AREA_FACTOR * input.lengthCm * input.bowDepthCm;
|
|
56
|
+
return base;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateDimensions(input: TankInput): string | null {
|
|
60
|
+
const dimensions = input.shape === 'cylindrical'
|
|
61
|
+
? [input.diameterCm, input.heightCm]
|
|
62
|
+
: [input.lengthCm, input.widthCm, input.heightCm];
|
|
63
|
+
if (dimensions.some((value) => isValidDimension(value) === false)) return 'dimensions';
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateBow(input: TankInput): string | null {
|
|
68
|
+
if (input.shape !== 'bow') return null;
|
|
69
|
+
if (Number.isFinite(input.bowDepthCm) === false || input.bowDepthCm < 0 || input.bowDepthCm > input.widthCm) return 'bowDepth';
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateSubstrate(input: TankInput): string | null {
|
|
74
|
+
if (Number.isFinite(input.substrateDepthCm) === false || input.substrateDepthCm < 0 || input.substrateDepthCm >= input.heightCm) return 'substrateDepth';
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateChange(input: TankInput): string | null {
|
|
79
|
+
if (Number.isFinite(input.changePercent) === false || input.changePercent < 0 || input.changePercent > 100) return 'changePercent';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateDensity(input: TankInput): string | null {
|
|
84
|
+
if (Number.isFinite(input.waterDensityKgPerL) === false || input.waterDensityKgPerL <= 0) return 'density';
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateInput(input: TankInput): string | null {
|
|
89
|
+
const validators = [validateDimensions, validateBow, validateSubstrate, validateChange, validateDensity];
|
|
90
|
+
for (const validator of validators) {
|
|
91
|
+
const error = validator(input);
|
|
92
|
+
if (error !== null) return error;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function calculateTankVolume(input: TankInput): CalculationResult {
|
|
98
|
+
const error = validateInput(input);
|
|
99
|
+
if (error !== null) return { ok: false, error };
|
|
100
|
+
const footprintCm2 = calculateFootprint(input);
|
|
101
|
+
const grossLitres = (footprintCm2 * input.heightCm) / 1000;
|
|
102
|
+
const substrateLitres = (footprintCm2 * input.substrateDepthCm) / 1000;
|
|
103
|
+
const netLitres = grossLitres - substrateLitres;
|
|
104
|
+
const changeLitres = netLitres * input.changePercent / 100;
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
value: {
|
|
108
|
+
footprintCm2,
|
|
109
|
+
grossLitres,
|
|
110
|
+
substrateLitres,
|
|
111
|
+
netLitres,
|
|
112
|
+
changeLitres,
|
|
113
|
+
waterMassKg: netLitres * input.waterDensityKgPerL,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function litresToGallons(litres: number): number {
|
|
119
|
+
return litres / 3.785411784;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function kilogramsToPounds(kilograms: number): number {
|
|
123
|
+
return kilograms * 2.2046226218;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function centimetresToInches(centimetres: number): number {
|
|
127
|
+
return centimetres / 2.54;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function inchesToCentimetres(inches: number): number {
|
|
131
|
+
return inches * 2.54;
|
|
132
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { aquariumTankVolumeWaterChangeCalculator } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props { locale?: KnownLocale; }
|
|
7
|
+
|
|
8
|
+
const { locale = 'en' } = Astro.props;
|
|
9
|
+
const loader = aquariumTankVolumeWaterChangeCalculator.i18n[locale] ?? aquariumTankVolumeWaterChangeCalculator.i18n.en;
|
|
10
|
+
const content = loader === undefined ? undefined : await loader();
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
{content && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { DEFAULT_TANK_INPUT, type TankInput, type UnitSystem } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'jjlmoya-utils-aquarium:tank-volume-water-change';
|
|
4
|
+
|
|
5
|
+
export interface SavedAquariumState {
|
|
6
|
+
unit: UnitSystem;
|
|
7
|
+
input: TankInput;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadAquariumState(): SavedAquariumState | null {
|
|
11
|
+
try {
|
|
12
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
13
|
+
if (raw === null) return null;
|
|
14
|
+
const parsed = JSON.parse(raw) as SavedAquariumState;
|
|
15
|
+
if (parsed.input === undefined || (parsed.unit !== 'metric' && parsed.unit !== 'imperial')) return null;
|
|
16
|
+
return parsed;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveAquariumState(state: SavedAquariumState): void {
|
|
23
|
+
try {
|
|
24
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function defaultAquariumState(): SavedAquariumState {
|
|
29
|
+
return { unit: 'metric', input: { ...DEFAULT_TANK_INPUT } };
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface AquariumTankVolumeWaterChangeCalculatorUI {
|
|
2
|
+
[key: string]: string;
|
|
3
|
+
toolTitle: string;
|
|
4
|
+
toolSubtitle: string;
|
|
5
|
+
dimensionsTitle: string;
|
|
6
|
+
shapeLabel: string;
|
|
7
|
+
rectangularShape: string;
|
|
8
|
+
cylindricalShape: string;
|
|
9
|
+
bowShape: string;
|
|
10
|
+
lengthLabel: string;
|
|
11
|
+
widthLabel: string;
|
|
12
|
+
heightLabel: string;
|
|
13
|
+
diameterLabel: string;
|
|
14
|
+
bowDepthLabel: string;
|
|
15
|
+
substrateLabel: string;
|
|
16
|
+
changeLabel: string;
|
|
17
|
+
unitLabel: string;
|
|
18
|
+
metricLabel: string;
|
|
19
|
+
imperialLabel: string;
|
|
20
|
+
calculateLabel: string;
|
|
21
|
+
resetLabel: string;
|
|
22
|
+
grossVolumeLabel: string;
|
|
23
|
+
substrateVolumeLabel: string;
|
|
24
|
+
netVolumeLabel: string;
|
|
25
|
+
changeVolumeLabel: string;
|
|
26
|
+
waterWeightLabel: string;
|
|
27
|
+
emptyState: string;
|
|
28
|
+
invalidState: string;
|
|
29
|
+
litreUnit: string;
|
|
30
|
+
gallonUnit: string;
|
|
31
|
+
kilogramUnit: string;
|
|
32
|
+
poundUnit: string;
|
|
33
|
+
lengthUnitMetric: string;
|
|
34
|
+
lengthUnitImperial: string;
|
|
35
|
+
shapeHint: string;
|
|
36
|
+
bowHint: string;
|
|
37
|
+
substrateHint: string;
|
|
38
|
+
resultHint: string;
|
|
39
|
+
approximationLabel: string;
|
|
40
|
+
footprintLabel: string;
|
|
41
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { ALL_ENTRIES } from './entries';
|
|
2
|
+
import { aquariumTankVolumeWaterChangeCalculator } from './tool/aquariumTankVolumeWaterChangeCalculator/entry';
|
|
3
|
+
|
|
4
|
+
export const ALL_TOOLS = [{
|
|
5
|
+
entry: aquariumTankVolumeWaterChangeCalculator,
|
|
6
|
+
Component: () => import('./tool/aquariumTankVolumeWaterChangeCalculator/component.astro'),
|
|
7
|
+
SEOComponent: () => import('./tool/aquariumTankVolumeWaterChangeCalculator/seo.astro'),
|
|
8
|
+
BibliographyComponent: () => import('./tool/aquariumTankVolumeWaterChangeCalculator/bibliography.astro'),
|
|
9
|
+
}];
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { SEOSection } from '@jjlmoya/utils-shared';
|
|
2
|
+
import type { WithContext, Thing } from 'schema-dts';
|
|
3
|
+
|
|
4
|
+
export type { SEOSection };
|
|
5
|
+
|
|
6
|
+
export type KnownLocale =
|
|
7
|
+
| 'ar' | 'da' | 'de' | 'en' | 'es' | 'fi'
|
|
8
|
+
| 'fr' | 'id' | 'it' | 'ja' | 'ko' | 'nb' | 'nl'
|
|
9
|
+
| 'pl' | 'pt' | 'ru' | 'sv' | 'tr' | 'zh';
|
|
10
|
+
|
|
11
|
+
export interface FAQItem { question: string; answer: string; }
|
|
12
|
+
export interface BibliographyEntry { name: string; url: string; }
|
|
13
|
+
export interface HowToStep { name: string; text: string; }
|
|
14
|
+
|
|
15
|
+
export interface ToolLocaleContent<TUI extends Record<string, string> = Record<string, string>> {
|
|
16
|
+
slug: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
ui: TUI;
|
|
20
|
+
seo: SEOSection[];
|
|
21
|
+
faq: FAQItem[];
|
|
22
|
+
bibliography: BibliographyEntry[];
|
|
23
|
+
howTo: HowToStep[];
|
|
24
|
+
schemas: WithContext<Thing>[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CategoryLocaleContent {
|
|
28
|
+
slug: string;
|
|
29
|
+
title: string;
|
|
30
|
+
description: string;
|
|
31
|
+
seo: SEOSection[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type LocaleLoader<T> = () => Promise<T>;
|
|
35
|
+
export type LocaleMap<T> = Partial<Record<KnownLocale, LocaleLoader<T>>>;
|
|
36
|
+
|
|
37
|
+
export interface AquariumToolEntry<TUI extends Record<string, string> = Record<string, string>> {
|
|
38
|
+
id: string;
|
|
39
|
+
icons: { bg: string; fg: string; };
|
|
40
|
+
i18n: LocaleMap<ToolLocaleContent<TUI>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AquariumCategoryEntry {
|
|
44
|
+
icon: string;
|
|
45
|
+
tools: AquariumToolEntry<Record<string, string>>[];
|
|
46
|
+
i18n: LocaleMap<CategoryLocaleContent>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ToolDefinition {
|
|
50
|
+
entry: AquariumToolEntry;
|
|
51
|
+
Component: unknown;
|
|
52
|
+
SEOComponent: unknown;
|
|
53
|
+
BibliographyComponent: unknown;
|
|
54
|
+
}
|