@jjlmoya/utils-health 1.33.0 → 1.34.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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +3 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/estimatedBloodVolumeCalculator/bibliography.astro +14 -0
  8. package/src/tool/estimatedBloodVolumeCalculator/bibliography.ts +20 -0
  9. package/src/tool/estimatedBloodVolumeCalculator/component.astro +350 -0
  10. package/src/tool/estimatedBloodVolumeCalculator/entry.ts +27 -0
  11. package/src/tool/estimatedBloodVolumeCalculator/estimated-blood-volume-calculator.css +438 -0
  12. package/src/tool/estimatedBloodVolumeCalculator/i18n/de.ts +352 -0
  13. package/src/tool/estimatedBloodVolumeCalculator/i18n/en.ts +352 -0
  14. package/src/tool/estimatedBloodVolumeCalculator/i18n/es.ts +352 -0
  15. package/src/tool/estimatedBloodVolumeCalculator/i18n/fr.ts +352 -0
  16. package/src/tool/estimatedBloodVolumeCalculator/i18n/id.ts +352 -0
  17. package/src/tool/estimatedBloodVolumeCalculator/i18n/it.ts +352 -0
  18. package/src/tool/estimatedBloodVolumeCalculator/i18n/ja.ts +352 -0
  19. package/src/tool/estimatedBloodVolumeCalculator/i18n/ko.ts +352 -0
  20. package/src/tool/estimatedBloodVolumeCalculator/i18n/nl.ts +352 -0
  21. package/src/tool/estimatedBloodVolumeCalculator/i18n/pl.ts +352 -0
  22. package/src/tool/estimatedBloodVolumeCalculator/i18n/pt.ts +352 -0
  23. package/src/tool/estimatedBloodVolumeCalculator/i18n/ru.ts +352 -0
  24. package/src/tool/estimatedBloodVolumeCalculator/i18n/sv.ts +352 -0
  25. package/src/tool/estimatedBloodVolumeCalculator/i18n/tr.ts +352 -0
  26. package/src/tool/estimatedBloodVolumeCalculator/i18n/zh.ts +352 -0
  27. package/src/tool/estimatedBloodVolumeCalculator/index.ts +12 -0
  28. package/src/tool/estimatedBloodVolumeCalculator/logic.ts +67 -0
  29. package/src/tool/estimatedBloodVolumeCalculator/seo.astro +15 -0
  30. package/src/tool/estimatedBloodVolumeCalculator/ui.ts +45 -0
  31. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-health",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -29,10 +29,11 @@ import { correctedSodiumHyperglycemiaCalculator } from '../tool/correctedSodiumH
29
29
  import { fractionalExcretionSodiumCalculator } from '../tool/fractionalExcretionSodiumCalculator/entry';
30
30
  import { targetHeightEstimator } from '../tool/targetHeightEstimator/entry';
31
31
  import { correctedReticulocyteIndex } from '../tool/correctedReticulocyteIndex/entry';
32
+ import { estimatedBloodVolumeCalculator } from '../tool/estimatedBloodVolumeCalculator/entry';
32
33
 
