Colour effort by zone, and read distance in the rider's units
A watt is a fact; a zone is what it costs you. The biggest number on the ride screen was the same shade of white at 90 W and at 400 W, which is a thing no training app has done in fifteen years. Zones, with two rules: - **No reference, no zone.** An unset FTP draws the plain number. A zone measured against a guessed threshold would paint every ride with a confident lie. - **Colour never carries it alone.** "Z4" renders beside the swatch, so the meaning survives a colour-blind rider, a phone in direct sun and a black-and-white screenshot. Read off the rolling average, not the instantaneous watts: at 4 Hz the raw figure crosses two boundaries every pedal stroke, and a colour that strobes is worse than no colour. Not pedalling is not zone 1. Units are a display preference applied at the last step before the glass. Everything computed, stored and recorded stays SI, so a FIT file never depends on what the screen was set to. `format.ts` takes the unit system as an argument rather than reading a module-level setting — pure functions are what let every readout redraw the moment it changes. The `km` helper is gone rather than left beside `dist`, so there is no second way to format a distance that ignores the preference. Rust's block labels lose their baked-in kilometres. The block already carries start_x and end_x and the frontend renders that span in the rider's units; a kilometre in the text sat inside a sentence saying miles everywhere else. Also on the ride screen: - The gradient gets a wedge beside the number. A signed decimal has to be read; a slope is seen. Exaggerated and clamped, because a true-scale 6% is indistinguishable from 3% at 40 px wide. - What is coming, from the profile's own block list — "2.1 km at 12% in 460 m". The chart says where the rider is; what is about to happen is what decides whether to shift now. The data was already computed Rust-side and thrown away here. Close in, the small unit reads better than a fraction of the big one. - Mode and target merge into one chip. They are a single fact, and splitting them spent a chip of header width repeating the word "target". - The pod chip no longer reports a missing `+` pod while the `−` pod is connected. The `−` pod relays its twin, so that is the intended configuration — the ride screen was calling it a fault, contradicting the device screen two keystrokes away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -333,13 +333,17 @@ fn block_label(block: &Block) -> String {
|
|||||||
amplitude,
|
amplitude,
|
||||||
repeats
|
repeats
|
||||||
),
|
),
|
||||||
|
// No distance in the label. The block already carries `start_x` and
|
||||||
|
// `end_x`, and the frontend renders that span in the rider's own units
|
||||||
|
// (FR-7.5a) — a kilometre baked into the text here would sit inside a
|
||||||
|
// sentence that says miles everywhere else.
|
||||||
Block::Segments { segments } => {
|
Block::Segments { segments } => {
|
||||||
let d: f64 = segments.iter().map(|s| s.distance_m).sum();
|
format!(
|
||||||
format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
|
"{} segment{}",
|
||||||
}
|
segments.len(),
|
||||||
Block::Terrain { points } => {
|
if segments.len() == 1 { "" } else { "s" }
|
||||||
let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
|
)
|
||||||
format!("terrain · {:.1} km", d / 1000.0)
|
|
||||||
}
|
}
|
||||||
|
Block::Terrain { .. } => "terrain".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,25 @@
|
|||||||
--power: #dfe8f3;
|
--power: #dfe8f3;
|
||||||
--power-raw: #3f4d5d;
|
--power-raw: #3f4d5d;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Effort zones (see `powerZone` in lib/format.ts). An ordered ramp, not a
|
||||||
|
* categorical palette: cool and quiet at the bottom, hot and loud at the top,
|
||||||
|
* so intensity reads from the colour before the number is even focused on.
|
||||||
|
* Four of the seven are the tones this stylesheet already uses for ok / warn
|
||||||
|
* / climb / bad, which keeps one vocabulary on the screen rather than two.
|
||||||
|
*
|
||||||
|
* Colour never carries the meaning alone — every zoned readout renders "Z4"
|
||||||
|
* beside the swatch. That is what makes this safe for the ~8% of male riders
|
||||||
|
* with a colour vision deficiency, and for a phone in direct sun.
|
||||||
|
*/
|
||||||
|
--zone-1: #7b8b9c;
|
||||||
|
--zone-2: #4aa8ff;
|
||||||
|
--zone-3: #35d9a0;
|
||||||
|
--zone-4: #ffcf4a;
|
||||||
|
--zone-5: #ff9a3c;
|
||||||
|
--zone-6: #ff5a52;
|
||||||
|
--zone-7: #c07cff;
|
||||||
|
|
||||||
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
|
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
|
||||||
--gap: 24px;
|
--gap: 24px;
|
||||||
--edge: 44px;
|
--edge: 44px;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { api } from '../lib/bridge';
|
import { api } from '../lib/bridge';
|
||||||
import { axisValue } from '../lib/format';
|
import { axisValue, dist, distUnit, elev, elevUnit } from '../lib/format';
|
||||||
import type { ProfileView } from '../lib/types';
|
import type { ProfileView } from '../lib/types';
|
||||||
|
|
||||||
let yaml = $state('');
|
let yaml = $state('');
|
||||||
@@ -136,9 +136,11 @@
|
|||||||
<span class="label">Loaded</span>
|
<span class="label">Loaded</span>
|
||||||
<strong>{loaded.name}</strong>
|
<strong>{loaded.name}</strong>
|
||||||
<span class="sample-sub">
|
<span class="sample-sub">
|
||||||
{loaded.totalMetres ? `${(loaded.totalMetres / 1000).toFixed(1)} km` : ''}
|
{loaded.totalMetres ? `${dist(loaded.totalMetres, app.units, 1)} ${distUnit(app.units)}` : ''}
|
||||||
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
|
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
|
||||||
{loaded.totalAscentM != null ? `· ${loaded.totalAscentM.toFixed(0)} m up` : ''}
|
{loaded.totalAscentM != null
|
||||||
|
? `· ${elev(loaded.totalAscentM, app.units)} ${elevUnit(app.units)} up`
|
||||||
|
: ''}
|
||||||
{loaded.looping ? '· loops' : ''}
|
{loaded.looping ? '· loops' : ''}
|
||||||
</span>
|
</span>
|
||||||
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
|
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
|
||||||
@@ -174,7 +176,7 @@
|
|||||||
<em>{b.kind}</em>
|
<em>{b.kind}</em>
|
||||||
{b.label}
|
{b.label}
|
||||||
<span class="quiet">
|
<span class="quiet">
|
||||||
{axisValue(b.unit, b.endX - b.startX)}
|
{axisValue(b.unit, b.endX - b.startX, app.units)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
sub?: string | null;
|
sub?: string | null;
|
||||||
dim?: boolean;
|
dim?: boolean;
|
||||||
align?: 'start' | 'end';
|
align?: 'start' | 'end';
|
||||||
|
/**
|
||||||
|
* A short tag beside the label — the effort zone, in practice.
|
||||||
|
*
|
||||||
|
* It sits next to the *label* rather than the number so the number keeps
|
||||||
|
* its full weight, and it is a word rather than a colour alone: `Z4` still
|
||||||
|
* reads on a phone in the sun and to a rider who cannot separate the amber
|
||||||
|
* from the green (see the zone note in app.css).
|
||||||
|
*/
|
||||||
|
badge?: { label: string; colour: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -24,11 +33,15 @@
|
|||||||
sub = null,
|
sub = null,
|
||||||
dim = false,
|
dim = false,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
|
badge = null,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
|
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
|
||||||
<span class="label">{label}</span>
|
<span class="head">
|
||||||
|
<span class="label">{label}</span>
|
||||||
|
{#if badge}<span class="badge" style:color={badge.colour}>{badge.label}</span>{/if}
|
||||||
|
</span>
|
||||||
<span class="value" style:color={colour}>
|
<span class="value" style:color={colour}>
|
||||||
{value}{#if unit}<span class="unit">{unit}</span>{/if}
|
{value}{#if unit}<span class="unit">{unit}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -43,6 +56,21 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4em;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: var(--type-label);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
/* No pill: a box around a two-character tag is more furniture than the tag
|
||||||
|
is worth, and this stylesheet earns its structure from space. */
|
||||||
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 0.92;
|
line-height: 0.92;
|
||||||
|
|||||||
@@ -11,12 +11,19 @@
|
|||||||
import { api } from '../lib/bridge';
|
import { api } from '../lib/bridge';
|
||||||
import {
|
import {
|
||||||
clock,
|
clock,
|
||||||
|
dist,
|
||||||
|
distUnit,
|
||||||
duration,
|
duration,
|
||||||
|
elev,
|
||||||
|
elevUnit,
|
||||||
finishAt,
|
finishAt,
|
||||||
km,
|
hrZone,
|
||||||
MODE_LABEL,
|
MODE_LABEL,
|
||||||
num,
|
num,
|
||||||
|
powerZone,
|
||||||
signed,
|
signed,
|
||||||
|
speed,
|
||||||
|
speedUnit,
|
||||||
targetText,
|
targetText,
|
||||||
} from '../lib/format';
|
} from '../lib/format';
|
||||||
import { viewport } from '../lib/viewport.svelte';
|
import { viewport } from '../lib/viewport.svelte';
|
||||||
@@ -33,6 +40,9 @@
|
|||||||
const show = $derived(viewport.plan.show);
|
const show = $derived(viewport.plan.show);
|
||||||
const compact = $derived(viewport.plan.sizeClass === 'compact');
|
const compact = $derived(viewport.plan.sizeClass === 'compact');
|
||||||
|
|
||||||
|
const units = $derived(app.units);
|
||||||
|
const prefs = $derived(app.prefs);
|
||||||
|
|
||||||
const frame = $derived(app.frame);
|
const frame = $derived(app.frame);
|
||||||
const snap = $derived(frame?.snapshot ?? null);
|
const snap = $derived(frame?.snapshot ?? null);
|
||||||
const d = $derived(frame?.derived ?? null);
|
const d = $derived(frame?.derived ?? null);
|
||||||
@@ -81,13 +91,20 @@
|
|||||||
const podChip = $derived.by(() => {
|
const podChip = $derived.by(() => {
|
||||||
const c = app.controller;
|
const c = app.controller;
|
||||||
if (!c) return null;
|
if (!c) return null;
|
||||||
const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
|
// The − pod relays its twin, so a `+` pod that is not connected while the
|
||||||
if (missing.length === 0) return null;
|
// `−` pod is up is the *intended* configuration, not a missing device —
|
||||||
|
// and this chip was reporting it as one, contradicting the device screen.
|
||||||
|
if (c.minus.state === 'connected') return null;
|
||||||
|
const missing = c.plus.state === 'connected' ? [c.minus] : [c.minus, c.plus];
|
||||||
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
|
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
|
||||||
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
|
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
|
||||||
return {
|
return {
|
||||||
tone: searching ? 'tone-warn' : 'tone-idle',
|
tone: searching ? 'tone-warn' : 'tone-idle',
|
||||||
label: searching ? `Looking for the ${names}…` : `No ${names}`,
|
label: searching
|
||||||
|
? `Looking for the ${names}…`
|
||||||
|
: c.plus.state === 'connected'
|
||||||
|
? '+ pod only — no D-pad'
|
||||||
|
: `No ${names}`,
|
||||||
// The keyboard is always the fallback, which is what keeps a missing pod
|
// The keyboard is always the fallback, which is what keeps a missing pod
|
||||||
// an annoyance rather than the end of the ride.
|
// an annoyance rather than the end of the ride.
|
||||||
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
|
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
|
||||||
@@ -190,10 +207,10 @@
|
|||||||
*/
|
*/
|
||||||
const speedSub = $derived.by(() => {
|
const speedSub = $derived.by(() => {
|
||||||
if (speedFault) return speedFault;
|
if (speedFault) return speedFault;
|
||||||
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
|
const now = `now ${speed(snap?.virtual_speed_kph ?? 0, units)}`;
|
||||||
const trainer = snap?.telemetry.speed_kph;
|
const trainer = snap?.telemetry.speed_kph;
|
||||||
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
|
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
|
||||||
return (trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now) + coasting;
|
return (trainer != null ? `${now} · trainer ${speed(trainer, units)}` : now) + coasting;
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusChip = $derived.by(() => {
|
const statusChip = $derived.by(() => {
|
||||||
@@ -220,13 +237,66 @@
|
|||||||
const routeSummary = $derived.by(() => {
|
const routeSummary = $derived.by(() => {
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
const bits: string[] = [];
|
const bits: string[] = [];
|
||||||
if (profile.totalMetres) bits.push(`${(profile.totalMetres / 1000).toFixed(1)} km`);
|
if (profile.totalMetres) bits.push(`${dist(profile.totalMetres, units, 1)} ${distUnit(units)}`);
|
||||||
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
|
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
|
||||||
if (profile.totalAscentM != null) bits.push(`${profile.totalAscentM.toFixed(0)} m up`);
|
if (profile.totalAscentM != null) {
|
||||||
|
bits.push(`${elev(profile.totalAscentM, units)} ${elevUnit(units)} up`);
|
||||||
|
}
|
||||||
if (profile.looping) bits.push('loops');
|
if (profile.looping) bits.push('loops');
|
||||||
return bits.join(' · ');
|
return bits.join(' · ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effort as a zone, not just a number (see `powerZone` in lib/format.ts).
|
||||||
|
*
|
||||||
|
* Read off the *rolling* average rather than the instantaneous watts: at 4 Hz
|
||||||
|
* the raw figure crosses two zone boundaries every pedal stroke, and a
|
||||||
|
* colour that strobes is worse than no colour. `null` whenever the rider has
|
||||||
|
* not set an FTP — the readout then draws exactly as it always did.
|
||||||
|
*/
|
||||||
|
const pZone = $derived(powerZone(d?.rollingPowerW, prefs.ftpW));
|
||||||
|
const hZone = $derived(hrZone(snap?.telemetry.heart_rate_bpm, prefs.maxHrBpm));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gradient, as a shape.
|
||||||
|
*
|
||||||
|
* A signed decimal has to be read; a wedge is seen. The angle is exaggerated
|
||||||
|
* — real road gradients are almost flat at true scale, and 6% drawn honestly
|
||||||
|
* is indistinguishable from 3% at 40 px wide — and clamped so a profile with
|
||||||
|
* a 30% wall cannot draw a vertical cliff.
|
||||||
|
*/
|
||||||
|
const wedge = $derived.by(() => {
|
||||||
|
const clamped = Math.max(-15, Math.min(15, gradient));
|
||||||
|
// 22 px of rise across 38 px of run at the clamp, which reads as a
|
||||||
|
// recognisable hill without leaving the line box.
|
||||||
|
const rise = (clamped / 15) * 11;
|
||||||
|
return { y1: 12 + rise, y2: 12 - rise };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is coming, from the profile's own block list.
|
||||||
|
*
|
||||||
|
* The route chart says where the rider *is*; every professional app also says
|
||||||
|
* what is about to happen, because that is what decides whether to shift now
|
||||||
|
* or hold on. The data was already computed Rust-side and thrown away here.
|
||||||
|
*/
|
||||||
|
const nextUp = $derived.by(() => {
|
||||||
|
if (!profile || !d) return null;
|
||||||
|
const at = d.positionX;
|
||||||
|
const block = profile.blocks.find((b) => b.startX > at);
|
||||||
|
if (!block) return null;
|
||||||
|
const away = block.startX - at;
|
||||||
|
// Close in, the small unit reads better than a fraction of the big one:
|
||||||
|
// "in 460 m" is a distance a rider can feel, "in 0.46 km" is arithmetic.
|
||||||
|
const inWords =
|
||||||
|
block.unit !== 'metres'
|
||||||
|
? duration(away)
|
||||||
|
: away < 1000
|
||||||
|
? `${elev(away, units)} ${elevUnit(units)}`
|
||||||
|
: `${dist(away, units, 1)} ${distUnit(units)}`;
|
||||||
|
return { label: block.label, away: inWords };
|
||||||
|
});
|
||||||
|
|
||||||
const openRoutes = () => (app.showProfiles = true);
|
const openRoutes = () => (app.showProfiles = true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -257,17 +327,36 @@
|
|||||||
<span class="dot"></span>{podChip.label}
|
<span class="dot"></span>{podChip.label}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
|
<!-- One chip, not two. The mode and the target it produced are a single
|
||||||
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
|
fact — "Manual grade, +2.5%" — and splitting them cost a whole chip
|
||||||
|
of the header's width to repeat the word "target" (FR-9.8). -->
|
||||||
|
<span class="chip mode">
|
||||||
|
{MODE_LABEL[ride?.mode ?? 'ManualGrade']}
|
||||||
|
<span class="target">{targetText(ride?.target ?? null)}</span>
|
||||||
|
</span>
|
||||||
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
|
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
|
||||||
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
|
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
|
||||||
|
<!-- Hidden on a phone (see below): the setup screen is a one-off, it has
|
||||||
|
its own way in from the device screen, and the header is the one row
|
||||||
|
on this layout that cannot afford a second line. -->
|
||||||
|
<button class="btn ghost setup" title="Rider setup" onclick={() => app.openSettings()}>
|
||||||
|
Rider <span class="kbd">,</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- The hero. -->
|
<!-- The hero. -->
|
||||||
{#if show.routeChart}
|
{#if show.routeChart}
|
||||||
<section class="route">
|
<section class="route">
|
||||||
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
<!-- The chart owns the slack; the next-up line takes the one text row it
|
||||||
|
needs. Without this wrapper the chart is 100% of the section and the
|
||||||
|
line is clipped by the section's own `overflow: hidden`. -->
|
||||||
|
<div class="route-chart">
|
||||||
|
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
||||||
|
</div>
|
||||||
|
{#if nextUp}
|
||||||
|
<span class="next"><span class="label">Next</span>{nextUp.label} in {nextUp.away}</span>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -314,27 +403,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<Readout
|
<Readout
|
||||||
label="To go"
|
label="To go"
|
||||||
value={remaining != null ? km(remaining, 2) : '—'}
|
value={remaining != null ? dist(remaining, units, 2) : '—'}
|
||||||
unit={remaining != null ? 'km' : ''}
|
unit={remaining != null ? distUnit(units) : ''}
|
||||||
size="big"
|
size="big"
|
||||||
sub={d?.distanceTotalM ? `of ${km(d.distanceTotalM, 1)} km` : null}
|
sub={d?.distanceTotalM ? `of ${dist(d.distanceTotalM, units, 1)} ${distUnit(units)}` : null}
|
||||||
dim={remaining == null}
|
dim={remaining == null}
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Speed"
|
label="Speed"
|
||||||
value={num(d?.displaySpeedKph ?? 0, 1)}
|
value={speed(d?.displaySpeedKph ?? 0, units)}
|
||||||
unit="km/h"
|
unit={speedUnit(units)}
|
||||||
size="big"
|
size="big"
|
||||||
sub={speedSub}
|
sub={speedSub}
|
||||||
/>
|
/>
|
||||||
<Readout
|
<div class="grade-cell">
|
||||||
label="Gradient"
|
<Readout
|
||||||
value={signed(gradient, 1)}
|
label="Gradient"
|
||||||
unit="%"
|
value={signed(gradient, 1)}
|
||||||
size="big"
|
unit="%"
|
||||||
colour={gradeColour}
|
size="big"
|
||||||
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
|
colour={gradeColour}
|
||||||
/>
|
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
|
||||||
|
/>
|
||||||
|
<!-- The same number as a slope. Seen, not read (see `wedge`). -->
|
||||||
|
<svg class="wedge" viewBox="0 0 40 24" aria-hidden="true" style:color={gradeColour}>
|
||||||
|
<path d="M2 {wedge.y1} L38 {wedge.y2}" stroke="currentColor" stroke-width="2.6"
|
||||||
|
stroke-linecap="round" fill="none" />
|
||||||
|
<path d="M2 {wedge.y1} L38 {wedge.y2} L38 22 L2 22 Z" fill="currentColor" opacity="0.14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<Readout
|
<Readout
|
||||||
label="Gear"
|
label="Gear"
|
||||||
value={gear.value}
|
value={gear.value}
|
||||||
@@ -350,14 +447,14 @@
|
|||||||
<section class="detail">
|
<section class="detail">
|
||||||
<Readout
|
<Readout
|
||||||
label="Elevation"
|
label="Elevation"
|
||||||
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
|
value={d?.elevationM != null ? elev(d.elevationM, units) : '—'}
|
||||||
unit={d?.elevationM != null ? 'm' : ''}
|
unit={d?.elevationM != null ? elevUnit(units) : ''}
|
||||||
colour="var(--climb)"
|
colour="var(--climb)"
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Climbing left"
|
label="Climbing left"
|
||||||
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
|
value={d?.ascentRemainingM != null ? elev(d.ascentRemainingM, units) : '—'}
|
||||||
unit={d?.ascentRemainingM != null ? 'm' : ''}
|
unit={d?.ascentRemainingM != null ? elevUnit(units) : ''}
|
||||||
/>
|
/>
|
||||||
<!--
|
<!--
|
||||||
Compact keeps three of the five, and elapsed time is one of them: it is
|
Compact keeps three of the five, and elapsed time is one of them: it is
|
||||||
@@ -366,8 +463,16 @@
|
|||||||
"how far" ("of 42.0 km") and the summary screen answers the rest.
|
"how far" ("of 42.0 km") and the summary screen answers the rest.
|
||||||
-->
|
-->
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
|
<Readout
|
||||||
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
|
label="Covered"
|
||||||
|
value={dist(snap?.virtual_distance_m ?? 0, units, 2)}
|
||||||
|
unit={distUnit(units)}
|
||||||
|
/>
|
||||||
|
<Readout
|
||||||
|
label="Ascended"
|
||||||
|
value={elev(snap?.elevation_gain_m ?? 0, units)}
|
||||||
|
unit={elevUnit(units)}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
|
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
|
||||||
</section>
|
</section>
|
||||||
@@ -380,8 +485,9 @@
|
|||||||
value={num(d?.rollingPowerW ?? 0, 0)}
|
value={num(d?.rollingPowerW ?? 0, 0)}
|
||||||
unit="W"
|
unit="W"
|
||||||
size="mid"
|
size="mid"
|
||||||
colour="var(--power)"
|
colour={pZone?.colour ?? 'var(--power)'}
|
||||||
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
|
badge={pZone ? { label: pZone.short, colour: pZone.colour } : null}
|
||||||
|
sub={`${pZone ? pZone.name + ' · ' : ''}now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Cadence"
|
label="Cadence"
|
||||||
@@ -395,6 +501,9 @@
|
|||||||
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
|
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
|
||||||
unit="bpm"
|
unit="bpm"
|
||||||
size="mid"
|
size="mid"
|
||||||
|
colour={hZone?.colour ?? 'var(--ink)'}
|
||||||
|
badge={hZone ? { label: hZone.short, colour: hZone.colour } : null}
|
||||||
|
sub={hZone?.name ?? null}
|
||||||
/>
|
/>
|
||||||
<!--
|
<!--
|
||||||
Averages, normalised power, work and calories are what the summary screen
|
Averages, normalised power, work and calories are what the summary screen
|
||||||
@@ -517,18 +626,60 @@
|
|||||||
background: #131b26;
|
background: #131b26;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip.mode .target {
|
||||||
|
color: var(--route);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The gradient number and its slope, side by side and sharing a colour. */
|
||||||
|
.grade-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wedge {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-size='compact']) .chips .setup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.chip.target {
|
.chip.target {
|
||||||
color: var(--route);
|
color: var(--route);
|
||||||
background: rgba(69, 208, 255, 0.1);
|
background: rgba(69, 208, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.route {
|
.route {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
flex: 2 1 0;
|
flex: 2 1 0;
|
||||||
min-height: var(--route-min);
|
min-height: var(--route-min);
|
||||||
padding: 0 var(--edge);
|
padding: 0 var(--edge);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-chart {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- pre-ride launch panel ---------------------------------------- */
|
/* ---- pre-ride launch panel ---------------------------------------- */
|
||||||
|
|
||||||
.launch {
|
.launch {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
import 'uplot/dist/uPlot.min.css';
|
import 'uplot/dist/uPlot.min.css';
|
||||||
|
import { app } from '../lib/app.svelte';
|
||||||
|
import { dist, elev } from '../lib/format';
|
||||||
import { axis, observeSize, positionMarker } from '../lib/uplot';
|
import { axis, observeSize, positionMarker } from '../lib/uplot';
|
||||||
import type { ProfileView } from '../lib/types';
|
import type { ProfileView } from '../lib/types';
|
||||||
|
|
||||||
@@ -24,6 +26,10 @@
|
|||||||
|
|
||||||
let { profile, positionX, revision }: Props = $props();
|
let { profile, positionX, revision }: Props = $props();
|
||||||
|
|
||||||
|
/** Axis labels are the one part of this chart that a display preference
|
||||||
|
* reaches, so a change of units rebuilds it — see the effect below. */
|
||||||
|
const units = $derived(app.units);
|
||||||
|
|
||||||
let host = $state<HTMLDivElement | null>(null);
|
let host = $state<HTMLDivElement | null>(null);
|
||||||
let plot: uPlot | null = null;
|
let plot: uPlot | null = null;
|
||||||
let disposeSize: (() => void) | null = null;
|
let disposeSize: (() => void) | null = null;
|
||||||
@@ -56,12 +62,15 @@
|
|||||||
axes: [
|
axes: [
|
||||||
axis({
|
axis({
|
||||||
values: (_u, splits) =>
|
values: (_u, splits) =>
|
||||||
splits.map((v) => (isMetres ? `${(v / 1000).toFixed(1)}` : formatMinutes(v))),
|
splits.map((v) => (isMetres ? dist(v, units, 1) : formatMinutes(v))),
|
||||||
}),
|
}),
|
||||||
axis({
|
axis({
|
||||||
side: 3,
|
side: 3,
|
||||||
size: 46,
|
size: 46,
|
||||||
values: (_u, splits) => splits.map((v) => v.toFixed(0)),
|
// Metres of elevation follow the rider's units; a profile's own
|
||||||
|
// channel (percent, watts) is already in the unit it means.
|
||||||
|
values: (_u, splits) =>
|
||||||
|
splits.map((v) => (source?.isElevation ? elev(v, units) : v.toFixed(0))),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
@@ -122,7 +131,8 @@
|
|||||||
// is a single array walk rather than a chart teardown.
|
// is a single array walk rather than a chart teardown.
|
||||||
let builtFor: string | null = null;
|
let builtFor: string | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = host && profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null;
|
const key =
|
||||||
|
host && profile ? `${profile.name}|${profile.source}|${profile.totalX}|${units}` : null;
|
||||||
if (key === builtFor) return;
|
if (key === builtFor) return;
|
||||||
builtFor = key;
|
builtFor = key;
|
||||||
if (key) {
|
if (key) {
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
* urgent. It says where the file already is, whatever the rider does next.
|
* urgent. It says where the file already is, whatever the rider does next.
|
||||||
*/
|
*/
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { clock, km, num } from '../lib/format';
|
import { clock, dist, distUnit, elev, elevUnit, num } from '../lib/format';
|
||||||
import Readout from './Readout.svelte';
|
import Readout from './Readout.svelte';
|
||||||
|
|
||||||
const s = $derived(app.summary);
|
const s = $derived(app.summary);
|
||||||
|
const units = $derived(app.units);
|
||||||
|
|
||||||
/** Time not spent riding. Only worth showing when it is not zero. */
|
/** Time not spent riding. Only worth showing when it is not zero. */
|
||||||
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
|
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
|
||||||
@@ -64,15 +65,15 @@
|
|||||||
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
|
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
|
||||||
<Readout
|
<Readout
|
||||||
label="Distance"
|
label="Distance"
|
||||||
value={km(s.distanceM, 2)}
|
value={dist(s.distanceM, units, 2)}
|
||||||
unit="km"
|
unit={distUnit(units)}
|
||||||
size="big"
|
size="big"
|
||||||
colour="var(--route)"
|
colour="var(--route)"
|
||||||
/>
|
/>
|
||||||
<Readout
|
<Readout
|
||||||
label="Climbing"
|
label="Climbing"
|
||||||
value={num(s.ascentM, 0)}
|
value={elev(s.ascentM, units)}
|
||||||
unit="m"
|
unit={elevUnit(units)}
|
||||||
size="big"
|
size="big"
|
||||||
colour="var(--climb)"
|
colour="var(--climb)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { dist, elev, hrZone, percentOf, powerZone, speed, toMass, fromMass } from './format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display layer is where a rider's preference is applied and nowhere else,
|
||||||
|
* so these tests exist to pin two properties: the conversions are exact, and a
|
||||||
|
* zone is never invented from a reference the rider has not given.
|
||||||
|
*/
|
||||||
|
describe('units', () => {
|
||||||
|
it('leaves metric alone and converts imperial exactly', () => {
|
||||||
|
expect(dist(42195, 'metric', 2)).toBe('42.20');
|
||||||
|
expect(dist(1609.344, 'imperial', 3)).toBe('1.000');
|
||||||
|
expect(elev(1000, 'metric')).toBe('1000');
|
||||||
|
expect(elev(304.8, 'imperial')).toBe('1000');
|
||||||
|
expect(speed(32.18688, 'imperial')).toBe('20.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a mass through the form and back', () => {
|
||||||
|
const kg = 72.5;
|
||||||
|
expect(fromMass(toMass(kg, 'imperial'), 'imperial')).toBeCloseTo(kg, 6);
|
||||||
|
expect(toMass(kg, 'metric')).toBe(kg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing rather than NaN when there is no value', () => {
|
||||||
|
expect(dist(null, 'metric')).toBe('—');
|
||||||
|
expect(elev(undefined, 'imperial')).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('effort zones', () => {
|
||||||
|
it('places power on the Coggan boundaries', () => {
|
||||||
|
const ftp = 250;
|
||||||
|
expect(powerZone(100, ftp)?.short).toBe('Z1'); // 40%
|
||||||
|
expect(powerZone(175, ftp)?.short).toBe('Z2'); // 70%
|
||||||
|
expect(powerZone(220, ftp)?.short).toBe('Z3'); // 88%
|
||||||
|
expect(powerZone(250, ftp)?.short).toBe('Z4'); // 100%
|
||||||
|
expect(powerZone(290, ftp)?.short).toBe('Z5'); // 116%
|
||||||
|
expect(powerZone(350, ftp)?.short).toBe('Z6'); // 140%
|
||||||
|
expect(powerZone(500, ftp)?.short).toBe('Z7'); // 200%
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts a boundary value in the lower zone', () => {
|
||||||
|
// 55% of FTP is the top of zone 1, not the bottom of zone 2.
|
||||||
|
expect(powerZone(137.5, 250)?.short).toBe('Z1');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The whole contract of `ftpW: 0` — no reference, no colour. */
|
||||||
|
it('refuses to invent a zone without a reference', () => {
|
||||||
|
expect(powerZone(300, 0)).toBeNull();
|
||||||
|
expect(hrZone(150, 0)).toBeNull();
|
||||||
|
expect(percentOf(300, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not colour a rider who is not pedalling', () => {
|
||||||
|
expect(powerZone(0, 250)).toBeNull();
|
||||||
|
expect(powerZone(null, 250)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places heart rate as a fraction of maximum', () => {
|
||||||
|
expect(hrZone(100, 190)?.short).toBe('Z1'); // 53%
|
||||||
|
expect(hrZone(125, 190)?.short).toBe('Z2'); // 66%
|
||||||
|
expect(hrZone(150, 190)?.short).toBe('Z3'); // 79%
|
||||||
|
expect(hrZone(170, 190)?.short).toBe('Z4'); // 89%
|
||||||
|
expect(hrZone(185, 190)?.short).toBe('Z5'); // 97%
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the percentage the zone came from', () => {
|
||||||
|
expect(percentOf(275, 250)).toBe('110% of FTP');
|
||||||
|
});
|
||||||
|
});
|
||||||
+141
-7
@@ -1,4 +1,5 @@
|
|||||||
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
|
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
|
||||||
|
import type { Units } from './types';
|
||||||
|
|
||||||
const EM_DASH = '—';
|
const EM_DASH = '—';
|
||||||
|
|
||||||
@@ -32,11 +33,6 @@ export function finishAt(secondsFromNow: number | null | undefined): string {
|
|||||||
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
|
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function km(metres: number | null | undefined, digits = 2): string {
|
|
||||||
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
|
||||||
return (metres / 1000).toFixed(digits);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function num(v: number | null | undefined, digits = 0): string {
|
export function num(v: number | null | undefined, digits = 0): string {
|
||||||
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
||||||
return v.toFixed(digits);
|
return v.toFixed(digits);
|
||||||
@@ -51,8 +47,8 @@ export function axisLabel(unit: 'seconds' | 'metres'): string {
|
|||||||
return unit === 'metres' ? 'distance' : 'time';
|
return unit === 'metres' ? 'distance' : 'time';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function axisValue(unit: 'seconds' | 'metres', x: number): string {
|
export function axisValue(unit: 'seconds' | 'metres', x: number, units: Units): string {
|
||||||
return unit === 'metres' ? `${(x / 1000).toFixed(1)} km` : clock(x);
|
return unit === 'metres' ? `${dist(x, units, 1)} ${distUnit(units)}` : clock(x);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rssiBars(rssi: number): number {
|
export function rssiBars(rssi: number): number {
|
||||||
@@ -102,3 +98,141 @@ export function connectionText(
|
|||||||
return { label: 'Idle', tone: 'idle' };
|
return { label: 'Idle', tone: 'idle' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Units (FR-7.4's neighbour)
|
||||||
|
//
|
||||||
|
// SI everywhere behind this line. The engine computes in metres and km/h, the
|
||||||
|
// FIT file records in metres and km/h, and a rider who prefers miles changes
|
||||||
|
// *only* what the glass says. Every function here therefore takes the unit
|
||||||
|
// system as an argument rather than reading a module-level setting: pure
|
||||||
|
// functions are what let the ride screen redraw the moment the preference
|
||||||
|
// changes, and what keeps a recorded activity independent of a display choice.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const KM_PER_MILE = 1.609344;
|
||||||
|
const M_PER_FOOT = 0.3048;
|
||||||
|
const KG_PER_LB = 0.45359237;
|
||||||
|
|
||||||
|
export function distUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'mi' : 'km';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elevUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'ft' : 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speedUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'mph' : 'km/h';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function massUnit(units: Units): string {
|
||||||
|
return units === 'imperial' ? 'lb' : 'kg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres → the rider's long-distance unit. */
|
||||||
|
export function dist(metres: number | null | undefined, units: Units, digits = 2): string {
|
||||||
|
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
||||||
|
const km = metres / 1000;
|
||||||
|
return (units === 'imperial' ? km / KM_PER_MILE : km).toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres of climbing → metres or feet. Always whole: nobody climbs 0.4 m. */
|
||||||
|
export function elev(metres: number | null | undefined, units: Units): string {
|
||||||
|
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
|
||||||
|
return (units === 'imperial' ? metres / M_PER_FOOT : metres).toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speed(kph: number | null | undefined, units: Units, digits = 1): string {
|
||||||
|
if (kph == null || !Number.isFinite(kph)) return EM_DASH;
|
||||||
|
return (units === 'imperial' ? kph / KM_PER_MILE : kph).toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kilograms → kg or lb. For the settings form, which edits SI underneath. */
|
||||||
|
export function toMass(kg: number, units: Units): number {
|
||||||
|
return units === 'imperial' ? kg / KG_PER_LB : kg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromMass(value: number, units: Units): number {
|
||||||
|
return units === 'imperial' ? value * KG_PER_LB : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Effort zones
|
||||||
|
//
|
||||||
|
// A watt is a fact; a zone is what it *costs you*, and it is the difference
|
||||||
|
// between a number and a number that means something. Every professional
|
||||||
|
// training app colours effort this way and this one did not, so the biggest
|
||||||
|
// figure on the ride screen was the same shade of white at 90 W and at 400 W.
|
||||||
|
//
|
||||||
|
// Two rules hold here:
|
||||||
|
//
|
||||||
|
// 1. **No reference, no zone.** `ftp` or `maxHr` of zero means the rider has
|
||||||
|
// not told us, and an invented threshold would paint every ride with a
|
||||||
|
// confident lie. `null` is the honest answer and the callers draw the
|
||||||
|
// plain number.
|
||||||
|
// 2. **Colour never carries it alone.** Every zone has a short name that is
|
||||||
|
// rendered beside the swatch, so the meaning survives a colour-blind
|
||||||
|
// rider, a sun-washed phone and a black-and-white screenshot.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Zone {
|
||||||
|
/** One-based, as riders say them: "zone 4". */
|
||||||
|
index: number;
|
||||||
|
/** What it is called in a training plan. */
|
||||||
|
name: string;
|
||||||
|
/** Short form for a chip beside a number: "Z4". */
|
||||||
|
short: string;
|
||||||
|
/** A CSS custom property reference, so themes stay in the stylesheet. */
|
||||||
|
colour: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coggan's seven power zones, as fractions of FTP. The boundaries are the
|
||||||
|
* conventional ones — 55 / 75 / 90 / 105 / 120 / 150 % — which is what makes a
|
||||||
|
* "zone 3 ride" here mean the same thing it means in the rider's plan.
|
||||||
|
*/
|
||||||
|
const POWER_ZONES: { upto: number; zone: Zone }[] = [
|
||||||
|
{ upto: 0.55, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
|
||||||
|
{ upto: 0.75, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
|
||||||
|
{ upto: 0.9, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
|
||||||
|
{ upto: 1.05, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-4)' } },
|
||||||
|
{ upto: 1.2, zone: { index: 5, name: 'VO₂ max', short: 'Z5', colour: 'var(--zone-5)' } },
|
||||||
|
{ upto: 1.5, zone: { index: 6, name: 'Anaerobic', short: 'Z6', colour: 'var(--zone-6)' } },
|
||||||
|
{ upto: Infinity, zone: { index: 7, name: 'Sprint', short: 'Z7', colour: 'var(--zone-7)' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Five heart-rate zones as fractions of maximum. */
|
||||||
|
const HR_ZONES: { upto: number; zone: Zone }[] = [
|
||||||
|
{ upto: 0.6, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
|
||||||
|
{ upto: 0.7, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
|
||||||
|
{ upto: 0.8, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
|
||||||
|
{ upto: 0.9, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-5)' } },
|
||||||
|
{ upto: Infinity, zone: { index: 5, name: 'Maximum', short: 'Z5', colour: 'var(--zone-6)' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function zoneFor(
|
||||||
|
table: { upto: number; zone: Zone }[],
|
||||||
|
value: number | null | undefined,
|
||||||
|
reference: number,
|
||||||
|
): Zone | null {
|
||||||
|
// Not pedalling is not zone 1. A stopped rider in "Recovery" green is the
|
||||||
|
// screen claiming an effort that is not happening.
|
||||||
|
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
|
||||||
|
const ratio = value / reference;
|
||||||
|
return table.find((row) => ratio <= row.upto)?.zone ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function powerZone(watts: number | null | undefined, ftp: number): Zone | null {
|
||||||
|
return zoneFor(POWER_ZONES, watts, ftp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrZone(bpm: number | null | undefined, maxHr: number): Zone | null {
|
||||||
|
return zoneFor(HR_ZONES, bpm, maxHr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percentage of the reference, for the subtitle under a zoned number. */
|
||||||
|
export function percentOf(value: number | null | undefined, reference: number): string | null {
|
||||||
|
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return `${Math.round((value / reference) * 100)}% of FTP`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user