@jjlmoya/utils-health 1.25.0 → 1.27.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 +2 -2
  7. package/src/tool/correctedReticulocyteIndex/bibliography.astro +14 -0
  8. package/src/tool/correctedReticulocyteIndex/bibliography.ts +20 -0
  9. package/src/tool/correctedReticulocyteIndex/component.astro +262 -0
  10. package/src/tool/correctedReticulocyteIndex/corrected-reticulocyte-index-calculator.css +419 -0
  11. package/src/tool/correctedReticulocyteIndex/entry.ts +27 -0
  12. package/src/tool/correctedReticulocyteIndex/i18n/de.ts +349 -0
  13. package/src/tool/correctedReticulocyteIndex/i18n/en.ts +349 -0
  14. package/src/tool/correctedReticulocyteIndex/i18n/es.ts +349 -0
  15. package/src/tool/correctedReticulocyteIndex/i18n/fr.ts +349 -0
  16. package/src/tool/correctedReticulocyteIndex/i18n/id.ts +349 -0
  17. package/src/tool/correctedReticulocyteIndex/i18n/it.ts +349 -0
  18. package/src/tool/correctedReticulocyteIndex/i18n/ja.ts +349 -0
  19. package/src/tool/correctedReticulocyteIndex/i18n/ko.ts +349 -0
  20. package/src/tool/correctedReticulocyteIndex/i18n/nl.ts +349 -0
  21. package/src/tool/correctedReticulocyteIndex/i18n/pl.ts +349 -0
  22. package/src/tool/correctedReticulocyteIndex/i18n/pt.ts +349 -0
  23. package/src/tool/correctedReticulocyteIndex/i18n/ru.ts +349 -0
  24. package/src/tool/correctedReticulocyteIndex/i18n/sv.ts +349 -0
  25. package/src/tool/correctedReticulocyteIndex/i18n/tr.ts +349 -0
  26. package/src/tool/correctedReticulocyteIndex/i18n/zh.ts +349 -0
  27. package/src/tool/correctedReticulocyteIndex/index.ts +12 -0
  28. package/src/tool/correctedReticulocyteIndex/logic.ts +45 -0
  29. package/src/tool/correctedReticulocyteIndex/seo.astro +15 -0
  30. package/src/tool/correctedReticulocyteIndex/ui.ts +38 -0
  31. package/src/tool/targetHeightEstimator/bibliography.astro +14 -0
  32. package/src/tool/targetHeightEstimator/bibliography.ts +28 -0
  33. package/src/tool/targetHeightEstimator/component.astro +335 -0
  34. package/src/tool/targetHeightEstimator/entry.ts +27 -0
  35. package/src/tool/targetHeightEstimator/i18n/de.ts +330 -0
  36. package/src/tool/targetHeightEstimator/i18n/en.ts +330 -0
  37. package/src/tool/targetHeightEstimator/i18n/es.ts +330 -0
  38. package/src/tool/targetHeightEstimator/i18n/fr.ts +330 -0
  39. package/src/tool/targetHeightEstimator/i18n/id.ts +330 -0
  40. package/src/tool/targetHeightEstimator/i18n/it.ts +330 -0
  41. package/src/tool/targetHeightEstimator/i18n/ja.ts +330 -0
  42. package/src/tool/targetHeightEstimator/i18n/ko.ts +330 -0
  43. package/src/tool/targetHeightEstimator/i18n/nl.ts +330 -0
  44. package/src/tool/targetHeightEstimator/i18n/pl.ts +330 -0
  45. package/src/tool/targetHeightEstimator/i18n/pt.ts +330 -0
  46. package/src/tool/targetHeightEstimator/i18n/ru.ts +330 -0
  47. package/src/tool/targetHeightEstimator/i18n/sv.ts +330 -0
  48. package/src/tool/targetHeightEstimator/i18n/tr.ts +330 -0
  49. package/src/tool/targetHeightEstimator/i18n/zh.ts +330 -0
  50. package/src/tool/targetHeightEstimator/index.ts +12 -0
  51. package/src/tool/targetHeightEstimator/logic.ts +27 -0
  52. package/src/tool/targetHeightEstimator/seo.astro +15 -0
  53. package/src/tool/targetHeightEstimator/target-height-estimator.css +407 -0
  54. package/src/tool/targetHeightEstimator/ui.ts +31 -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.25.0",
