Fix VO2 max gauge: correct semicircle geometry, fixed 30-70 range, arrow pointer
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:38:53 +01:00
parent 45ff01f740
commit 221b2cd333
+65 -51
View File
@@ -55,70 +55,84 @@ function getVo2Category(value, age, sex) {
return idx === -1 ? VO2_CATEGORIES[4] : VO2_CATEGORIES[idx] return idx === -1 ? VO2_CATEGORIES[4] : VO2_CATEGORIES[idx]
} }
function arcPath(cx, cy, r, startDeg, endDeg) { function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
const toRad = d => (d - 90) * Math.PI / 180 // Fixed display range: 3070 spans the full 180° semicircle
const x1 = cx + r * Math.cos(toRad(startDeg)) const MIN = 30, MAX = 70
const y1 = cy + r * Math.sin(toRad(startDeg)) const cx = 70, cy = 68, r = 50, sw = 12
const x2 = cx + r * Math.cos(toRad(endDeg))
const y2 = cy + r * Math.sin(toRad(endDeg)) const age = birthYear ? new Date().getFullYear() - birthYear : 40
const large = endDeg - startDeg > 180 ? 1 : 0
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}` // 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)]
} }
function Vo2MaxGauge({ value, birthYear, biologicalSex }) { // SVG arc path from VO2 v1 to v2, sweep=0 (upper / counter-clockwise)
const age = birthYear ? new Date().getFullYear() - birthYear : 40 const seg = (v1, v2) => {
const cat = value != null ? getVo2Category(value, age, biologicalSex) : null 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)}`
}
// Gauge spans 180° — from 180° (9 o'clock) to 360° (3 o'clock) across the top // Category segment boundaries from the age/sex ACSM table
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 table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1] const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
const thresholds = row[1] const thresholds = row[1]
const rangeMin = thresholds[0] - 8 // below Very Poor threshold const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 segments
const rangeMax = thresholds[3] + 12 // above Good threshold (Excellent zone)
const pct = Math.max(0, Math.min(1, (value - rangeMin) / (rangeMax - rangeMin))) const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
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 ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<svg width="140" height="80" viewBox="0 0 140 80"> <svg width="140" height="85" viewBox="0 0 140 85">
{/* Background track */} {/* Dark background track */}
<path d={arcPath(cx, cy, r, startDeg, startDeg + totalDeg)} <path d={seg(MIN, MAX)} stroke="#374151" strokeWidth={sw + 2} fill="none" strokeLinecap="butt" />
stroke="#1f2937" strokeWidth={sw} fill="none" strokeLinecap="butt" />
{/* 5 coloured category segments */} {/* Dimmed category colour bands */}
{VO2_CATEGORIES.map((c, i) => ( {VO2_CATEGORIES.map((c, i) => {
<path key={i} const v1 = Math.max(bounds[i], MIN)
d={arcPath(cx, cy, r, startDeg + i * segDeg, startDeg + (i + 1) * segDeg)} const v2 = Math.min(bounds[i + 1], MAX)
stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt" if (v2 <= v1) return null
strokeOpacity={0.25} return (
/> <path key={i} d={seg(v1, v2)}
))} stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt" strokeOpacity={0.3} />
{/* Filled arc up to current value */} )
{needleAngle != null && needleAngle > startDeg && ( })}
<path d={arcPath(cx, cy, r, startDeg, needleAngle)}
stroke={cat?.color} strokeWidth={sw} fill="none" strokeLinecap="butt" /> {/* Bright filled arc from MIN to current value */}
{value != null && (
<path d={seg(MIN, Math.max(MIN + 0.1, Math.min(value, MAX)))}
stroke={cat.color} strokeWidth={sw} fill="none" strokeLinecap="butt" />
)} )}
{/* Needle dot */}
{needleAngle != null && (() => { {/* Arrow pointer */}
const toRad = d => (d - 90) * Math.PI / 180 {pointerPts && <polygon points={pointerPts} fill={cat.color} />}
const nx = cx + r * Math.cos(toRad(needleAngle))
const ny = cy + r * Math.sin(toRad(needleAngle)) {/* VO2 value — coloured to match category */}
return <circle cx={nx} cy={ny} r={5} fill={cat?.color} /> <text x={cx} y={cy - 8} textAnchor="middle" dominantBaseline="middle"
})()} fontSize="21" fontWeight="700" fill={cat?.color ?? '#6b7280'}>
{/* Value */}
<text x={cx} y={cy - 6} textAnchor="middle" dominantBaseline="middle"
fontSize="22" fontWeight="700" fill={cat?.color ?? '#6b7280'}>
{value != null ? value.toFixed(1) : '--'} {value != null ? value.toFixed(1) : '--'}
</text> </text>
{/* Category label */} {/* Category label */}
<text x={cx} y={cy + 14} textAnchor="middle" dominantBaseline="middle" <text x={cx} y={cy + 10} textAnchor="middle" dominantBaseline="middle"
fontSize="9" fill="#9ca3af"> fontSize="9" fill="#9ca3af">
{cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')} {cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}
</text> </text>