@jjlmoya/utils-health 1.21.0 → 1.22.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 +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/waistHeightRatioCalculator/bibliography.astro +14 -0
  8. package/src/tool/waistHeightRatioCalculator/bibliography.ts +16 -0
  9. package/src/tool/waistHeightRatioCalculator/component.astro +300 -0
  10. package/src/tool/waistHeightRatioCalculator/entry.ts +27 -0
  11. package/src/tool/waistHeightRatioCalculator/i18n/de.ts +343 -0
  12. package/src/tool/waistHeightRatioCalculator/i18n/en.ts +343 -0
  13. package/src/tool/waistHeightRatioCalculator/i18n/es.ts +343 -0
  14. package/src/tool/waistHeightRatioCalculator/i18n/fr.ts +343 -0
  15. package/src/tool/waistHeightRatioCalculator/i18n/id.ts +343 -0
  16. package/src/tool/waistHeightRatioCalculator/i18n/it.ts +343 -0
  17. package/src/tool/waistHeightRatioCalculator/i18n/ja.ts +343 -0
  18. package/src/tool/waistHeightRatioCalculator/i18n/ko.ts +343 -0
  19. package/src/tool/waistHeightRatioCalculator/i18n/nl.ts +343 -0
  20. package/src/tool/waistHeightRatioCalculator/i18n/pl.ts +343 -0
  21. package/src/tool/waistHeightRatioCalculator/i18n/pt.ts +343 -0
  22. package/src/tool/waistHeightRatioCalculator/i18n/ru.ts +343 -0
  23. package/src/tool/waistHeightRatioCalculator/i18n/sv.ts +343 -0
  24. package/src/tool/waistHeightRatioCalculator/i18n/tr.ts +343 -0
  25. package/src/tool/waistHeightRatioCalculator/i18n/zh.ts +343 -0
  26. package/src/tool/waistHeightRatioCalculator/index.ts +12 -0
  27. package/src/tool/waistHeightRatioCalculator/logic.ts +29 -0
  28. package/src/tool/waistHeightRatioCalculator/seo.astro +15 -0
  29. package/src/tool/waistHeightRatioCalculator/ui.ts +34 -0
  30. package/src/tool/waistHeightRatioCalculator/waist-height-ratio-calculator.css +368 -0
  31. package/src/tool/waistHipRatioCalculator/bibliography.astro +14 -0
  32. package/src/tool/waistHipRatioCalculator/bibliography.ts +24 -0
  33. package/src/tool/waistHipRatioCalculator/component.astro +316 -0
  34. package/src/tool/waistHipRatioCalculator/entry.ts +27 -0
  35. package/src/tool/waistHipRatioCalculator/i18n/de.ts +316 -0
  36. package/src/tool/waistHipRatioCalculator/i18n/en.ts +316 -0
  37. package/src/tool/waistHipRatioCalculator/i18n/es.ts +316 -0
  38. package/src/tool/waistHipRatioCalculator/i18n/fr.ts +316 -0
  39. package/src/tool/waistHipRatioCalculator/i18n/id.ts +316 -0
  40. package/src/tool/waistHipRatioCalculator/i18n/it.ts +316 -0
  41. package/src/tool/waistHipRatioCalculator/i18n/ja.ts +316 -0
  42. package/src/tool/waistHipRatioCalculator/i18n/ko.ts +316 -0
  43. package/src/tool/waistHipRatioCalculator/i18n/nl.ts +316 -0
  44. package/src/tool/waistHipRatioCalculator/i18n/pl.ts +316 -0
  45. package/src/tool/waistHipRatioCalculator/i18n/pt.ts +316 -0
  46. package/src/tool/waistHipRatioCalculator/i18n/ru.ts +316 -0
  47. package/src/tool/waistHipRatioCalculator/i18n/sv.ts +316 -0
  48. package/src/tool/waistHipRatioCalculator/i18n/tr.ts +316 -0
  49. package/src/tool/waistHipRatioCalculator/i18n/zh.ts +316 -0
  50. package/src/tool/waistHipRatioCalculator/index.ts +12 -0
  51. package/src/tool/waistHipRatioCalculator/logic.ts +36 -0
  52. package/src/tool/waistHipRatioCalculator/seo.astro +15 -0
  53. package/src/tool/waistHipRatioCalculator/ui.ts +35 -0
  54. package/src/tool/waistHipRatioCalculator/waist-hip-ratio-calculator.css +433 -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.21.0",
