Core ride logic, FTMS client, FIT encoder and probe CLI
Adds backing state for Resistance and Erg control modes, which had no value to hold and so could never satisfy FR-4.3/FR-4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* One number, sized by importance. The whole ride screen is built from these,
|
||||
* which is how the visual hierarchy stays honest: importance is a prop, not a
|
||||
* pile of one-off styles.
|
||||
*/
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
size?: 'hero' | 'big' | 'mid' | 'small';
|
||||
colour?: string;
|
||||
sub?: string | null;
|
||||
dim?: boolean;
|
||||
align?: 'start' | 'end';
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
value,
|
||||
unit = '',
|
||||
size = 'mid',
|
||||
colour = 'var(--ink)',
|
||||
sub = null,
|
||||
dim = false,
|
||||
align = 'start',
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
|
||||
<span class="label">{label}</span>
|
||||
<span class="value" style:color={colour}>
|
||||
{value}{#if unit}<span class="unit">{unit}</span>{/if}
|
||||
</span>
|
||||
{#if sub}<span class="sub">{sub}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.readout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 300;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.035em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 0.34em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
margin-left: 0.22em;
|
||||
color: var(--ink-dim);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero .value {
|
||||
font-size: clamp(3.4rem, 7vw, 7.2rem);
|
||||
}
|
||||
.big .value {
|
||||
font-size: clamp(2.4rem, 4.6vw, 4.8rem);
|
||||
}
|
||||
.mid .value {
|
||||
font-size: clamp(1.6rem, 2.6vw, 2.8rem);
|
||||
font-weight: 400;
|
||||
}
|
||||
.small .value {
|
||||
font-size: clamp(1.05rem, 1.5vw, 1.65rem);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dim .value {
|
||||
color: var(--ink-dim) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The hero element: the route, with the rider's position on it.
|
||||
*
|
||||
* For a GPX or terrain profile this is a real elevation trace. For a
|
||||
* waveform profile there is no elevation, so it plots the profile's own
|
||||
* channel instead — same chart, same marker, honest label (FR-9.7).
|
||||
*
|
||||
* The ridden part is drawn bright and filled; what is still to come is dim.
|
||||
* At a glance, from a metre away, that is the one thing a rider wants: how
|
||||
* much of this is left, and does it go up.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { axis, observeSize, positionMarker } from '../lib/uplot';
|
||||
import type { ProfileView } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
profile: ProfileView | null;
|
||||
positionX: number;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
let { profile, positionX, revision }: Props = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let plot: uPlot | null = null;
|
||||
let disposeSize: (() => void) | null = null;
|
||||
let currentX = 0;
|
||||
|
||||
/** Elevation when we have it, otherwise the profile's own channel. */
|
||||
const source = $derived.by(() => {
|
||||
if (!profile) return null;
|
||||
const pairs = profile.elevation ?? profile.series;
|
||||
if (!pairs || pairs.length < 2) return null;
|
||||
return { pairs, isElevation: profile.elevation != null };
|
||||
});
|
||||
|
||||
function build(): void {
|
||||
if (!host || !source) return;
|
||||
plot?.destroy();
|
||||
plot = null;
|
||||
|
||||
const xs = source.pairs.map((p) => p[0]);
|
||||
const ys = source.pairs.map((p) => p[1]);
|
||||
const isMetres = profile?.xUnit === 'metres';
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
width: host.clientWidth || 800,
|
||||
height: host.clientHeight || 260,
|
||||
padding: [12, 8, 0, 0],
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
scales: { x: { time: false } },
|
||||
axes: [
|
||||
axis({
|
||||
values: (_u, splits) =>
|
||||
splits.map((v) => (isMetres ? `${(v / 1000).toFixed(1)}` : formatMinutes(v))),
|
||||
}),
|
||||
axis({
|
||||
side: 3,
|
||||
size: 46,
|
||||
values: (_u, splits) => splits.map((v) => v.toFixed(0)),
|
||||
}),
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
// Everything still to come.
|
||||
stroke: '#2b4d5e',
|
||||
width: 1,
|
||||
fill: 'rgba(24, 58, 76, 0.55)',
|
||||
points: { show: false },
|
||||
},
|
||||
{
|
||||
// Ridden so far.
|
||||
stroke: '#45d0ff',
|
||||
width: 2,
|
||||
fill: 'rgba(69, 208, 255, 0.22)',
|
||||
points: { show: false },
|
||||
},
|
||||
],
|
||||
plugins: [positionMarker(() => currentX, '#ffffff')],
|
||||
};
|
||||
|
||||
plot = new uPlot(opts, [xs, ys, ys.map(() => null)] as unknown as uPlot.AlignedData, host);
|
||||
disposeSize?.();
|
||||
disposeSize = observeSize(host, (w, h) => plot?.setSize({ width: w, height: h }));
|
||||
update();
|
||||
}
|
||||
|
||||
function formatMinutes(v: number): string {
|
||||
const m = Math.round(v / 60);
|
||||
return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Split the trace at the current position; only the split array changes. */
|
||||
function update(): void {
|
||||
if (!plot || !source) return;
|
||||
currentX = positionX;
|
||||
const xs = source.pairs;
|
||||
const ridden: (number | null)[] = new Array(xs.length);
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
ridden[i] = xs[i][0] <= positionX ? xs[i][1] : null;
|
||||
}
|
||||
// Carry one point past the split so the bright fill meets the marker.
|
||||
const idx = ridden.findIndex((v) => v === null);
|
||||
if (idx > 0) ridden[idx] = xs[idx][1];
|
||||
plot.setData(
|
||||
[xs.map((p) => p[0]), xs.map((p) => p[1]), ridden] as unknown as uPlot.AlignedData,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
build();
|
||||
return () => {
|
||||
disposeSize?.();
|
||||
plot?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
// Rebuild only when the route itself changes; otherwise just re-split.
|
||||
let builtFor = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
const key = profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null;
|
||||
if (key !== builtFor) {
|
||||
builtFor = key;
|
||||
if (key) build();
|
||||
else {
|
||||
plot?.destroy();
|
||||
plot = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
revision;
|
||||
positionX;
|
||||
update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="route">
|
||||
<div class="canvas" bind:this={host}></div>
|
||||
{#if !source}
|
||||
<div class="empty">
|
||||
<span class="label">No route loaded</span>
|
||||
<p>Load a GPX or a YAML profile to see the terrain ahead.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if source && !source.isElevation}
|
||||
<span class="overlay label">
|
||||
{profile?.channel} profile — no elevation
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.route {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.5rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A live streaming chart backed by a decimating `History` (NFR-3). The data
|
||||
* arrays are typed-array views that never grow, so this stays cheap for a
|
||||
* two-hour ride.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import uPlot from 'uplot';
|
||||
import { axis, observeSize } from '../lib/uplot';
|
||||
import type { History } from '../lib/history';
|
||||
|
||||
interface SeriesSpec {
|
||||
stroke: string;
|
||||
width?: number;
|
||||
fill?: string;
|
||||
dash?: number[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
history: History;
|
||||
series: SeriesSpec[];
|
||||
revision: number;
|
||||
/** Fixed y range, or null to autoscale. */
|
||||
range?: [number, number] | null;
|
||||
zeroLine?: boolean;
|
||||
}
|
||||
|
||||
let { history, series, revision, range = null, zeroLine = false }: Props = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let plot: uPlot | null = null;
|
||||
let dispose: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!host) return;
|
||||
const opts: uPlot.Options = {
|
||||
width: host.clientWidth || 400,
|
||||
height: host.clientHeight || 140,
|
||||
padding: [8, 6, 0, 0],
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: range ? { range: () => range as [number, number] } : {},
|
||||
},
|
||||
axes: [
|
||||
axis({
|
||||
values: (_u, splits) => splits.map((v) => formatClock(v)),
|
||||
}),
|
||||
axis({ side: 3, size: 42, values: (_u, splits) => splits.map((v) => v.toFixed(0)) }),
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
...series.map((s) => ({
|
||||
stroke: s.stroke,
|
||||
width: s.width ?? 1.5,
|
||||
fill: s.fill,
|
||||
dash: s.dash,
|
||||
points: { show: false },
|
||||
})),
|
||||
],
|
||||
hooks: zeroLine
|
||||
? {
|
||||
draw: [
|
||||
(u: uPlot) => {
|
||||
const y = u.valToPos(0, 'y', true);
|
||||
if (!Number.isFinite(y)) return;
|
||||
u.ctx.save();
|
||||
u.ctx.strokeStyle = '#243040';
|
||||
u.ctx.lineWidth = 1;
|
||||
u.ctx.beginPath();
|
||||
u.ctx.moveTo(u.bbox.left, y);
|
||||
u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
|
||||
u.ctx.stroke();
|
||||
u.ctx.restore();
|
||||
},
|
||||
],
|
||||
}
|
||||
: {},
|
||||
};
|
||||
plot = new uPlot(opts, history.view() as unknown as uPlot.AlignedData, host);
|
||||
dispose = observeSize(host, (w, h) => plot?.setSize({ width: w, height: h }));
|
||||
return () => {
|
||||
dispose?.();
|
||||
plot?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
function formatClock(v: number): string {
|
||||
const m = Math.floor(v / 60);
|
||||
return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}m`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
revision;
|
||||
if (plot && history.length > 1) {
|
||||
plot.setData(history.view() as unknown as uPlot.AlignedData, true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="stream" bind:this={host}></div>
|
||||
|
||||
<style>
|
||||
.stream {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user