Add Svelte GUI, FIT encoder and README

Standalone binary embeds the frontend, avoiding the dev-server dependency
that made the window fail to load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:50:01 +02:00
co-authored by Claude Opus 5
parent 7c17ca6158
commit 3a2a787b7d
23 changed files with 3297 additions and 46 deletions
+346
View File
@@ -0,0 +1,346 @@
<script lang="ts">
/**
* The ride screen, route-led.
*
* The hierarchy is deliberate and is the whole design: the *route* is the
* hero, then the numbers that answer "how much longer" — ETA, distance
* remaining, speed, gradient, elevation. Power, cadence and heart rate are
* real but subordinate; they live in a single quiet strip.
*/
import { app } from '../lib/app.svelte';
import { api } from '../lib/bridge';
import {
clock,
duration,
finishAt,
km,
MODE_LABEL,
num,
signed,
targetText,
} from '../lib/format';
import ControlBar from './ControlBar.svelte';
import Readout from './Readout.svelte';
import RouteChart from './RouteChart.svelte';
import StreamChart from './StreamChart.svelte';
const frame = $derived(app.frame);
const snap = $derived(frame?.snapshot ?? null);
const d = $derived(frame?.derived ?? null);
const ride = $derived(app.ride);
const profile = $derived(ride?.profile ?? null);
const gradient = $derived(snap?.gradient_pct ?? 0);
const gradeColour = $derived(
gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
);
/** ETA presentation, driven entirely by the kind Rust reported (FR-9.15). */
const eta = $derived.by(() => {
if (!d) return { label: 'Time to go', value: '—', sub: null, dim: true };
switch (d.etaKind) {
case 'exact':
return {
label: 'Time to go',
value: duration(d.timeRemainingS),
sub: `ends ${finishAt(d.timeRemainingS)}`,
dim: false,
};
case 'estimated':
return {
label: 'ETA',
value: duration(d.timeRemainingS),
sub: `arrive ${finishAt(d.timeRemainingS)}`,
dim: false,
};
case 'held':
return {
label: 'ETA',
value: duration(d.timeRemainingS),
sub: 'held — not moving',
dim: true,
};
case 'looping':
return {
label: 'Lap',
value: String(d.loopIndex ?? 1),
sub: 'looping profile',
dim: false,
};
default:
return { label: 'ETA', value: '—', sub: 'no route loaded', dim: true };
}
});
const remaining = $derived.by(() => {
if (!d || d.distanceRemainingM == null) return null;
return d.distanceRemainingM;
});
const statusChip = $derived.by(() => {
switch (ride?.status) {
case 'running':
return { label: 'Riding', tone: 'tone-ok' };
case 'paused':
return { label: 'Paused', tone: 'tone-warn' };
case 'finished':
return { label: 'Finished', tone: 'tone-idle' };
default:
return { label: 'Ready', tone: 'tone-idle' };
}
});
</script>
<div class="ride">
<!-- Header: what is loaded, what mode, what target (FR-9.8). -->
<header>
<div class="who">
<h1>{profile?.name ?? 'No route'}</h1>
{#if profile?.description}
<p>{profile.description}</p>
{:else}
<p>Manual control — load a profile to ride terrain.</p>
{/if}
</div>
<div class="chips">
{#if ride?.source === 'mock'}
<span class="chip tone-warn"><span class="dot"></span>Simulated — no trainer</span>
{/if}
<span class="chip {statusChip.tone}"><span class="dot"></span>{statusChip.label}</span>
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
</div>
</header>
<!-- The hero. -->
<section class="route">
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
</section>
<!-- Primary readouts. -->
<section class="primary">
<Readout
label={eta.label}
value={eta.value}
size="hero"
colour="var(--route)"
sub={eta.sub}
dim={eta.dim}
/>
<Readout
label="To go"
value={remaining != null ? km(remaining, 2) : '—'}
unit={remaining != null ? 'km' : ''}
size="big"
sub={d?.distanceTotalM ? `of ${km(d.distanceTotalM, 1)} km` : null}
dim={remaining == null}
/>
<Readout
label="Speed"
value={num(d?.smoothedSpeedKph ?? 0, 1)}
unit="km/h"
size="big"
sub={`now ${num(snap?.virtual_speed_kph ?? 0, 1)}`}
/>
<Readout
label="Gradient"
value={signed(gradient, 1)}
unit="%"
size="big"
colour={gradeColour}
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
/>
</section>
<!-- Route detail. -->
<section class="detail">
<Readout
label="Elevation"
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
unit={d?.elevationM != null ? 'm' : ''}
colour="var(--climb)"
/>
<Readout
label="Climbing left"
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
unit={d?.ascentRemainingM != null ? 'm' : ''}
/>
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
</section>
<!-- Effort: present, readable, subordinate. -->
<section class="effort">
<Readout
label={`Power · ${num(d?.rollingPowerWindowS ?? 10, 0)}s`}
value={num(d?.rollingPowerW ?? 0, 0)}
unit="W"
size="mid"
colour="var(--power)"
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
/>
<Readout label="Cadence" value={num(snap?.telemetry.cadence_rpm ?? 0, 0)} unit="rpm" size="mid" />
<Readout
label="Heart rate"
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
unit="bpm"
size="mid"
/>
<Readout label="Avg power" value={num(d?.avgPowerW ?? 0, 0)} unit="W" size="small" />
<Readout
label="Normalised"
value={d?.normalisedPowerW != null ? num(d.normalisedPowerW, 0) : '—'}
unit="W"
size="small"
/>
<Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
<div class="spacer"></div>
<div class="charts">
<div class="chart">
<span class="label">Power</span>
<StreamChart
history={app.power}
revision={app.revision}
series={[
{ stroke: 'var(--power-raw)', width: 1 },
{ stroke: 'var(--power)', width: 2 },
]}
/>
</div>
<div class="chart">
<span class="label">Gradient</span>
<StreamChart
history={app.grade}
revision={app.revision}
zeroLine
series={[{ stroke: 'var(--climb)', width: 2, fill: 'rgba(255, 154, 60, 0.14)' }]}
/>
</div>
</div>
</section>
<ControlBar />
</div>
<style>
.ride {
display: grid;
grid-template-rows: auto minmax(150px, 1fr) auto auto minmax(190px, 0.95fr) auto;
height: 100%;
min-height: 0;
}
header {
display: flex;
align-items: flex-start;
gap: var(--gap);
padding: 0.9rem var(--edge) 0.6rem;
}
.who {
min-width: 0;
}
h1 {
margin: 0;
font-size: clamp(1.05rem, 1.6vw, 1.5rem);
font-weight: 600;
letter-spacing: -0.015em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.who p {
margin: 0.15rem 0 0;
font-size: 0.85rem;
color: var(--ink-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chips {
display: flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.chip.mode {
color: var(--ink);
background: #131b26;
}
.chip.target {
color: var(--route);
background: rgba(69, 208, 255, 0.1);
}
.route {
min-height: 0;
padding: 0 var(--edge);
}
.primary {
display: grid;
grid-template-columns: 1.15fr 1fr 1fr 1fr;
gap: var(--gap);
padding: 1.1rem var(--edge) 0.9rem;
align-items: end;
}
.detail {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--gap);
padding: 0 var(--edge) 1rem;
border-bottom: 1px solid var(--hairline);
}
.effort {
display: grid;
grid-template-columns: repeat(6, minmax(0, auto)) 1fr;
grid-template-rows: auto minmax(0, 1fr);
align-items: start;
gap: var(--gap);
padding: 0.9rem var(--edge) 0.4rem;
min-height: 0;
}
.spacer {
display: none;
}
.charts {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: var(--gap);
min-height: 0;
}
.chart {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.15rem;
min-height: 0;
}
@media (max-width: 1150px) {
.primary {
grid-template-columns: 1fr 1fr;
}
.detail {
grid-template-columns: repeat(3, 1fr);
}
.effort {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>