3
+ "version": "1.22.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,6 +1,8 @@
1
1
  import type { HealthCategoryEntry } from '../types';
2
2
  import { bmiCalculator } from '../tool/bmiCalculator/entry';
3
3
  import { bodyAdiposityIndexCalculator } from '../tool/bodyAdiposityIndexCalculator/entry';
4
+ import { waistHipRatioCalculator } from '../tool/waistHipRatioCalculator/entry';
5
+ import { waistHeightRatioCalculator } from '../tool/waistHeightRatioCalculator/entry';
4
6
  import { daltonismSimulator } from '../tool/daltonismSimulator/entry';
5
7
  import { tinnitusReliever } from '../tool/tinnitusReliever/entry';
6
8
  import { breathingVisualizer } from '../tool/breathingVisualizer/entry';
@@ -18,7 +20,7 @@ import { metabolicAgeCalculator } from '../tool/metabolicAgeCalculator/entry';
18
20
 
19
21
  export const healthCategory: HealthCategoryEntry = {
20
22
  icon: 'mdi:heart-pulse',
21
- tools: [bmiCalculator, bodyAdiposityIndexCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, daltonismSimulator, tinnitusReliever, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
23
+ tools: [bmiCalculator, bodyAdiposityIndexCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, daltonismSimulator, tinnitusReliever, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
22
24
  i18n: {
23
25
  en: () => import('./i18n/en').then((m) => m.content),
24
26
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -2,6 +2,8 @@ export { binauralTuner } from './tool/binauralTuner/entry';
2
2
  export { bloodUnitConverter } from './tool/bloodUnitConverter/entry';
3
3
  export { bmiCalculator } from './tool/bmiCalculator/entry';
4
4
  export { bodyAdiposityIndexCalculator } from './tool/bodyAdiposityIndexCalculator/entry';
5
+ export { waistHipRatioCalculator } from './tool/waistHipRatioCalculator/entry';
6
+ export { waistHeightRatioCalculator } from './tool/waistHeightRatioCalculator/entry';
5
7
  export { breathingVisualizer } from './tool/breathingVisualizer/entry';
6
8
  export { caffeineTracker } from './tool/caffeineTracker/entry';
7
9
  export { caloricDeficitCalculator } from './tool/caloricDeficitCalculator/entry';
@@ -24,6 +26,8 @@ import { binauralTuner } from './tool/binauralTuner/entry';
24
26
  import { bloodUnitConverter } from './tool/bloodUnitConverter/entry';
25
27
  import { bmiCalculator } from './tool/bmiCalculator/entry';
26
28
  import { bodyAdiposityIndexCalculator } from './tool/bodyAdiposityIndexCalculator/entry';
29
+ import { waistHipRatioCalculator } from './tool/waistHipRatioCalculator/entry';
30
+ import { waistHeightRatioCalculator } from './tool/waistHeightRatioCalculator/entry';
27
31
  import { breathingVisualizer } from './tool/breathingVisualizer/entry';
28
32
  import { caffeineTracker } from './tool/caffeineTracker/entry';
29
33
  import { caloricDeficitCalculator } from './tool/caloricDeficitCalculator/entry';
@@ -41,4 +45,4 @@ import { screenDecompressionTime } from './tool/screenDecompressionTime/entry';
41
45
  import { tinnitusReliever } from './tool/tinnitusReliever/entry';
42
46
  import { ubeCalculator } from './tool/ubeCalculator/entry';
43
47
  import { waterPurifier } from './tool/waterPurifier/entry';
44
- export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, bodyAdiposityIndexCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, ubeCalculator, waterPurifier];
48
+ export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, bodyAdiposityIndexCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, ubeCalculator, waterPurifier];
package/src/index.ts CHANGED
@@ -16,6 +16,8 @@ export * from './tool/bloodUnitConverter';
16
16
  export * from './tool/readingDistanceCalculator';
17
17
  export * from './tool/hydrationCalculator';
18
18
  export * from './tool/bodyAdiposityIndexCalculator';
19
+ export * from './tool/waistHipRatioCalculator';
20
+ export * from './tool/waistHeightRatioCalculator';
19
21
  export * from './tool/dailyProteinCalculator';
20
22
  export * from './tool/leanBodyMassCalculator';
21
23
  export * from './tool/metabolicAgeCalculator';
@@ -35,7 +35,7 @@ describe('Locale Completeness Validation', () => {
35
35
  });
36
36
  });
