@jjlmoya/utils-health 1.32.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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +2 -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/tool/meanArterialPressureCalculator/bibliography.astro +14 -0
  32. package/src/tool/meanArterialPressureCalculator/bibliography.ts +24 -0
  33. package/src/tool/meanArterialPressureCalculator/component.astro +224 -0
  34. package/src/tool/meanArterialPressureCalculator/entry.ts +27 -0
  35. package/src/tool/meanArterialPressureCalculator/i18n/de.ts +330 -0
  36. package/src/tool/meanArterialPressureCalculator/i18n/en.ts +330 -0
  37. package/src/tool/meanArterialPressureCalculator/i18n/es.ts +330 -0
  38. package/src/tool/meanArterialPressureCalculator/i18n/fr.ts +330 -0
  39. package/src/tool/meanArterialPressureCalculator/i18n/id.ts +330 -0
  40. package/src/tool/meanArterialPressureCalculator/i18n/it.ts +330 -0
  41. package/src/tool/meanArterialPressureCalculator/i18n/ja.ts +330 -0
  42. package/src/tool/meanArterialPressureCalculator/i18n/ko.ts +330 -0
  43. package/src/tool/meanArterialPressureCalculator/i18n/nl.ts +330 -0
  44. package/src/tool/meanArterialPressureCalculator/i18n/pl.ts +330 -0
  45. package/src/tool/meanArterialPressureCalculator/i18n/pt.ts +330 -0
  46. package/src/tool/meanArterialPressureCalculator/i18n/ru.ts +330 -0
  47. package/src/tool/meanArterialPressureCalculator/i18n/sv.ts +330 -0
  48. package/src/tool/meanArterialPressureCalculator/i18n/tr.ts +330 -0
  49. package/src/tool/meanArterialPressureCalculator/i18n/zh.ts +330 -0
  50. package/src/tool/meanArterialPressureCalculator/index.ts +12 -0
  51. package/src/tool/meanArterialPressureCalculator/logic.ts +44 -0
  52. package/src/tool/meanArterialPressureCalculator/mean-arterial-pressure-calculator.css +437 -0
  53. package/src/tool/meanArterialPressureCalculator/seo.astro +15 -0
  54. package/src/tool/meanArterialPressureCalculator/ui.ts +36 -0
  55. package/src/tools.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-health",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -24,14 +24,16 @@ import { leanBodyMassCalculator } from '../tool/leanBodyMassCalculator/entry';
24
24
  import { metabolicAgeCalculator } from '../tool/metabolicAgeCalculator/entry';
25
25
  import { bunCreatinineCalculator } from '../tool/bunCreatinineCalculator/entry';
26
26
  import { serumOsmolalityCalculator } from '../tool/serumOsmolalityCalculator/entry';
27
+ import { meanArterialPressureCalculator } from '../tool/meanArterialPressureCalculator/entry';
27
28
  import { correctedSodiumHyperglycemiaCalculator } from '../tool/correctedSodiumHyperglycemiaCalculator/entry';
28
29
  import { fractionalExcretionSodiumCalculator } from '../tool/fractionalExcretionSodiumCalculator/entry';
29
30
  import { targetHeightEstimator } from '../tool/targetHeightEstimator/entry';
30
31
  import { correctedReticulocyteIndex } from '../tool/correctedReticulocyteIndex/entry';
32
+ import { estimatedBloodVolumeCalculator } from '../tool/estimatedBloodVolumeCalculator/entry';
31
33
 
32
34
  export const healthCategory: HealthCategoryEntry = {
33
35
  icon: 'mdi:heart-pulse',
34
- tools: [bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, 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],
35
37
  i18n: {
36
38
  en: () => import('./i18n/en').then((m) => m.content),
37
39
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -20,10 +20,12 @@ export { macronutrientCalculator } from './tool/macronutrientCalculator/entry';
20
20
  export { tdeeCalculator } from './tool/tdeeCalculator/entry';
21
21
  export { bunCreatinineCalculator } from './tool/bunCreatinineCalculator/entry';
22
22
  export { serumOsmolalityCalculator } from './tool/serumOsmolalityCalculator/entry';
23
+ export { meanArterialPressureCalculator } from './tool/meanArterialPressureCalculator/entry';
23
24
  export { correctedSodiumHyperglycemiaCalculator } from './tool/correctedSodiumHyperglycemiaCalculator/entry';
24
25
  export { fractionalExcretionSodiumCalculator } from './tool/fractionalExcretionSodiumCalculator/entry';
25
26
  export { targetHeightEstimator } from './tool/targetHeightEstimator/entry';
26
27
  export { correctedReticulocyteIndex } from './tool/correctedReticulocyteIndex/entry';
28
+ export { estimatedBloodVolumeCalculator } from './tool/estimatedBloodVolumeCalculator/entry';
27
29
  export { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
28
30
  export { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
29
31
  export { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -55,10 +57,12 @@ import { macronutrientCalculator } from './tool/macronutrientCalculator/entry';
55
57
  import { tdeeCalculator } from './tool/tdeeCalculator/entry';
56
58
  import { bunCreatinineCalculator } from './tool/bunCreatinineCalculator/entry';
57
59
  import { serumOsmolalityCalculator } from './tool/serumOsmolalityCalculator/entry';
60
+ import { meanArterialPressureCalculator } from './tool/meanArterialPressureCalculator/entry';
58
61
  import { correctedSodiumHyperglycemiaCalculator } from './tool/correctedSodiumHyperglycemiaCalculator/entry';
59
62
  import { fractionalExcretionSodiumCalculator } from './tool/fractionalExcretionSodiumCalculator/entry';
60
63
  import { targetHeightEstimator } from './tool/targetHeightEstimator/entry';
61
64
  import { correctedReticulocyteIndex } from './tool/correctedReticulocyteIndex/entry';
65
+ import { estimatedBloodVolumeCalculator } from './tool/estimatedBloodVolumeCalculator/entry';
62
66
  import { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
63
67
  import { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
64
68
  import { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -67,4 +71,4 @@ import { tinnitusReliever } from './tool/tinnitusReliever/entry';
67
71
  import { parametricNoiseSynthesizer } from './tool/parametricNoiseSynthesizer/entry';
68
72
  import { ubeCalculator } from './tool/ubeCalculator/entry';
69
73
  import { waterPurifier } from './tool/waterPurifier/entry';
70
- export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, 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
@@ -28,10 +28,12 @@ export * from './tool/macronutrientCalculator';
28
28
  export * from './tool/tdeeCalculator';
29
29
  export * from './tool/bunCreatinineCalculator';
30
30
  export * from './tool/serumOsmolalityCalculator';
31
+ export * from './tool/meanArterialPressureCalculator';
31
32
  export * from './tool/correctedSodiumHyperglycemiaCalculator';
32
33
  export * from './tool/fractionalExcretionSodiumCalculator';
33
34
  export * from './tool/targetHeightEstimator';
34
35
  export * from './tool/correctedReticulocyteIndex';
36
+ export * from './tool/estimatedBloodVolumeCalculator';
35
37
 
36
38
  export { healthCategory } from './category';
37
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(34);
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(34);
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
+ };