Fix VO2 max gauge: correct semicircle geometry, fixed 30-70 range, arrow pointer
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:
@@ -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) {
|
|
||||||
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 }) {
|
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
|
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
|
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
|
// Triangular arrow pointer: tip points outward from arc, base sits inside track
|
||||||
const cx = 70, cy = 72, r = 52, sw = 14
|
const pointerPts = value != null ? (() => {
|
||||||
const startDeg = 180, totalDeg = 180
|
const a = toAngle(Math.max(MIN, Math.min(MAX, value)))
|
||||||
const segDeg = totalDeg / 5
|
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)
|
||||||
// Compute value angle for needle
|
const baseX = r - 5
|
||||||
let needleAngle = null
|
const b1x = cx + baseX * Math.cos(a + s), b1y = cy - baseX * Math.sin(a + s)
|
||||||
if (value != null) {
|
const b2x = cx + baseX * Math.cos(a - s), b2y = cy - baseX * Math.sin(a - s)
|
||||||
const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
|
return `${tipX.toFixed(1)},${tipY.toFixed(1)} ${b1x.toFixed(1)},${b1y.toFixed(1)} ${b2x.toFixed(1)},${b2y.toFixed(1)}`
|
||||||
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
})() : null
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user