33
34
  export const healthCategory: HealthCategoryEntry = {
34
35
  icon: 'mdi:heart-pulse',
35
- tools: [bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, meanArterialPressureCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, targetHeightEstimator, daltonismSimulator, tinnitusReliever, parametricNoiseSynthesizer, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
36
+ tools: [bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, meanArterialPressureCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, estimatedBloodVolumeCalculator, targetHeightEstimator, daltonismSimulator, tinnitusReliever, parametricNoiseSynthesizer, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
36
37
  i18n: {
37
38
  en: () => import('./i18n/en').then((m) => m.content),
38
39
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -25,6 +25,7 @@ export { correctedSodiumHyperglycemiaCalculator } from './tool/correctedSodiumHy
25
25
  export { fractionalExcretionSodiumCalculator } from './tool/fractionalExcretionSodiumCalculator/entry';
26
26
  export { targetHeightEstimator } from './tool/targetHeightEstimator/entry';
27
27
  export { correctedReticulocyteIndex } from './tool/correctedReticulocyteIndex/entry';
28
+ export { estimatedBloodVolumeCalculator } from './tool/estimatedBloodVolumeCalculator/entry';
28
29
  export { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
29
30
  export { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
30
31
  export { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -61,6 +62,7 @@ import { correctedSodiumHyperglycemiaCalculator } from './tool/correctedSodiumHy
61
62
  import { fractionalExcretionSodiumCalculator } from './tool/fractionalExcretionSodiumCalculator/entry';
62
63
  import { targetHeightEstimator } from './tool/targetHeightEstimator/entry';
63
64
  import { correctedReticulocyteIndex } from './tool/correctedReticulocyteIndex/entry';
65
+ import { estimatedBloodVolumeCalculator } from './tool/estimatedBloodVolumeCalculator/entry';
64
66
  import { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
65
67
  import { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
66
68
  import { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -69,4 +71,4 @@ import { tinnitusReliever } from './tool/tinnitusReliever/entry';
69
71
  import { parametricNoiseSynthesizer } from './tool/parametricNoiseSynthesizer/entry';
70
72
  import { ubeCalculator } from './tool/ubeCalculator/entry';
71
73
  import { waterPurifier } from './tool/waterPurifier/entry';
72
- export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, meanArterialPressureCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, targetHeightEstimator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, parametricNoiseSynthesizer, ubeCalculator, waterPurifier];
74
+ export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, meanArterialPressureCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, estimatedBloodVolumeCalculator, targetHeightEstimator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, parametricNoiseSynthesizer, ubeCalculator, waterPurifier];
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from './tool/correctedSodiumHyperglycemiaCalculator';
33
33
  export * from './tool/fractionalExcretionSodiumCalculator';
34
34
  export * from './tool/targetHeightEstimator';
35
35
  export * from './tool/correctedReticulocyteIndex';
36
+ export * from './tool/estimatedBloodVolumeCalculator';
36
37
 
37
38
  export { healthCategory } from './category';
38
39
  export const healthCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
@@ -36,6 +36,6 @@ describe('Locale Completeness Validation', () => {
36
36
  });
37
37
 
38
38
  it('all tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(35);
39
+ expect(ALL_TOOLS.length).toBe(36);
40
40
  });
41
41
  });
@@ -5,7 +5,7 @@ import { healthCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have 33 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(35);
8
+ expect(ALL_TOOLS.length).toBe(36);
9
9
  });
10
10
 
11
11
  it('healthCategory should be defined', () => {
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography } from '@jjlmoya/utils-shared';
3
+ import { estimatedBloodVolumeCalculator } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await estimatedBloodVolumeCalculator.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <Bibliography links={content.bibliography} />}
@@ -0,0 +1,20 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Nadler, Hidalgo, and Bloch: Prediction of blood volume in normal human adults',
6
+ url: 'https://pubmed.ncbi.nlm.nih.gov/21936146/',
7
+ },
8
+ {
9
+ name: 'Lemmens, Bernstein, and Brodsky: Estimating blood volume in obese and nonobese patients',
10
+ url: 'https://pubmed.ncbi.nlm.nih.gov/16925381/',
11
+ },
12
+ {
13
+ name: 'Association of Anaesthetists: Management of major haemorrhage',
14
+ url: 'https://associationofanaesthetists-publications.onlinelibrary.wiley.com/doi/10.1111/anae.14139',
15
+ },
16
+ {
17
+ name: 'Wikipedia: Blood volume',
18
+ url: 'https://en.wikipedia.org/wiki/Blood_volume',
19
+ },
20
+ ];
@@ -0,0 +1,350 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ import type { EstimatedBloodVolumeCalculatorUI } from './ui';
4
+ import './estimated-blood-volume-calculator.css';
5
+
6
+ interface Props {
7
+ locale?: string;
8
+ ui?: Record<string, unknown>;
9
+ }
10
+
11
+ const t = (Astro.props.ui || {}) as EstimatedBloodVolumeCalculatorUI;
12
+ ---
13
+
14
+ <div class="ebv" data-ui={JSON.stringify(t)}>
15
+ <div class="ebv__panel">
16
+ <section class="ebv__controls" aria-label={t.inputsAria}>
17
+ <div class="ebv__unit" role="group" aria-label={t.unitSystemLabel}>
18
+ <button class="ebv__pill ebv__pill--active" type="button" data-unit="metric">{t.unitMetric}</button>
19
+ <button class="ebv__pill" type="button" data-unit="imperial">{t.unitImperial}</button>
20
+ </div>
21
+
22
+ <div class="ebv__selector-stack">
23
+ <div class="ebv__choice-field">
24
+ <span>{t.sexLabel}</span>
25
+ <div class="ebv__choice ebv__choice--sex" role="radiogroup" aria-label={t.sexLabel}>
26
+ <button class="ebv__choice-btn ebv__choice-btn--active" type="button" role="radio" aria-checked="true" data-sex="male">{t.male}</button>
27
+ <button class="ebv__choice-btn" type="button" role="radio" aria-checked="false" data-sex="female">{t.female}</button>
28
+ </div>
29
+ </div>
30
+ <div class="ebv__choice-field">
31
+ <span>{t.buildLabel}</span>
32
+ <div class="ebv__choice ebv__choice--build" role="radiogroup" aria-label={t.buildLabel}>
33
+ <button class="ebv__choice-btn ebv__choice-btn--active" type="button" role="radio" aria-checked="true" data-build="normal">{t.normal}</button>
34
+ <button class="ebv__choice-btn" type="button" role="radio" aria-checked="false" data-build="athletic">{t.athletic}</button>
35
+ <button class="ebv__choice-btn" type="button" role="radio" aria-checked="false" data-build="thin">{t.thin}</button>
36
+ <button class="ebv__choice-btn" type="button" role="radio" aria-checked="false" data-build="obese">{t.obese}</button>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="ebv__row ebv__row--metric">
42
+ <label class="ebv__field">
43
+ <span>{t.height}</span>
44
+ <input id="ebv-height-cm" inputmode="decimal" type="number" min="120" max="230" step="1" value="175" />
45
+ </label>
46
+ <label class="ebv__field">
47
+ <span>{t.weight}</span>
48
+ <input id="ebv-weight-kg" inputmode="decimal" type="number" min="35" max="250" step="0.1" value="80" />
49
+ </label>
50
+ </div>
51
+
52
+ <div class="ebv__row ebv__row--imperial" hidden>
53
+ <label class="ebv__field">
54
+ <span>{t.feet}</span>
55
+ <input id="ebv-height-ft" inputmode="numeric" type="number" min="3" max="7" step="1" value="5" />
56
+ </label>
57
+ <label class="ebv__field">
58
+ <span>{t.inches}</span>
59
+ <input id="ebv-height-in" inputmode="decimal" type="number" min="0" max="11.9" step="0.1" value="9" />
60
+ </label>
61
+ <label class="ebv__field">
62
+ <span>{t.weight}</span>
63
+ <input id="ebv-weight-lb" inputmode="decimal" type="number" min="75" max="550" step="0.1" value="176" />
64
+ </label>
65
+ </div>
66
+
67
+ <div class="ebv__chips">
68
+ <span><Icon name="mdi:calculator-variant-outline" /> <strong id="ebv-bmi">26.1</strong> {t.bmi}</span>
69
+ <span><Icon name="mdi:water-percent" /> <strong id="ebv-factor">70</strong> {t.mlKg}</span>
70
+ </div>
71
+
72
+ <div class="ebv__note">
73
+ <strong>{t.cautionTitle}</strong>
74
+ <p>{t.cautionText}</p>
75
+ </div>
76
+ </section>
77
+
78
+ <section class="ebv__visual" aria-label={t.beakerAria}>
79
+ <div class="ebv__bag">
80
+ <div class="ebv__bag-neck"></div>
81
+ <div class="ebv__bag-body">
82
+ <div class="ebv__liquid" id="ebv-liquid"></div>
83
+ <div class="ebv__shine"></div>
84
+ <div class="ebv__marks">
85
+ <span>{t.scaleEight}</span><span>{t.scaleSix}</span><span>{t.scaleFour}</span><span>{t.scaleTwo}</span>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ <div class="ebv__total">
90
+ <span>{t.fillLabel}</span>
91
+ <strong id="ebv-average">{t.initialAverage}</strong>
92
+ </div>
93
+ </section>
94
+
95
+ <section class="ebv__results" aria-label={t.methodComparison}>
96
+ <div class="ebv__result-card ebv__result-card--primary">
97
+ <span>{t.nadler}</span>
98
+ <strong id="ebv-nadler">{t.initialNadler}</strong>
99
+ <p>{t.nadlerCaption}</p>
100
+ <div class="ebv__bar"><i id="ebv-nadler-bar"></i></div>
101
+ </div>
102
+ <div class="ebv__result-card">
103
+ <span>{t.lbb}</span>
104
+ <strong id="ebv-lbb">{t.initialLbb}</strong>
105
+ <p>{t.lbbCaption}</p>
106
+ <div class="ebv__bar"><i id="ebv-lbb-bar"></i></div>
107
+ </div>
108
+ <div class="ebv__result-card">
109
+ <span>{t.gilcher}</span>
110
+ <strong id="ebv-gilcher">{t.initialGilcher}</strong>
111
+ <p id="ebv-gilcher-caption">{t.gilcherCaption}</p>
112
+ <div class="ebv__bar"><i id="ebv-gilcher-bar"></i></div>
113
+ </div>
114
+ <div class="ebv__method-note">
115
+ <strong>{t.clinicalUseTitle}</strong>
116
+ <p>{t.clinicalUseText}</p>
117
+ <small>{t.methodNote}</small>
118
+ </div>
119
+ </section>
120
+ </div>
121
+ </div>
122
+
123
+ <script>
124
+ type Unit = 'metric' | 'imperial';
125
+ type Sex = 'male' | 'female';
126
+ type Build = 'athletic' | 'normal' | 'thin' | 'obese';
127
+
128
+ interface Elements {
129
+ unitButtons: NodeListOf<HTMLButtonElement>;
130
+ metricRow: HTMLElement;
131
+ imperialRow: HTMLElement;
132
+ sexButtons: NodeListOf<HTMLButtonElement>;
133
+ buildButtons: NodeListOf<HTMLButtonElement>;
134
+ heightCm: HTMLInputElement;
135
+ weightKg: HTMLInputElement;
136
+ heightFt: HTMLInputElement;
137
+ heightIn: HTMLInputElement;
138
+ weightLb: HTMLInputElement;
139
+ bmi: HTMLElement;
140
+ factor: HTMLElement;
141
+ liquid: HTMLElement;
142
+ average: HTMLElement;
143
+ nadler: HTMLElement;
144
+ lbb: HTMLElement;
145
+ gilcher: HTMLElement;
146
+ gilcherCaption: HTMLElement;
147
+ nadlerBar: HTMLElement;
148
+ lbbBar: HTMLElement;
149
+ gilcherBar: HTMLElement;
150
+ }
151
+
152
+ const EBV_STORAGE = 'estimated_blood_volume_state';
153
+ const factors: Record<Sex, Record<Build, number>> = {
154
+ male: { athletic: 75, normal: 70, thin: 65, obese: 60 },
155
+ female: { athletic: 70, normal: 65, thin: 60, obese: 55 },
156
+ };
157
+
158
+ function required<T extends Element>(container: Element, selector: string): T {
159
+ const element = container.querySelector<T>(selector);
160
+ if (!element) throw new Error(selector);
161
+ return element;
162
+ }
163
+
164
+ function getElements(container: Element): Elements | null {
165
+ try {
166
+ return {
167
+ unitButtons: container.querySelectorAll<HTMLButtonElement>('.ebv__pill'),
168
+ metricRow: required<HTMLElement>(container, '.ebv__row--metric'),
169
+ imperialRow: required<HTMLElement>(container, '.ebv__row--imperial'),
170
+ sexButtons: container.querySelectorAll<HTMLButtonElement>('[data-sex]'),
171
+ buildButtons: container.querySelectorAll<HTMLButtonElement>('[data-build]'),
172
+ heightCm: required<HTMLInputElement>(container, '#ebv-height-cm'),
173
+ weightKg: required<HTMLInputElement>(container, '#ebv-weight-kg'),
174
+ heightFt: required<HTMLInputElement>(container, '#ebv-height-ft'),
175
+ heightIn: required<HTMLInputElement>(container, '#ebv-height-in'),
176
+ weightLb: required<HTMLInputElement>(container, '#ebv-weight-lb'),
177
+ bmi: required<HTMLElement>(container, '#ebv-bmi'),
178
+ factor: required<HTMLElement>(container, '#ebv-factor'),
179
+ liquid: required<HTMLElement>(container, '#ebv-liquid'),
180
+ average: required<HTMLElement>(container, '#ebv-average'),
181
+ nadler: required<HTMLElement>(container, '#ebv-nadler'),
182
+ lbb: required<HTMLElement>(container, '#ebv-lbb'),
183
+ gilcher: required<HTMLElement>(container, '#ebv-gilcher'),
184
+ gilcherCaption: required<HTMLElement>(container, '#ebv-gilcher-caption'),
185
+ nadlerBar: required<HTMLElement>(container, '#ebv-nadler-bar'),
186
+ lbbBar: required<HTMLElement>(container, '#ebv-lbb-bar'),
187
+ gilcherBar: required<HTMLElement>(container, '#ebv-gilcher-bar'),
188
+ };
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ function kgToLb(kg: number): number {
195
+ return kg * 2.2046226218;
196
+ }
197
+
198
+ function lbToKg(lb: number): number {
199
+ return lb / 2.2046226218;
200
+ }
201
+
202
+ function cmToImperial(cm: number): { ft: number; inch: number } {
203
+ const total = cm / 2.54;
204
+ const ft = Math.floor(total / 12);
205
+ return { ft, inch: Math.round((total - ft * 12) * 10) / 10 };
206
+ }
207
+
208
+ function imperialToCm(ft: number, inch: number): number {
209
+ return (ft * 12 + inch) * 2.54;
210
+ }
211
+
212
+ function calculate(sex: Sex, build: Build, heightCm: number, weightKg: number) {
213
+ const heightM = heightCm / 100;
214
+ const bmi = weightKg / heightM ** 2;
215
+ const nadler = sex === 'male'
216
+ ? 0.3669 * heightM ** 3 + 0.03219 * weightKg + 0.6041
217
+ : 0.3561 * heightM ** 3 + 0.03308 * weightKg + 0.1833;
218
+ const lbb = weightKg * (70 / Math.sqrt(bmi / 22)) / 1000;
219
+ const factor = factors[sex][build];
220
+ const gilcher = weightKg * factor / 1000;
221
+ return { bmi, nadler, lbb, gilcher, factor, average: (nadler + lbb + gilcher) / 3 };
222
+ }
223
+
224
+ function formatL(value: number, ui: Record<string, string>): string {
225
+ return `${value.toFixed(2)} ${ui.liters}`;
226
+ }
227
+
228
+ function getActiveUnit(elements: Elements): Unit {
229
+ return Array.from(elements.unitButtons).find((button) => button.classList.contains('ebv__pill--active'))?.dataset.unit as Unit || 'metric';
230
+ }
231
+
232
+ function getActiveSex(elements: Elements): Sex {
233
+ return Array.from(elements.sexButtons).find((button) => button.classList.contains('ebv__choice-btn--active'))?.dataset.sex as Sex || 'male';
234
+ }
235
+
236
+ function getActiveBuild(elements: Elements): Build {
237
+ return Array.from(elements.buildButtons).find((button) => button.classList.contains('ebv__choice-btn--active'))?.dataset.build as Build || 'normal';
238
+ }
239
+
240
+ function getPatientMetrics(elements: Elements, unit: Unit): { heightCm: number; weightKg: number } {
241
+ if (unit === 'metric') {
242
+ return { heightCm: Number(elements.heightCm.value), weightKg: Number(elements.weightKg.value) };
243
+ }
244
+
245
+ return {
246
+ heightCm: imperialToCm(Number(elements.heightFt.value), Number(elements.heightIn.value)),
247
+ weightKg: lbToKg(Number(elements.weightLb.value)),
248
+ };
249
+ }
250
+
251
+ function render(elements: Elements, ui: Record<string, string>) {
252
+ const unit = getActiveUnit(elements);
253
+ const { heightCm, weightKg } = getPatientMetrics(elements, unit);
254
+ const sex = getActiveSex(elements);
255
+ const build = getActiveBuild(elements);
256
+ const result = calculate(sex, build, heightCm, weightKg);
257
+ const max = Math.max(8, result.nadler, result.lbb, result.gilcher);
258
+
259
+ elements.bmi.textContent = result.bmi.toFixed(1);
260
+ elements.factor.textContent = String(result.factor);
261
+ elements.average.textContent = formatL(result.average, ui);
262
+ elements.nadler.textContent = formatL(result.nadler, ui);
263
+ elements.lbb.textContent = formatL(result.lbb, ui);
264
+ elements.gilcher.textContent = formatL(result.gilcher, ui);
265
+ elements.gilcherCaption.textContent = `${ui.gilcherCaption} ${result.factor} ${ui.mlKg}.`;
266
+ elements.liquid.style.height = `${Math.min(100, (result.average / 8) * 100)}%`;
267
+ elements.nadlerBar.style.width = `${Math.min(100, (result.nadler / max) * 100)}%`;
268
+ elements.lbbBar.style.width = `${Math.min(100, (result.lbb / max) * 100)}%`;
269
+ elements.gilcherBar.style.width = `${Math.min(100, (result.gilcher / max) * 100)}%`;
270
+
271
+ try {
272
+ window.localStorage.setItem(EBV_STORAGE, JSON.stringify({ unit, sex, build, heightCm, weightKg }));
273
+ } catch {
274
+ }
275
+ }
276
+
277
+ function setUnit(elements: Elements, nextUnit: Unit) {
278
+ const heightCm = elements.metricRow.hidden
279
+ ? imperialToCm(Number(elements.heightFt.value), Number(elements.heightIn.value))
280
+ : Number(elements.heightCm.value);
281
+ const weightKg = elements.metricRow.hidden ? lbToKg(Number(elements.weightLb.value)) : Number(elements.weightKg.value);
282
+ elements.unitButtons.forEach((button) => button.classList.toggle('ebv__pill--active', button.dataset.unit === nextUnit));
283
+ elements.metricRow.hidden = nextUnit !== 'metric';
284
+ elements.imperialRow.hidden = nextUnit !== 'imperial';
285
+
286
+ if (nextUnit === 'metric') {
287
+ elements.heightCm.value = String(Math.round(heightCm));
288
+ elements.weightKg.value = String(Math.round(weightKg * 10) / 10);
289
+ } else {
290
+ const imperialHeight = cmToImperial(heightCm);
291
+ elements.heightFt.value = String(imperialHeight.ft);
292
+ elements.heightIn.value = String(imperialHeight.inch);
293
+ elements.weightLb.value = String(Math.round(kgToLb(weightKg) * 10) / 10);
294
+ }
295
+ }
296
+
297
+ function restore(elements: Elements) {
298
+ try {
299
+ const saved = JSON.parse(window.localStorage.getItem(EBV_STORAGE) || '{}') as Partial<{ unit: Unit; sex: Sex; build: Build; heightCm: number; weightKg: number }>;
300
+ if (saved.sex) setChoice(elements.sexButtons, saved.sex, 'sex');
301
+ if (saved.build) setChoice(elements.buildButtons, saved.build, 'build');
302
+ if (saved.heightCm) elements.heightCm.value = String(Math.round(saved.heightCm));
303
+ if (saved.weightKg) elements.weightKg.value = String(Math.round(saved.weightKg * 10) / 10);
304
+ setUnit(elements, saved.unit === 'imperial' ? 'imperial' : 'metric');
305
+ } catch {
306
+ setUnit(elements, 'metric');
307
+ }
308
+ }
309
+
310
+ function setChoice(buttons: NodeListOf<HTMLButtonElement>, value: string, key: 'sex' | 'build') {
311
+ buttons.forEach((button) => {
312
+ const active = button.dataset[key] === value;
313
+ button.classList.toggle('ebv__choice-btn--active', active);
314
+ button.setAttribute('aria-checked', active ? 'true' : 'false');
315
+ });
316
+ }
317
+
318
+ function initEBV(container: Element) {
319
+ const ui = JSON.parse((container as HTMLElement).dataset.ui || '{}') as Record<string, string>;
320
+ const elements = getElements(container);
321
+ if (!elements) return;
322
+ restore(elements);
323
+ const update = () => render(elements, ui);
324
+ elements.unitButtons.forEach((button) => {
325
+ button.addEventListener('click', () => {
326
+ setUnit(elements, button.dataset.unit === 'imperial' ? 'imperial' : 'metric');
327
+ update();
328
+ });
329
+ });
330
+ elements.sexButtons.forEach((button) => {
331
+ button.addEventListener('click', () => {
332
+ setChoice(elements.sexButtons, button.dataset.sex || 'male', 'sex');
333
+ update();
334
+ });
335
+ });
336
+ elements.buildButtons.forEach((button) => {
337
+ button.addEventListener('click', () => {
338
+ setChoice(elements.buildButtons, button.dataset.build || 'normal', 'build');
339
+ update();
340
+ });
341
+ });
342
+ [elements.heightCm, elements.weightKg, elements.heightFt, elements.heightIn, elements.weightLb].forEach((element) => {
343
+ element.addEventListener('input', update);
344
+ element.addEventListener('change', update);
345
+ });
346
+ update();
347
+ }
348
+
349
+ document.querySelectorAll('.ebv').forEach(initEBV);
350
+ </script>
@@ -0,0 +1,27 @@
1
+ import type { HealthToolEntry } from '../../types';
2
+ import type { EstimatedBloodVolumeCalculatorUI } from './ui';
3
+
4
+ export const estimatedBloodVolumeCalculator: HealthToolEntry<EstimatedBloodVolumeCalculatorUI> = {
5
+ id: 'estimatedBloodVolumeCalculator',
6
+ icons: {
7
+ bg: 'mdi:blood-bag',
8
+ fg: 'mdi:beaker-outline',
9
+ },
10
+ i18n: {
11
+ de: () => import('./i18n/de').then((m) => m.content),
12
+ en: () => import('./i18n/en').then((m) => m.content),
13
+ es: () => import('./i18n/es').then((m) => m.content),
14
+ fr: () => import('./i18n/fr').then((m) => m.content),
15
+ id: () => import('./i18n/id').then((m) => m.content),
16
+ it: () => import('./i18n/it').then((m) => m.content),
17
+ ja: () => import('./i18n/ja').then((m) => m.content),
18
+ ko: () => import('./i18n/ko').then((m) => m.content),
19
+ nl: () => import('./i18n/nl').then((m) => m.content),
20
+ pl: () => import('./i18n/pl').then((m) => m.content),
21
+ pt: () => import('./i18n/pt').then((m) => m.content),
22
+ ru: () => import('./i18n/ru').then((m) => m.content),
23
+ sv: () => import('./i18n/sv').then((m) => m.content),
24
+ tr: () => import('./i18n/tr').then((m) => m.content),
25
+ zh: () => import('./i18n/zh').then((m) => m.content),
26
+ },
27
+ };