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:
@@ -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>
|
||||
Reference in New Issue
Block a user