3
+ "version": "1.27.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -21,10 +21,12 @@ import { epworthSleepinessScale } from '../tool/epworthSleepinessScale/entry';
21
21
  import { leanBodyMassCalculator } from '../tool/leanBodyMassCalculator/entry';
22
22
  import { metabolicAgeCalculator } from '../tool/metabolicAgeCalculator/entry';
23
23
  import { bunCreatinineCalculator } from '../tool/bunCreatinineCalculator/entry';
24
+ import { targetHeightEstimator } from '../tool/targetHeightEstimator/entry';
25
+ import { correctedReticulocyteIndex } from '../tool/correctedReticulocyteIndex/entry';
24
26
 
25
27
  export const healthCategory: HealthCategoryEntry = {
26
28
  icon: 'mdi:heart-pulse',
27
- tools: [bmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, daltonismSimulator, tinnitusReliever, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
29
+ tools: [bmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, correctedReticulocyteIndex, targetHeightEstimator, daltonismSimulator, tinnitusReliever, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
28
30
  i18n: {
29
31
  en: () => import('./i18n/en').then((m) => m.content),
30
32
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -18,6 +18,8 @@ export { metabolicAgeCalculator } from './tool/metabolicAgeCalculator/entry';
18
18
  export { macronutrientCalculator } from './tool/macronutrientCalculator/entry';
19
19
  export { tdeeCalculator } from './tool/tdeeCalculator/entry';
20
20
  export { bunCreatinineCalculator } from './tool/bunCreatinineCalculator/entry';
21
+ export { targetHeightEstimator } from './tool/targetHeightEstimator/entry';
22
+ export { correctedReticulocyteIndex } from './tool/correctedReticulocyteIndex/entry';
21
23
  export { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
22
24
  export { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
23
25
  export { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -46,6 +48,8 @@ import { metabolicAgeCalculator } from './tool/metabolicAgeCalculator/entry';
46
48
  import { macronutrientCalculator } from './tool/macronutrientCalculator/entry';
47
49
  import { tdeeCalculator } from './tool/tdeeCalculator/entry';
48
50
  import { bunCreatinineCalculator } from './tool/bunCreatinineCalculator/entry';
51
+ import { targetHeightEstimator } from './tool/targetHeightEstimator/entry';
52
+ import { correctedReticulocyteIndex } from './tool/correctedReticulocyteIndex/entry';
49
53
  import { pelliRobsonTest } from './tool/pelliRobsonTest/entry';
50
54
  import { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
51
55
  import { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
@@ -53,4 +57,4 @@ import { screenDecompressionTime } from './tool/screenDecompressionTime/entry';
53
57
  import { tinnitusReliever } from './tool/tinnitusReliever/entry';
54
58
  import { ubeCalculator } from './tool/ubeCalculator/entry';
55
59
  import { waterPurifier } from './tool/waterPurifier/entry';
56
- export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, ubeCalculator, waterPurifier];
60
+ export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, correctedReticulocyteIndex, targetHeightEstimator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, ubeCalculator, waterPurifier];
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ export * from './tool/bodyFatPercentageCalculator';
25
25
  export * from './tool/macronutrientCalculator';
26
26
  export * from './tool/tdeeCalculator';
27
27
  export * from './tool/bunCreatinineCalculator';
28
+ export * from './tool/targetHeightEstimator';
29
+ export * from './tool/correctedReticulocyteIndex';
28
30
 
29
31
  export { healthCategory } from './category';
30
32
  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(27);
39
+ expect(ALL_TOOLS.length).toBe(29);
40
40
  });
41
41
  });
@@ -4,8 +4,8 @@ import { healthCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 27 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(27);
7
+ it('should have 29 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(29);
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 { correctedReticulocyteIndex } 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 correctedReticulocyteIndex.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: 'Hillman RS, Finch CA: Red Cell Manual',
6
+ url: 'https://openlibrary.org/books/OL5686128M/Red_cell_manual',
7
+ },
8
+ {
9
+ name: 'Panzassy AJ: Reticulocyte production index',
10
+ url: 'https://pubmed.ncbi.nlm.nih.gov/4061477/',
11
+ },
12
+ {
13
+ name: 'MedlinePlus / NIH: Reticulocyte count test',
14
+ url: 'https://medlineplus.gov/lab-tests/reticulocyte-count/',
15
+ },
16
+ {
17
+ name: 'Wikipedia: Reticulocyte production index',
18
+ url: 'https://en.wikipedia.org/wiki/Reticulocyte_production_index',
19
+ },
20
+ ];
@@ -0,0 +1,262 @@
1
+ ---
2
+ import './corrected-reticulocyte-index-calculator.css';
3
+ import type { CorrectedReticulocyteIndexUI } from './ui';
4
+
5
+ interface Props {
6
+ locale?: string;
7
+ ui?: Record<string, unknown>;
8
+ }
9
+
10
+ const t = (Astro.props.ui || {}) as CorrectedReticulocyteIndexUI;
11
+ ---
12
+
13
+ <div class="rpi" data-ui={JSON.stringify(t)}>
14
+ <div class="rpi__warning" role="note" aria-label={t.disclaimerTitle}>
15
+ <svg class="rpi__warning-icon" viewBox="0 0 24 24" aria-hidden="true">
16
+ <path d="M12 2L2.6 20.5h18.8L12 2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
17
+ <path d="M12 8v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
18
+ <circle cx="12" cy="17.2" r="1.2" fill="currentColor" />
19
+ </svg>
20
+ <div>
21
+ <strong>{t.disclaimerTitle}</strong>
22
+ <span>{t.disclaimerBody}</span>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="rpi__report">
27
+ <div class="rpi__inputs">
28
+ <label class="rpi__field">
29
+ <span>{t.observedLabel}</span>
30
+ <input id="rpi-observed" class="rpi__input" type="number" min="0" max="30" step="0.1" value="7" placeholder={t.observedPlaceholder} aria-label={t.observedLabel} />
31
+ </label>
32
+
33
+ <label class="rpi__field">
34
+ <span>{t.patientHctLabel}</span>
35
+ <input id="rpi-patient-hct" class="rpi__input" type="number" min="5" max="65" step="0.1" value="30" placeholder={t.patientHctPlaceholder} aria-label={t.patientHctLabel} />
36
+ </label>
37
+
38
+ <label class="rpi__field rpi__field--reference">
39
+ <span>{t.referenceHctLabel}</span>
40
+ <input id="rpi-reference-hct" class="rpi__input" type="number" min="20" max="60" step="0.1" value="45" placeholder={t.referenceHctPlaceholder} aria-label={t.referenceHctLabel} />
41
+ <div class="rpi__presets" role="group" aria-label={t.referenceHctLabel}>
42
+ <button type="button" class="rpi__preset rpi__preset--active" data-reference="45">{t.referenceMale}</button>
43
+ <button type="button" class="rpi__preset" data-reference="40">{t.referenceFemale}</button>
44
+ <button type="button" class="rpi__preset" data-reference="custom">{t.referenceCustom}</button>
45
+ </div>
46
+ </label>
47
+ </div>
48
+
49
+ <div class="rpi__result" data-band="adequate">
50
+ <div class="rpi__sphere" aria-label={t.rpiSphereLabel} role="img">
51
+ <svg viewBox="0 0 220 220" aria-hidden="true">
52
+ <defs>
53
+ <radialGradient id="rpiSphereGlow" cx="42%" cy="32%" r="70%">
54
+ <stop offset="0" stop-color="#fff" stop-opacity="0.95" />
55
+ <stop offset="0.26" stop-color="#fca5a5" stop-opacity="0.72" />
56
+ <stop offset="1" stop-color="#7f1d1d" stop-opacity="0.94" />
57
+ </radialGradient>
58
+ </defs>
59
+ <circle class="rpi__sphere-orbit" cx="110" cy="110" r="92" />
60
+ <circle class="rpi__sphere-core" cx="110" cy="110" r="76" />
61
+ <path id="rpi-sphere-fill" class="rpi__sphere-fill" d="M42 141 C75 118 139 118 178 141 L178 186 L42 186 Z" />
62
+ <circle class="rpi__sphere-shine" cx="82" cy="72" r="19" />
63
+ </svg>
64
+ <div class="rpi__sphere-value">
65
+ <span>{t.rpiLabel}</span>
66
+ <strong id="rpi-value">3.11</strong>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="rpi__readout">
71
+ <span class="rpi__badge" id="rpi-badge">{t.adequateBadge}</span>
72
+ <strong id="rpi-band">{t.adequateLabel}</strong>
73
+ <p id="rpi-description">{t.adequateDescription}</p>
74
+ </div>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="rpi__metrics">
79
+ <div class="rpi__metric">
80
+ <span>{t.correctedLabel}</span>
81
+ <strong id="rpi-corrected">4.67%</strong>
82
+ </div>
83
+ <div class="rpi__metric">
84
+ <span>{t.maturationFactorLabel}</span>
85
+ <strong id="rpi-factor">1.5</strong>
86
+ </div>
87
+ <div class="rpi__metric">
88
+ <span>{t.rpiLabel}</span>
89
+ <strong id="rpi-small">3.11</strong>
90
+ </div>
91
+ </div>
92
+
93
+ <div class="rpi__formula">
94
+ <div class="rpi__formula-card">
95
+ <span class="rpi__formula-mark" aria-hidden="true"></span>
96
+ <div class="rpi__formula-copy">
97
+ <strong>{t.formulaCorrectedTitle}</strong>
98
+ <span>{t.formulaCorrectedText}</span>
99
+ </div>
100
+ </div>
101
+ <div class="rpi__formula-card">
102
+ <span class="rpi__formula-mark" aria-hidden="true"></span>
103
+ <div class="rpi__formula-copy">
104
+ <strong>{t.formulaRpiTitle}</strong>
105
+ <span>{t.formulaRpiText}</span>
106
+ </div>
107
+ </div>
108
+ <div class="rpi__factor-card">
109
+ <div class="rpi__formula-copy">
110
+ <strong>{t.factorTitle}</strong>
111
+ </div>
112
+ <div class="rpi__factor-grid">
113
+ <span>{t.factorRule1}</span>
114
+ <span>{t.factorRule15}</span>
115
+ <span>{t.factorRule2}</span>
116
+ <span>{t.factorRule25}</span>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ <script>
123
+ type Band = 'insufficient' | 'borderline' | 'adequate';
124
+
125
+ interface Elements {
126
+ root: HTMLElement;
127
+ observed: HTMLInputElement;
128
+ patientHct: HTMLInputElement;
129
+ referenceHct: HTMLInputElement;
130
+ presets: NodeListOf<HTMLButtonElement>;
131
+ result: HTMLElement;
132
+ rpiValue: HTMLElement;
133
+ rpiSmall: HTMLElement;
134
+ corrected: HTMLElement;
135
+ factor: HTMLElement;
136
+ badge: HTMLElement;
137
+ band: HTMLElement;
138
+ description: HTMLElement;
139
+ sphereFill: SVGPathElement;
140
+ }
141
+
142
+ function required<T extends Element>(root: Element, selector: string): T {
143
+ const element = root.querySelector<T>(selector);
144
+ if (!element) throw new Error(selector);
145
+ return element;
146
+ }
147
+
148
+ function getElements(root: Element): Elements | null {
149
+ try {
150
+ return {
151
+ root: root as HTMLElement,
152
+ observed: required<HTMLInputElement>(root, '#rpi-observed'),
153
+ patientHct: required<HTMLInputElement>(root, '#rpi-patient-hct'),
154
+ referenceHct: required<HTMLInputElement>(root, '#rpi-reference-hct'),
155
+ presets: root.querySelectorAll<HTMLButtonElement>('.rpi__preset'),
156
+ result: required<HTMLElement>(root, '.rpi__result'),
157
+ rpiValue: required<HTMLElement>(root, '#rpi-value'),
158
+ rpiSmall: required<HTMLElement>(root, '#rpi-small'),
159
+ corrected: required<HTMLElement>(root, '#rpi-corrected'),
160
+ factor: required<HTMLElement>(root, '#rpi-factor'),
161
+ badge: required<HTMLElement>(root, '#rpi-badge'),
162
+ band: required<HTMLElement>(root, '#rpi-band'),
163
+ description: required<HTMLElement>(root, '#rpi-description'),
164
+ sphereFill: required<SVGPathElement>(root, '#rpi-sphere-fill'),
165
+ };
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ function factorForHct(hct: number): number {
172
+ if (hct >= 35) return 1;
173
+ if (hct >= 25) return 1.5;
174
+ if (hct >= 15) return 2;
175
+ return 2.5;
176
+ }
177
+
178
+ function classifyRpi(rpi: number): Band {
179
+ if (rpi < 2) return 'insufficient';
180
+ if (rpi >= 3) return 'adequate';
181
+ return 'borderline';
182
+ }
183
+
184
+ function round2(value: number): number {
185
+ return Math.round(value * 100) / 100;
186
+ }
187
+
188
+ function formatPercent(value: number, ui: Record<string, string>): string {
189
+ return `${round2(value).toFixed(2)}${ui.percentUnit}`;
190
+ }
191
+
192
+ function renderEmpty(elements: Elements, ui: Record<string, string>) {
193
+ elements.corrected.textContent = ui.emptyValue;
194
+ elements.factor.textContent = ui.emptyValue;
195
+ elements.rpiValue.textContent = ui.emptyValue;
196
+ elements.rpiSmall.textContent = ui.emptyValue;
197
+ }
198
+
199
+ function render(elements: Elements, ui: Record<string, string>) {
200
+ const observed = Number(elements.observed.value);
201
+ const patientHct = Number(elements.patientHct.value);
202
+ const referenceHct = Number(elements.referenceHct.value);
203
+ const hasValues = observed > 0 && patientHct > 0 && referenceHct > 0;
204
+
205
+ if (!hasValues) {
206
+ renderEmpty(elements, ui);
207
+ return;
208
+ }
209
+
210
+ const corrected = observed * (patientHct / referenceHct);
211
+ const factor = factorForHct(patientHct);
212
+ const rpi = corrected / factor;
213
+ const band = classifyRpi(rpi);
214
+ const bandCopy: Record<Band, { label: string; badge: string; description: string }> = {
215
+ insufficient: { label: ui.insufficientLabel, badge: ui.insufficientBadge, description: ui.insufficientDescription },
216
+ borderline: { label: ui.borderlineLabel, badge: ui.borderlineBadge, description: ui.borderlineDescription },
217
+ adequate: { label: ui.adequateLabel, badge: ui.adequateBadge, description: ui.adequateDescription },
218
+ };
219
+
220
+ elements.result.dataset.band = band;
221
+ elements.corrected.textContent = formatPercent(corrected, ui);
222
+ elements.factor.textContent = factor.toFixed(1);
223
+ elements.rpiValue.textContent = round2(rpi).toFixed(2);
224
+ elements.rpiSmall.textContent = round2(rpi).toFixed(2);
225
+ elements.badge.textContent = bandCopy[band].badge;
226
+ elements.band.textContent = bandCopy[band].label;
227
+ elements.description.textContent = bandCopy[band].description;
228
+
229
+ const fillLevel = Math.max(0.14, Math.min(0.86, rpi / 5));
230
+ const top = 186 - fillLevel * 124;
231
+ elements.sphereFill.setAttribute('d', `M42 ${top} C76 ${top - 14} 142 ${top - 14} 178 ${top} L178 186 L42 186 Z`);
232
+ }
233
+
234
+ function initRpi(root: Element) {
235
+ const ui = JSON.parse((root as HTMLElement).dataset.ui || '{}') as Record<string, string>;
236
+ const elements = getElements(root);
237
+ if (!elements) return;
238
+ const update = () => render(elements, ui);
239
+
240
+ elements.presets.forEach((button) => {
241
+ button.addEventListener('click', () => {
242
+ const reference = button.dataset.reference;
243
+ if (reference !== 'custom' && reference) elements.referenceHct.value = reference;
244
+ elements.presets.forEach((item) => item.classList.toggle('rpi__preset--active', item === button));
245
+ update();
246
+ });
247
+ });
248
+
249
+ [elements.observed, elements.patientHct, elements.referenceHct].forEach((input) => {
250
+ input.addEventListener('input', () => {
251
+ if (input === elements.referenceHct) {
252
+ elements.presets.forEach((item) => item.classList.toggle('rpi__preset--active', item.dataset.reference === 'custom'));
253
+ }
254
+ update();
255
+ });
256
+ });
257
+
258
+ update();
259
+ }
260
+
261
+ document.querySelectorAll('.rpi').forEach(initRpi);
262
+ </script>