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:
2026-08-21 20:15:11 +02:00
co-authored by Claude Opus 5
parent cdff678167
commit 0c757a4a15
9 changed files with 477 additions and 58 deletions
+10 -6
View File
@@ -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(),
} }
} }
+19
View File
@@ -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;
+6 -4
View File
@@ -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}
+29 -1
View File
@@ -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;
+183 -32
View File
@@ -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 -3
View File
@@ -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) {
+6 -5
View File
@@ -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)"
/> />
+70
View File
@@ -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
View File
@@ -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`;
}