From 5c5877c7923cfd0bac3e4d11d02a65d409713356 Mon Sep 17 00:00:00 2001 From: owain Date: Mon, 8 Jun 2026 00:49:17 +0100 Subject: [PATCH] Rework VO2 max gauge: full-colour ACSM bands, white inward arrow, no fill arc - Remove the filled arc from MIN to value (was overpainting the coloured bands) - Category bands are now full-brightness with no opacity reduction - White triangular arrow: base outside the track, tip touching the outer edge, pointing inward at the exact value position - Dark background track slightly wider than colour bands for clean border effect - Adjusted cy/viewBox height to give the arrow room above the arc Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/HealthPage.jsx | 68 +++++++++++++++---------------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index a0f4f67..74402a5 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -56,83 +56,79 @@ function getVo2Category(value, age, sex) { } 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 + // cx/cy = centre of the semicircle; arc goes left→top→right (sweep=1, clockwise in SVG) + const cx = 70, cy = 74, r = 50, sw = 11 const age = birthYear ? new Date().getFullYear() - birthYear : 40 - // Map a VO2 value to standard-math angle (PI=left/30, 0=right/70) + // Standard-math angle: PI = left (VO2 30), 0 = right (VO2 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. + // SVG coordinates for a VO2 value at a given radius from centre 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) + // Arc path from VO2 v1 to v2; sweep=1 → clockwise = upper semicircle in SVG + const arc = (v1, v2, radius = r) => { + const [x1, y1] = toXY(v1, radius) + const [x2, y2] = toXY(v2, radius) const large = (v2 - v1) / (MAX - MIN) > 0.5 ? 1 : 0 - return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}` + return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${radius} ${radius} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}` } - // Category segment boundaries from the age/sex ACSM table + // ACSM category boundaries for this user's age/sex 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 bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands const cat = value != null ? getVo2Category(value, age, biologicalSex) : null - // Triangular arrow pointer: tip points outward from arc, base sits inside track - const pointerPts = value != null ? (() => { + // Small white triangle: base outside the arc, tip touching the outer edge — points inward + const arrowPts = 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) + const outerEdge = r + sw / 2 // outer surface of the track + const tipR = outerEdge + 1 // tip just outside the track surface + const baseR = outerEdge + 12 // base further out + const s = 0.11 // half-spread ≈ 6° + const tipX = cx + tipR * Math.cos(a), tipY = cy - tipR * Math.sin(a) + const b1x = cx + baseR * Math.cos(a + s), b1y = cy - baseR * Math.sin(a + s) + const b2x = cx + baseR * Math.cos(a - s), b2y = cy - baseR * 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 (
- - {/* Dark background track */} - + {/* Extra top padding so the arrow doesn't clip at the top of the card */} + + {/* Dark background track, slightly wider than the colour bands */} + - {/* Dimmed category colour bands */} + {/* Full-brightness ACSM 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 && ( - - )} + {/* White arrow pointing inward at the value's position */} + {arrowPts && } - {/* Arrow pointer */} - {pointerPts && } - - {/* VO2 value — coloured to match category */} - {value != null ? value.toFixed(1) : '--'} {/* Category label */} - {cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}