37
37
 
38
- it('all 21 tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(21);
38
+ it('all 23 tools registered', () => {
39
+ expect(ALL_TOOLS.length).toBe(23);
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 21 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(21);
7
+ it('should have 23 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(23);
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 { waistHeightRatioCalculator } 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 waistHeightRatioCalculator.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <Bibliography links={content.bibliography} />}
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'WHO: Waist circumference and waist-hip ratio expert consultation',
6
+ url: 'https://apps.who.int/iris/handle/10665/44583',
7
+ },
8
+ {
9
+ name: 'NICE guideline: Obesity identification and classification',
10
+ url: 'https://www.nice.org.uk/guidance/ng246/chapter/Recommendations',
11
+ },
12
+ {
13
+ name: 'Wikipedia: Waist-to-height ratio',
14
+ url: 'https://en.wikipedia.org/wiki/Waist-to-height_ratio',
15
+ },
16
+ ];
@@ -0,0 +1,300 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ import type { WaistHeightRatioCalculatorUI } from './ui';
4
+ import './waist-height-ratio-calculator.css';
5
+
6
+ interface Props {
7
+ locale?: string;
8
+ ui?: Record<string, unknown>;
9
+ }
10
+
11
+ const t = (Astro.props.ui || {}) as WaistHeightRatioCalculatorUI;
12
+ ---
13
+
14
+ <div class="whtr" data-ui={JSON.stringify(t)}>
15
+ <div class="whtr__panel" data-level="healthy">
16
+ <div class="whtr__measure" aria-label={t.tapeAriaLabel}>
17
+ <div class="whtr__tape">
18
+ <div class="whtr__ticks" aria-hidden="true">
19
+ {Array.from({ length: 13 }).map((_, index) => <span style={`--i:${index}`}></span>)}
20
+ </div>
21
+ <svg class="whtr__person" viewBox="0 0 180 300" role="img" aria-hidden="true">
22
+ <defs>
23
+ <linearGradient id="whtrBodyGradient" x1="0" x2="1" y1="0" y2="1">
24
+ <stop offset="0" stop-color="currentColor" stop-opacity="0.16" />
25
+ <stop offset="0.56" stop-color="currentColor" stop-opacity="0.34" />
26
+ <stop offset="1" stop-color="currentColor" stop-opacity="0.2" />
27
+ </linearGradient>
28
+ </defs>
29
+ <circle class="whtr__body-shape" cx="90" cy="31" r="24" />
30
+ <path
31
+ class="whtr__body-shape whtr__torso"
32
+ d="M58 66 C69 53 111 53 122 66 C135 82 137 111 124 133 C116 147 118 166 134 188 C147 207 150 242 139 276 L116 276 C121 240 113 213 96 195 C92 191 88 191 84 195 C67 213 59 240 64 276 L41 276 C30 242 33 207 46 188 C62 166 64 147 56 133 C43 111 45 82 58 66 Z"
33
+ />
34
+ <path class="whtr__arm" d="M58 74 C35 103 29 151 40 203" />
35
+ <path class="whtr__arm" d="M122 74 C145 103 151 151 140 203" />
36
+ <ellipse id="whtr-waist-band" class="whtr__waist-band" cx="90" cy="137" rx="36" ry="10" />
37
+ <ellipse id="whtr-hip-band" class="whtr__hip-band" cx="90" cy="190" rx="56" ry="13" />
38
+ </svg>
39
+ <div class="whtr__half-line" id="whtr-half-line">
40
+ <strong>{t.halfHeightLabel}</strong>
41
+ <span id="whtr-half-value">85 cm</span>
42
+ </div>
43
+ <div class="whtr__waist-line" id="whtr-waist-line">
44
+ <strong>{t.currentWaistLabel}</strong>
45
+ <span id="whtr-waist-tag">82 cm</span>
46
+ </div>
47
+ </div>
48
+ <div class="whtr__rule">
49
+ <Icon name="mdi:target-variant" />
50
+ <span>{t.safeWaistLabel}</span>
51
+ <strong id="whtr-safe-limit">85 cm</strong>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="whtr__controls">
56
+ <div class="whtr__unit" role="group" aria-label={t.unitSystemLabel}>
57
+ <button class="whtr__unit-btn whtr__unit-btn--active" type="button" data-unit="metric">{t.unitMetric}</button>
58
+ <button class="whtr__unit-btn" type="button" data-unit="imperial">{t.unitImperial}</button>
59
+ </div>
60
+
61
+ <label class="whtr__field">
62
+ <span class="whtr__field-top"><span>{t.labelHeight}</span><strong id="whtr-height-value">{t.initialHeight}</strong></span>
63
+ <input id="whtr-height" class="whtr__range" type="range" min="90" max="220" value="170" />
64
+ </label>
65
+
66
+ <label class="whtr__field">
67
+ <span class="whtr__field-top"><span>{t.labelWaist}</span><strong id="whtr-waist-value">{t.initialWaist}</strong></span>
68
+ <input id="whtr-waist" class="whtr__range" type="range" min="35" max="160" value="82" />
69
+ </label>
70
+
71
+ <div class="whtr__status">
72
+ <div>
73
+ <span>{t.ratioLabel}</span>
74
+ <strong id="whtr-ratio">{t.initialRatio}</strong>
75
+ </div>
76
+ <div>
77
+ <span>{t.riskLabel}</span>
78
+ <strong id="whtr-level">{t.levelHealthy}</strong>
79
+ </div>
80
+ <p id="whtr-desc">{t.descHealthy}</p>
81
+ </div>
82
+ </div>
83
+
84
+ <div class="whtr__notes">
85
+ <div class="whtr__note">
86
+ <Icon name="mdi:function-variant" />
87
+ <p><strong>{t.formulaLabel}</strong> {t.formulaText}</p>
88
+ </div>
89
+ <div class="whtr__note">
90
+ <Icon name="mdi:tape-measure" />
91
+ <p><strong>{t.ruleLabel}</strong> {t.ruleText}</p>
92
+ </div>
93
+ <div class="whtr__note">
94
+ <Icon name="mdi:human-handsup" />
95
+ <p><strong>{t.measurementLabel}</strong> {t.measurementText}</p>
96
+ </div>
97
+ <div class="whtr__note whtr__note--alert">
98
+ <Icon name="mdi:alert-circle-outline" />
99
+ <p><strong>{t.noteLabel}</strong> {t.noteText}</p>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ <script>
106
+ type Unit = 'metric' | 'imperial';
107
+ type Level = 'veryThin' | 'healthy' | 'overweight' | 'obese';
108
+
109
+ interface WHtRElements {
110
+ panel: HTMLElement;
111
+ height: HTMLInputElement;
112
+ waist: HTMLInputElement;
113
+ heightValue: HTMLElement;
114
+ waistValue: HTMLElement;
115
+ halfValue: HTMLElement;
116
+ waistTag: HTMLElement;
117
+ safeLimit: HTMLElement;
118
+ ratio: HTMLElement;
119
+ level: HTMLElement;
120
+ desc: HTMLElement;
121
+ halfLine: HTMLElement;
122
+ waistLine: HTMLElement;
123
+ waistBand: SVGEllipseElement;
124
+ hipBand: SVGEllipseElement;
125
+ unitBtns: NodeListOf<HTMLButtonElement>;
126
+ }
127
+
128
+ interface WHtRState {
129
+ unit: Unit;
130
+ heightCm: number;
131
+ waistCm: number;
132
+ }
133
+
134
+ const STORAGE_KEY = 'whtr_state';
135
+
136
+ function required<T extends Element>(container: Element, selector: string): T {
137
+ const element = container.querySelector<T>(selector);
138
+ if (!element) throw new Error(selector);
139
+ return element;
140
+ }
141
+
142
+ function cmToIn(cm: number): number {
143
+ return cm / 2.54;
144
+ }
145
+
146
+ function inToCm(inches: number): number {
147
+ return inches * 2.54;
148
+ }
149
+
150
+ function isUnit(value: unknown): value is Unit {
151
+ return value === 'metric' || value === 'imperial';
152
+ }
153
+
154
+ function clamp(value: number, min: number, max: number): number {
155
+ return Math.max(min, Math.min(max, value));
156
+ }
157
+
158
+ function classify(value: number): Level {
159
+ if (value < 0.4) return 'veryThin';
160
+ if (value < 0.5) return 'healthy';
161
+ if (value < 0.6) return 'overweight';
162
+ return 'obese';
163
+ }
164
+
165
+ function getElements(container: Element): WHtRElements | null {
166
+ try {
167
+ return {
168
+ panel: required<HTMLElement>(container, '.whtr__panel'),
169
+ height: required<HTMLInputElement>(container, '#whtr-height'),
170
+ waist: required<HTMLInputElement>(container, '#whtr-waist'),
171
+ heightValue: required<HTMLElement>(container, '#whtr-height-value'),
172
+ waistValue: required<HTMLElement>(container, '#whtr-waist-value'),
173
+ halfValue: required<HTMLElement>(container, '#whtr-half-value'),
174
+ waistTag: required<HTMLElement>(container, '#whtr-waist-tag'),
175
+ safeLimit: required<HTMLElement>(container, '#whtr-safe-limit'),
176
+ ratio: required<HTMLElement>(container, '#whtr-ratio'),
177
+ level: required<HTMLElement>(container, '#whtr-level'),
178
+ desc: required<HTMLElement>(container, '#whtr-desc'),
179
+ halfLine: required<HTMLElement>(container, '#whtr-half-line'),
180
+ waistLine: required<HTMLElement>(container, '#whtr-waist-line'),
181
+ waistBand: required<SVGEllipseElement>(container, '#whtr-waist-band'),
182
+ hipBand: required<SVGEllipseElement>(container, '#whtr-hip-band'),
183
+ unitBtns: container.querySelectorAll<HTMLButtonElement>('.whtr__unit-btn'),
184
+ };
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function formatLength(cm: number, unit: Unit, ui: Record<string, string>): string {
191
+ if (unit === 'imperial') return `${cmToIn(cm).toFixed(1)} ${ui.unitIn}`;
192
+ return `${Math.round(cm)} ${ui.unitCm}`;
193
+ }
194
+
195
+ function loadState(): WHtRState {
196
+ const fallback: WHtRState = { unit: 'metric', heightCm: 170, waistCm: 82 };
197
+ try {
198
+ const raw = window.localStorage.getItem(STORAGE_KEY);
199
+ if (!raw) return fallback;
200
+ const saved = JSON.parse(raw) as Partial<WHtRState>;
201
+ return {
202
+ unit: isUnit(saved.unit) ? saved.unit : fallback.unit,
203
+ heightCm: clamp(Number(saved.heightCm) || fallback.heightCm, 90, 220),
204
+ waistCm: clamp(Number(saved.waistCm) || fallback.waistCm, 35, 160),
205
+ };
206
+ } catch {
207
+ return fallback;
208
+ }
209
+ }
210
+
211
+ function saveState(state: WHtRState) {
212
+ try {
213
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
214
+ } catch {
215
+ }
216
+ }
217
+
218
+ function syncRanges(elements: WHtRElements, state: WHtRState) {
219
+ if (state.unit === 'imperial') {
220
+ elements.height.min = '35';
221
+ elements.height.max = '87';
222
+ elements.height.step = '0.5';
223
+ elements.height.value = cmToIn(state.heightCm).toFixed(1);
224
+ elements.waist.min = '14';
225
+ elements.waist.max = '63';
226
+ elements.waist.step = '0.5';
227
+ elements.waist.value = cmToIn(state.waistCm).toFixed(1);
228
+ } else {
229
+ elements.height.min = '90';
230
+ elements.height.max = '220';
231
+ elements.height.step = '1';
232
+ elements.height.value = String(Math.round(state.heightCm));
233
+ elements.waist.min = '35';
234
+ elements.waist.max = '160';
235
+ elements.waist.step = '1';
236
+ elements.waist.value = String(Math.round(state.waistCm));
237
+ }
238
+ }
239
+
240
+ function syncButtons(elements: WHtRElements, state: WHtRState) {
241
+ elements.unitBtns.forEach((btn) => btn.classList.toggle('whtr__unit-btn--active', btn.dataset.unit === state.unit));
242
+ }
243
+
244
+ function render(elements: WHtRElements, state: WHtRState, ui: Record<string, string>) {
245
+ const ratio = state.waistCm / state.heightCm;
246
+ const rounded = Math.round(ratio * 100) / 100;
247
+ const level = classify(ratio);
248
+ const halfHeight = state.heightCm / 2;
249
+ const waistPosition = clamp((state.waistCm / state.heightCm) * 100, 18, 86);
250
+ const copy = {
251
+ veryThin: { label: ui.levelVeryThin, desc: ui.descVeryThin },
252
+ healthy: { label: ui.levelHealthy, desc: ui.descHealthy },
253
+ overweight: { label: ui.levelOverweight, desc: ui.descOverweight },
254
+ obese: { label: ui.levelObese, desc: ui.descObese },
255
+ };
256
+
257
+ elements.heightValue.textContent = formatLength(state.heightCm, state.unit, ui);
258
+ elements.waistValue.textContent = formatLength(state.waistCm, state.unit, ui);
259
+ elements.halfValue.textContent = formatLength(halfHeight, state.unit, ui);
260
+ elements.waistTag.textContent = formatLength(state.waistCm, state.unit, ui);
261
+ elements.safeLimit.textContent = formatLength(halfHeight, state.unit, ui);
262
+ elements.ratio.textContent = rounded.toFixed(2);
263
+ elements.level.textContent = copy[level].label;
264
+ elements.desc.textContent = copy[level].desc;
265
+ elements.panel.dataset.level = level;
266
+ elements.halfLine.style.top = '50%';
267
+ elements.waistLine.style.top = `${waistPosition}%`;
268
+ elements.waistBand.setAttribute('rx', String(clamp(28 + ratio * 30, 34, 55)));
269
+ elements.hipBand.setAttribute('rx', String(clamp(49 + ratio * 18, 54, 64)));
270
+ }
271
+
272
+ function initWHtR(container: Element) {
273
+ const ui = JSON.parse((container as HTMLElement).dataset.ui || '{}') as Record<string, string>;
274
+ const elements = getElements(container);
275
+ if (!elements) return;
276
+ const state = loadState();
277
+ const update = () => {
278
+ state.heightCm = state.unit === 'imperial' ? inToCm(Number(elements.height.value)) : Number(elements.height.value);
279
+ state.waistCm = state.unit === 'imperial' ? inToCm(Number(elements.waist.value)) : Number(elements.waist.value);
280
+ render(elements, state, ui);
281
+ saveState(state);
282
+ };
283
+
284
+ elements.unitBtns.forEach((btn) => {
285
+ btn.addEventListener('click', () => {
286
+ state.unit = btn.dataset.unit as Unit;
287
+ syncButtons(elements, state);
288
+ syncRanges(elements, state);
289
+ update();
290
+ });
291
+ });
292
+ elements.height.addEventListener('input', update);
293
+ elements.waist.addEventListener('input', update);
294
+ syncButtons(elements, state);
295
+ syncRanges(elements, state);
296
+ update();
297
+ }
298
+
299
+ document.querySelectorAll('.whtr').forEach(initWHtR);
300
+ </script>
@@ -0,0 +1,27 @@
1
+ import type { HealthToolEntry } from '../../types';
2
+ import type { WaistHeightRatioCalculatorUI } from './ui';
3
+
4
+ export const waistHeightRatioCalculator: HealthToolEntry<WaistHeightRatioCalculatorUI> = {
5
+ id: 'waistHeightRatioCalculator',
6
+ icons: {
7
+ bg: 'mdi:tape-measure',
8
+ fg: 'mdi:human-male-height',
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
+ };