From 221b2cd333063db298c08ef34d4f80532be156d5 Mon Sep 17 00:00:00 2001 From: owain Date: Mon, 8 Jun 2026 00:38:53 +0100 Subject: [PATCH] Fix VO2 max gauge: correct semicircle geometry, fixed 30-70 range, arrow pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrote the SVG arc math — sweep=0 (counter-clockwise) correctly draws the upper semicircle from left (30) over the top to right (70). The gauge now: - Spans a fixed VO2 range of 30–70 across 180° - Shows dimmed age/sex-specific ACSM category bands as background - Fills a bright arc from 30 to the current value in the category colour - Has a small triangular arrow pointer at the value position on the arc - Shows the value number centred in the dome, coloured for its category Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/HealthPage.jsx | 120 +++++++++++++++++------------- 1 file changed, 67 insertions(+), 53 deletions(-) diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 6e4ac3b..c4e152d 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -55,70 +55,84 @@ function getVo2Category(value, age, sex) { return idx === -1 ? VO2_CATEGORIES[4] : VO2_CATEGORIES[idx] } -function arcPath(cx, cy, r, startDeg, endDeg) { - const toRad = d => (d - 90) * Math.PI / 180 - const x1 = cx + r * Math.cos(toRad(startDeg)) - const y1 = cy + r * Math.sin(toRad(startDeg)) - const x2 = cx + r * Math.cos(toRad(endDeg)) - const y2 = cy + r * Math.sin(toRad(endDeg)) - const large = endDeg - startDeg > 180 ? 1 : 0 - return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}` -} - function Vo2MaxGauge({ value, birthYear, biologicalSex }) { + // Fixed display range: 30–70 spans the full 180° semicircle + const MIN = 30, MAX = 70 + const cx = 70, cy = 68, r = 50, sw = 12 + const age = birthYear ? new Date().getFullYear() - birthYear : 40 + + // Map a VO2 value to standard-math angle (PI=left/30, 0=right/70) + const toAngle = v => Math.PI * (1 - Math.max(0, Math.min(1, (v - MIN) / (MAX - MIN)))) + + // Map a VO2 value to SVG [x, y] on the arc. + // Uses sweep=0 (counter-clockwise) so the arc goes left→top→right. + const toXY = (v, radius = r) => { + const a = toAngle(v) + return [cx + radius * Math.cos(a), cy - radius * Math.sin(a)] + } + + // SVG arc path from VO2 v1 to v2, sweep=0 (upper / counter-clockwise) + const seg = (v1, v2) => { + const [x1, y1] = toXY(v1) + const [x2, y2] = toXY(v2) + const large = (v2 - v1) / (MAX - MIN) > 0.5 ? 1 : 0 + return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 0 ${x2.toFixed(2)} ${y2.toFixed(2)}` + } + + // Category segment boundaries from the age/sex ACSM table + const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE + const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1] + const thresholds = row[1] + const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 segments + const cat = value != null ? getVo2Category(value, age, biologicalSex) : null - // Gauge spans 180° — from 180° (9 o'clock) to 360° (3 o'clock) across the top - const cx = 70, cy = 72, r = 52, sw = 14 - const startDeg = 180, totalDeg = 180 - const segDeg = totalDeg / 5 - - // Compute value angle for needle - let needleAngle = null - if (value != null) { - const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE - const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1] - const thresholds = row[1] - const rangeMin = thresholds[0] - 8 // below Very Poor threshold - const rangeMax = thresholds[3] + 12 // above Good threshold (Excellent zone) - const pct = Math.max(0, Math.min(1, (value - rangeMin) / (rangeMax - rangeMin))) - needleAngle = startDeg + pct * totalDeg - } + // Triangular arrow pointer: tip points outward from arc, base sits inside track + const pointerPts = value != null ? (() => { + const a = toAngle(Math.max(MIN, Math.min(MAX, value))) + const s = 0.13 // half-spread in radians (~7.5°) + const tipX = cx + (r + 9) * Math.cos(a), tipY = cy - (r + 9) * Math.sin(a) + const baseX = r - 5 + const b1x = cx + baseX * Math.cos(a + s), b1y = cy - baseX * Math.sin(a + s) + const b2x = cx + baseX * Math.cos(a - s), b2y = cy - baseX * Math.sin(a - s) + return `${tipX.toFixed(1)},${tipY.toFixed(1)} ${b1x.toFixed(1)},${b1y.toFixed(1)} ${b2x.toFixed(1)},${b2y.toFixed(1)}` + })() : null return (
- - {/* Background track */} - - {/* 5 coloured category segments */} - {VO2_CATEGORIES.map((c, i) => ( - - ))} - {/* Filled arc up to current value */} - {needleAngle != null && needleAngle > startDeg && ( - + + {/* Dark background track */} + + + {/* Dimmed category colour bands */} + {VO2_CATEGORIES.map((c, i) => { + const v1 = Math.max(bounds[i], MIN) + const v2 = Math.min(bounds[i + 1], MAX) + if (v2 <= v1) return null + return ( + + ) + })} + + {/* Bright filled arc from MIN to current value */} + {value != null && ( + )} - {/* Needle dot */} - {needleAngle != null && (() => { - const toRad = d => (d - 90) * Math.PI / 180 - const nx = cx + r * Math.cos(toRad(needleAngle)) - const ny = cy + r * Math.sin(toRad(needleAngle)) - return - })()} - {/* Value */} - + + {/* Arrow pointer */} + {pointerPts && } + + {/* VO2 value — coloured to match category */} + {value != null ? value.toFixed(1) : '--'} + {/* Category label */} - {cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}