Rework VO2 max gauge: full-colour ACSM bands, white inward arrow, no fill arc
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

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:49:17 +01:00
parent 5256bd448c
commit 5c5877c792
+32 -36
View File
@@ -56,83 +56,79 @@ function getVo2Category(value, age, sex) {
} }
function Vo2MaxGauge({ value, birthYear, biologicalSex }) { function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
// Fixed display range: 3070 spans the full 180° semicircle
const MIN = 30, MAX = 70 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 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)))) 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. // SVG coordinates for a VO2 value at a given radius from centre
// Uses sweep=0 (counter-clockwise) so the arc goes left→top→right.
const toXY = (v, radius = r) => { const toXY = (v, radius = r) => {
const a = toAngle(v) const a = toAngle(v)
return [cx + radius * Math.cos(a), cy - radius * Math.sin(a)] return [cx + radius * Math.cos(a), cy - radius * Math.sin(a)]
} }
// SVG arc path from VO2 v1 to v2, sweep=0 (upper / counter-clockwise) // Arc path from VO2 v1 to v2; sweep=1 → clockwise = upper semicircle in SVG
const seg = (v1, v2) => { const arc = (v1, v2, radius = r) => {
const [x1, y1] = toXY(v1) const [x1, y1] = toXY(v1, radius)
const [x2, y2] = toXY(v2) const [x2, y2] = toXY(v2, radius)
const large = (v2 - v1) / (MAX - MIN) > 0.5 ? 1 : 0 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 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 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 const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
// Triangular arrow pointer: tip points outward from arc, base sits inside track // Small white triangle: base outside the arc, tip touching the outer edge — points inward
const pointerPts = value != null ? (() => { const arrowPts = value != null ? (() => {
const a = toAngle(Math.max(MIN, Math.min(MAX, value))) const a = toAngle(Math.max(MIN, Math.min(MAX, value)))
const s = 0.13 // half-spread in radians (~7.5°) const outerEdge = r + sw / 2 // outer surface of the track
const tipX = cx + (r + 9) * Math.cos(a), tipY = cy - (r + 9) * Math.sin(a) const tipR = outerEdge + 1 // tip just outside the track surface
const baseX = r - 5 const baseR = outerEdge + 12 // base further out
const b1x = cx + baseX * Math.cos(a + s), b1y = cy - baseX * Math.sin(a + s) const s = 0.11 // half-spread ≈ 6°
const b2x = cx + baseX * Math.cos(a - s), b2y = cy - baseX * Math.sin(a - s) 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)}` return `${tipX.toFixed(1)},${tipY.toFixed(1)} ${b1x.toFixed(1)},${b1y.toFixed(1)} ${b2x.toFixed(1)},${b2y.toFixed(1)}`
})() : null })() : null
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<svg width="140" height="85" viewBox="0 0 140 85"> {/* Extra top padding so the arrow doesn't clip at the top of the card */}
{/* Dark background track */} <svg width="140" height="92" viewBox="0 0 140 92">
<path d={seg(MIN, MAX)} stroke="#374151" strokeWidth={sw + 2} fill="none" strokeLinecap="butt" /> {/* Dark background track, slightly wider than the colour bands */}
<path d={arc(MIN, MAX)} stroke="#1f2937" strokeWidth={sw + 4} fill="none" strokeLinecap="butt" />
{/* Dimmed category colour bands */} {/* Full-brightness ACSM colour bands */}
{VO2_CATEGORIES.map((c, i) => { {VO2_CATEGORIES.map((c, i) => {
const v1 = Math.max(bounds[i], MIN) const v1 = Math.max(bounds[i], MIN)
const v2 = Math.min(bounds[i + 1], MAX) const v2 = Math.min(bounds[i + 1], MAX)
if (v2 <= v1) return null if (v2 <= v1) return null
return ( return (
<path key={i} d={seg(v1, v2)} <path key={i} d={arc(v1, v2)}
stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt" strokeOpacity={0.3} /> stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt" />
) )
})} })}
{/* Bright filled arc from MIN to current value */} {/* White arrow pointing inward at the value's position */}
{value != null && ( {arrowPts && <polygon points={arrowPts} fill="white" />}
<path d={seg(MIN, Math.max(MIN + 0.1, Math.min(value, MAX)))}
stroke={cat.color} strokeWidth={sw} fill="none" strokeLinecap="butt" />
)}
{/* Arrow pointer */} {/* VO2 number, coloured by category */}
{pointerPts && <polygon points={pointerPts} fill={cat.color} />} <text x={cx} y={cy - 6} textAnchor="middle" dominantBaseline="middle"
{/* VO2 value — coloured to match category */}
<text x={cx} y={cy - 8} textAnchor="middle" dominantBaseline="middle"
fontSize="21" fontWeight="700" fill={cat?.color ?? '#6b7280'}> fontSize="21" 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 + 10} textAnchor="middle" dominantBaseline="middle" <text x={cx} y={cy + 11} 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>