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,116 @@
|
||||
/**
|
||||
* Client-side view state. Everything here is either received from Rust or is
|
||||
* purely presentational (which screen is showing, which toast is up).
|
||||
*/
|
||||
import { api, subscribe } from './bridge';
|
||||
import { History } from './history';
|
||||
import type {
|
||||
DeviceList,
|
||||
InputAck,
|
||||
LapSummary,
|
||||
Notice,
|
||||
RideFrame,
|
||||
RideState,
|
||||
SampleProfile,
|
||||
} from './types';
|
||||
|
||||
export type Screen = 'connect' | 'ride';
|
||||
|
||||
let toastSeq = 0;
|
||||
|
||||
class AppStore {
|
||||
screen = $state<Screen>('connect');
|
||||
frame = $state<RideFrame | null>(null);
|
||||
ride = $state<RideState | null>(null);
|
||||
devices = $state<DeviceList>({ scanning: false, devices: [] });
|
||||
samples = $state<SampleProfile[]>([]);
|
||||
toasts = $state<(Notice & { id: number })[]>([]);
|
||||
lastAck = $state<(InputAck & { at: number }) | null>(null);
|
||||
lastLap = $state<LapSummary | null>(null);
|
||||
showHelp = $state(false);
|
||||
showProfiles = $state(false);
|
||||
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
|
||||
revision = $state(0);
|
||||
|
||||
/** Bounded chart history — raw power, rolling power. */
|
||||
readonly power = new History(2);
|
||||
/** Bounded chart history — commanded gradient. */
|
||||
readonly grade = new History(1);
|
||||
|
||||
private lastElapsed = -1;
|
||||
|
||||
async init(): Promise<void> {
|
||||
const [ride, devices, samples] = await Promise.all([
|
||||
api.rideState(),
|
||||
api.deviceList(),
|
||||
api.sampleProfiles(),
|
||||
]);
|
||||
this.ride = ride;
|
||||
this.devices = devices;
|
||||
this.samples = samples;
|
||||
|
||||
await subscribe({
|
||||
onFrame: (f) => this.onFrame(f),
|
||||
onRideState: (s) => {
|
||||
this.ride = s;
|
||||
},
|
||||
onDevices: (d) => {
|
||||
this.devices = d;
|
||||
},
|
||||
onLap: (l) => {
|
||||
this.lastLap = l;
|
||||
},
|
||||
onNotice: (n) => this.toast(n),
|
||||
onInputAck: (a) => {
|
||||
this.lastAck = { ...a, at: performance.now() };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private onFrame(f: RideFrame): void {
|
||||
this.frame = f;
|
||||
const t = f.snapshot.elapsed_ms / 1000;
|
||||
// The ride clock only advances while running; a paused ride should not
|
||||
// stack duplicate points onto the charts.
|
||||
if (t > this.lastElapsed) {
|
||||
this.lastElapsed = t;
|
||||
this.power.push(t, [f.snapshot.telemetry.power_w ?? 0, f.derived.rollingPowerW]);
|
||||
this.grade.push(t, [f.snapshot.gradient_pct]);
|
||||
} else if (t < this.lastElapsed) {
|
||||
this.clearHistory();
|
||||
this.lastElapsed = t;
|
||||
}
|
||||
this.revision++;
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.power.clear();
|
||||
this.grade.clear();
|
||||
this.lastElapsed = -1;
|
||||
}
|
||||
|
||||
toast(n: Notice): void {
|
||||
const entry = { ...n, id: ++toastSeq };
|
||||
this.toasts = [...this.toasts, entry];
|
||||
const ttl = n.level === 'error' ? 8000 : 4000;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== entry.id);
|
||||
}, ttl);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
/** Run a command and surface any rejection as a toast rather than silently. */
|
||||
async run<T>(fn: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
this.toast({ level: 'error', message: String(e) });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const app = new AppStore();
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The only place that talks to Rust.
|
||||
*
|
||||
* Commands are intents; they never mutate local state directly. Truth comes
|
||||
* back on the event channel (§4.3).
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type {
|
||||
ControlMode,
|
||||
DeviceInfo,
|
||||
DeviceList,
|
||||
InputAck,
|
||||
LapSummary,
|
||||
Notice,
|
||||
ProfileView,
|
||||
RideFrame,
|
||||
RideState,
|
||||
RiderConfig,
|
||||
SafetyLimits,
|
||||
SampleProfile,
|
||||
} from './types';
|
||||
|
||||
export const EVENTS = {
|
||||
snapshot: 'ride://snapshot',
|
||||
rideState: 'ride://state',
|
||||
lap: 'ride://lap',
|
||||
devices: 'devices://updated',
|
||||
connection: 'devices://connection',
|
||||
notice: 'app://notice',
|
||||
inputAck: 'app://input-ack',
|
||||
} as const;
|
||||
|
||||
/** True when running inside the Tauri shell rather than a bare browser. */
|
||||
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
|
||||
async function call<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return invoke<T>(cmd, args);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// ride lifecycle
|
||||
rideState: () => call<RideState>('ride_state'),
|
||||
start: () => call<RideState>('start_ride'),
|
||||
pause: () => call<RideState>('pause_ride'),
|
||||
resume: () => call<RideState>('resume_ride'),
|
||||
togglePause: () => call<RideState>('toggle_pause'),
|
||||
stop: () => call<RideState>('stop_ride'),
|
||||
reset: () => call<RideState>('reset_ride'),
|
||||
|
||||
// control modes and targets
|
||||
setMode: (mode: ControlMode) => call<RideState>('set_control_mode', { mode }),
|
||||
cycleMode: () => call<RideState>('cycle_control_mode'),
|
||||
nudgeGradient: (deltaPct: number) => call<RideState>('nudge_gradient', { deltaPct }),
|
||||
setGradient: (percent: number) => call<RideState>('set_gradient', { percent }),
|
||||
resetGradient: () => call<RideState>('reset_gradient'),
|
||||
setResistance: (level: number) => call<RideState>('set_target_resistance', { level }),
|
||||
setPower: (watts: number) => call<RideState>('set_target_power', { watts }),
|
||||
markLap: () => call<LapSummary>('mark_lap'),
|
||||
|
||||
// configuration
|
||||
riderConfig: () => call<RiderConfig>('rider_config'),
|
||||
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
|
||||
safetyLimits: () => call<SafetyLimits>('safety_limits'),
|
||||
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
|
||||
|
||||
// profiles
|
||||
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
|
||||
loadProfileText: (name: string, text: string, isGpx: boolean) =>
|
||||
call<ProfileView>('load_profile_from_text', { name, text, isGpx }),
|
||||
previewYaml: (yaml: string) => call<ProfileView>('preview_profile_yaml', { yaml }),
|
||||
clearProfile: () => call<RideState>('clear_profile'),
|
||||
sampleProfiles: () => call<SampleProfile[]>('sample_profiles'),
|
||||
|
||||
// devices
|
||||
deviceList: () => call<DeviceList>('device_list'),
|
||||
startScan: () => call<void>('start_scan'),
|
||||
stopScan: () => call<void>('stop_scan'),
|
||||
connect: (deviceId: string) => call<DeviceInfo>('connect_device', { deviceId }),
|
||||
disconnect: (deviceId: string) => call<DeviceInfo>('disconnect_device', { deviceId }),
|
||||
forget: (deviceId: string) => call<void>('forget_device', { deviceId }),
|
||||
trainerControllable: () => call<boolean>('trainer_controllable'),
|
||||
};
|
||||
|
||||
type Handlers = {
|
||||
onFrame?: (f: RideFrame) => void;
|
||||
onRideState?: (s: RideState) => void;
|
||||
onLap?: (l: LapSummary) => void;
|
||||
onDevices?: (d: DeviceList) => void;
|
||||
onNotice?: (n: Notice) => void;
|
||||
onInputAck?: (a: InputAck) => void;
|
||||
};
|
||||
|
||||
/** Subscribe to the whole event channel. Returns a single unsubscribe. */
|
||||
export async function subscribe(h: Handlers): Promise<UnlistenFn> {
|
||||
const offs: UnlistenFn[] = [];
|
||||
const add = async <T>(name: string, fn?: (p: T) => void) => {
|
||||
if (!fn) return;
|
||||
offs.push(await listen<T>(name, (e) => fn(e.payload)));
|
||||
};
|
||||
await add(EVENTS.snapshot, h.onFrame);
|
||||
await add(EVENTS.rideState, h.onRideState);
|
||||
await add(EVENTS.lap, h.onLap);
|
||||
await add(EVENTS.devices, h.onDevices);
|
||||
await add(EVENTS.notice, h.onNotice);
|
||||
await add(EVENTS.inputAck, h.onInputAck);
|
||||
return () => offs.forEach((off) => off());
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
|
||||
|
||||
const EM_DASH = '—';
|
||||
|
||||
export function clock(seconds: number | null | undefined): string {
|
||||
if (seconds == null || !Number.isFinite(seconds)) return EM_DASH;
|
||||
const s = Math.max(0, Math.round(seconds));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
||||
}
|
||||
|
||||
/** Shorter form for an ETA: "1h 04" / "42 min" / "38 s". */
|
||||
export function duration(seconds: number | null | undefined): string {
|
||||
if (seconds == null || !Number.isFinite(seconds)) return EM_DASH;
|
||||
const s = Math.max(0, Math.round(seconds));
|
||||
if (s >= 3600) {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
return `${h}h ${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
if (s >= 60) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
/** Wall-clock time of arrival, e.g. "14:37". */
|
||||
export function finishAt(secondsFromNow: number | null | undefined): string {
|
||||
if (secondsFromNow == null || !Number.isFinite(secondsFromNow)) return EM_DASH;
|
||||
const t = new Date(Date.now() + secondsFromNow * 1000);
|
||||
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 {
|
||||
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
||||
return v.toFixed(digits);
|
||||
}
|
||||
|
||||
export function signed(v: number | null | undefined, digits = 1): string {
|
||||
if (v == null || !Number.isFinite(v)) return EM_DASH;
|
||||
return `${v >= 0 ? '' : '−'}${Math.abs(v).toFixed(digits)}`;
|
||||
}
|
||||
|
||||
export function axisLabel(unit: 'seconds' | 'metres'): string {
|
||||
return unit === 'metres' ? 'distance' : 'time';
|
||||
}
|
||||
|
||||
export function axisValue(unit: 'seconds' | 'metres', x: number): string {
|
||||
return unit === 'metres' ? `${(x / 1000).toFixed(1)} km` : clock(x);
|
||||
}
|
||||
|
||||
export function rssiBars(rssi: number): number {
|
||||
if (rssi >= -55) return 4;
|
||||
if (rssi >= -67) return 3;
|
||||
if (rssi >= -78) return 2;
|
||||
if (rssi >= -88) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function targetText(
|
||||
target:
|
||||
| { Gradient: { percent: number } }
|
||||
| { Resistance: { level: number } }
|
||||
| { Power: { watts: number } }
|
||||
| null,
|
||||
): string {
|
||||
if (!target) return EM_DASH;
|
||||
if ('Gradient' in target) return `${signed(target.Gradient.percent, 1)}%`;
|
||||
if ('Resistance' in target) return `L${target.Resistance.level}`;
|
||||
return `${target.Power.watts} W`;
|
||||
}
|
||||
|
||||
export const MODE_LABEL: Record<string, string> = {
|
||||
ManualGrade: 'Manual grade',
|
||||
Resistance: 'Resistance',
|
||||
Profile: 'Profile',
|
||||
Erg: 'ERG',
|
||||
};
|
||||
|
||||
export function connectionText(
|
||||
state: string | { Lost: { reason: string } },
|
||||
): { label: string; tone: 'ok' | 'warn' | 'bad' | 'idle' } {
|
||||
if (typeof state !== 'string') return { label: `Lost — ${state.Lost.reason}`, tone: 'bad' };
|
||||
switch (state) {
|
||||
case 'Controlling':
|
||||
return { label: 'Connected', tone: 'ok' };
|
||||
case 'Connected':
|
||||
return { label: 'Connected', tone: 'warn' };
|
||||
case 'Connecting':
|
||||
return { label: 'Connecting', tone: 'warn' };
|
||||
case 'Reconnecting':
|
||||
return { label: 'Reconnecting', tone: 'warn' };
|
||||
case 'Scanning':
|
||||
return { label: 'Discovered', tone: 'idle' };
|
||||
default:
|
||||
return { label: 'Idle', tone: 'idle' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Bounded, self-decimating chart history (NFR-3).
|
||||
*
|
||||
* A two-hour ride at 4 Hz is 28 800 samples per series. Keeping them all is
|
||||
* both a memory leak and a rendering cost that grows through the ride — exactly
|
||||
* what NFR-3 forbids. Instead the buffer has a hard capacity: when it fills, it
|
||||
* averages adjacent pairs in place, halving the point count and doubling the
|
||||
* time each point represents. Later samples are then averaged in groups of that
|
||||
* same stride before being stored.
|
||||
*
|
||||
* The result is constant memory and a constant point count for any ride
|
||||
* length, with resolution degrading gracefully: full 250 ms detail for the
|
||||
* first ~15 minutes, 2-second buckets by two hours. Typed arrays throughout so
|
||||
* uPlot can consume them without a copy.
|
||||
*/
|
||||
|
||||
const CAPACITY = 3600;
|
||||
|
||||
export class History {
|
||||
readonly capacity: number;
|
||||
readonly seriesCount: number;
|
||||
/** Seconds since ride start. */
|
||||
readonly x: Float64Array;
|
||||
readonly y: Float64Array[];
|
||||
/** Number of populated points. */
|
||||
length = 0;
|
||||
/** Raw samples currently folded into one stored point. */
|
||||
stride = 1;
|
||||
|
||||
private pendingX = 0;
|
||||
private pendingY: Float64Array;
|
||||
private pendingN = 0;
|
||||
|
||||
constructor(seriesCount: number, capacity = CAPACITY) {
|
||||
this.capacity = capacity;
|
||||
this.seriesCount = seriesCount;
|
||||
this.x = new Float64Array(capacity);
|
||||
this.y = Array.from({ length: seriesCount }, () => new Float64Array(capacity));
|
||||
this.pendingY = new Float64Array(seriesCount);
|
||||
}
|
||||
|
||||
push(x: number, values: number[]): void {
|
||||
this.pendingX += x;
|
||||
for (let s = 0; s < this.seriesCount; s++) this.pendingY[s] += values[s] ?? 0;
|
||||
this.pendingN++;
|
||||
if (this.pendingN < this.stride) return;
|
||||
|
||||
if (this.length >= this.capacity) this.compact();
|
||||
|
||||
const i = this.length++;
|
||||
this.x[i] = this.pendingX / this.pendingN;
|
||||
for (let s = 0; s < this.seriesCount; s++) this.y[s][i] = this.pendingY[s] / this.pendingN;
|
||||
|
||||
this.pendingX = 0;
|
||||
this.pendingY.fill(0);
|
||||
this.pendingN = 0;
|
||||
}
|
||||
|
||||
/** Halve the resolution in place. O(capacity), amortised to O(1) per sample. */
|
||||
private compact(): void {
|
||||
const half = this.length >> 1;
|
||||
for (let i = 0; i < half; i++) {
|
||||
const a = i * 2;
|
||||
const b = a + 1;
|
||||
this.x[i] = (this.x[a] + this.x[b]) / 2;
|
||||
for (let s = 0; s < this.seriesCount; s++) {
|
||||
this.y[s][i] = (this.y[s][a] + this.y[s][b]) / 2;
|
||||
}
|
||||
}
|
||||
this.length = half;
|
||||
this.stride *= 2;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.length = 0;
|
||||
this.stride = 1;
|
||||
this.pendingX = 0;
|
||||
this.pendingY.fill(0);
|
||||
this.pendingN = 0;
|
||||
}
|
||||
|
||||
/** Views sized to the populated region, ready for `uPlot.setData`. */
|
||||
view(): Float64Array[] {
|
||||
return [this.x.subarray(0, this.length), ...this.y.map((a) => a.subarray(0, this.length))];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* TypeScript mirror of the Rust payloads.
|
||||
*
|
||||
* The authoritative definitions are `crates/core/src/types.rs` (frozen),
|
||||
* `src-tauri/src/events.rs`, `src-tauri/src/derive.rs` and
|
||||
* `src-tauri/src/profile_view.rs`. Nothing here is computed — these are shapes
|
||||
* that arrive over the event channel.
|
||||
*/
|
||||
|
||||
// --- crates/core/src/types.rs (frozen contract) -----------------------------
|
||||
|
||||
export interface Telemetry {
|
||||
elapsedMs: number;
|
||||
power_w: number | null;
|
||||
cadence_rpm: number | null;
|
||||
speed_kph: number | null;
|
||||
resistance_level: number | null;
|
||||
heart_rate_bpm: number | null;
|
||||
total_distance_m: number | null;
|
||||
total_energy_kcal: number | null;
|
||||
}
|
||||
|
||||
/** Serde externally-tagged enum. */
|
||||
export type ControlTarget =
|
||||
| { Gradient: { percent: number } }
|
||||
| { Resistance: { level: number } }
|
||||
| { Power: { watts: number } };
|
||||
|
||||
export type ControlMode = 'ManualGrade' | 'Resistance' | 'Profile' | 'Erg';
|
||||
|
||||
export type ConnectionState =
|
||||
| 'Idle'
|
||||
| 'Scanning'
|
||||
| 'Connecting'
|
||||
| 'Connected'
|
||||
| 'Controlling'
|
||||
| 'Reconnecting'
|
||||
| { Lost: { reason: string } };
|
||||
|
||||
export interface RideSnapshot {
|
||||
elapsed_ms: number;
|
||||
telemetry: {
|
||||
elapsed_ms: number;
|
||||
power_w: number | null;
|
||||
cadence_rpm: number | null;
|
||||
speed_kph: number | null;
|
||||
resistance_level: number | null;
|
||||
heart_rate_bpm: number | null;
|
||||
total_distance_m: number | null;
|
||||
total_energy_kcal: number | null;
|
||||
};
|
||||
virtual_speed_kph: number;
|
||||
virtual_distance_m: number;
|
||||
gradient_pct: number;
|
||||
elevation_gain_m: number;
|
||||
mode: ControlMode;
|
||||
target: ControlTarget | null;
|
||||
profile_progress: number | null;
|
||||
}
|
||||
|
||||
export interface RiderConfig {
|
||||
rider_kg: number;
|
||||
bike_kg: number;
|
||||
crr: number;
|
||||
cda: number;
|
||||
drivetrain_efficiency: number;
|
||||
air_density: number;
|
||||
wheel_circumference_m: number;
|
||||
}
|
||||
|
||||
export interface SafetyLimits {
|
||||
min_gradient_pct: number;
|
||||
max_gradient_pct: number;
|
||||
min_resistance: number;
|
||||
max_resistance: number;
|
||||
min_power_w: number;
|
||||
max_power_w: number;
|
||||
}
|
||||
|
||||
// --- src-tauri/src/derive.rs -------------------------------------------------
|
||||
|
||||
export type EtaKind = 'exact' | 'estimated' | 'held' | 'looping' | 'unavailable';
|
||||
export type XUnit = 'seconds' | 'metres';
|
||||
|
||||
export interface Derived {
|
||||
etaKind: EtaKind;
|
||||
timeRemainingS: number | null;
|
||||
distanceTotalM: number | null;
|
||||
distanceRemainingM: number | null;
|
||||
elevationM: number | null;
|
||||
ascentRemainingM: number | null;
|
||||
positionX: number;
|
||||
axisUnit: XUnit;
|
||||
axisTotal: number;
|
||||
loopIndex: number | null;
|
||||
smoothedSpeedKph: number;
|
||||
rollingPowerW: number;
|
||||
rollingPowerWindowS: number;
|
||||
avgPowerW: number;
|
||||
maxPowerW: number;
|
||||
normalisedPowerW: number | null;
|
||||
avgCadenceRpm: number;
|
||||
energyKj: number;
|
||||
}
|
||||
|
||||
/** What arrives on `ride://snapshot`. */
|
||||
export interface RideFrame {
|
||||
snapshot: RideSnapshot;
|
||||
derived: Derived;
|
||||
}
|
||||
|
||||
// --- src-tauri/src/profile_view.rs ------------------------------------------
|
||||
|
||||
export type Channel = 'gradient' | 'resistance' | 'power';
|
||||
|
||||
export interface BlockSummary {
|
||||
index: number;
|
||||
kind: 'constant' | 'ramp' | 'wave' | 'segments' | 'terrain';
|
||||
channel: Channel;
|
||||
label: string;
|
||||
startX: number;
|
||||
endX: number;
|
||||
unit: XUnit;
|
||||
}
|
||||
|
||||
export interface ProfileView {
|
||||
name: string;
|
||||
description: string | null;
|
||||
looping: boolean;
|
||||
source: string;
|
||||
channel: Channel;
|
||||
xUnit: XUnit;
|
||||
totalX: number;
|
||||
totalSeconds: number | null;
|
||||
totalMetres: number | null;
|
||||
series: [number, number][];
|
||||
elevation: [number, number][] | null;
|
||||
elevationMinM: number | null;
|
||||
elevationMaxM: number | null;
|
||||
totalAscentM: number | null;
|
||||
blocks: BlockSummary[];
|
||||
yaml: string;
|
||||
}
|
||||
|
||||
// --- src-tauri/src/events.rs -------------------------------------------------
|
||||
|
||||
export type RideStatus = 'idle' | 'running' | 'paused' | 'finished';
|
||||
|
||||
export interface LapSummary {
|
||||
index: number;
|
||||
elapsedMs: number;
|
||||
distanceM: number;
|
||||
avgPowerW: number;
|
||||
}
|
||||
|
||||
export interface RideState {
|
||||
status: RideStatus;
|
||||
mode: ControlMode;
|
||||
target: ControlTarget | null;
|
||||
gradientOffsetPct: number;
|
||||
manualGradientPct: number;
|
||||
resistanceLevel: number;
|
||||
powerTargetW: number;
|
||||
lap: number;
|
||||
laps: LapSummary[];
|
||||
profile: ProfileView | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export type DeviceKind = 'trainer' | 'clickLeft' | 'clickRight' | 'heartRate' | 'unknown';
|
||||
|
||||
export interface DeviceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
rssi: number;
|
||||
kind: DeviceKind;
|
||||
state: ConnectionState;
|
||||
controlAcquired: boolean;
|
||||
services: string[];
|
||||
remembered: boolean;
|
||||
batteryPct: number | null;
|
||||
unlockExpiresInS: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceList {
|
||||
scanning: boolean;
|
||||
devices: DeviceInfo[];
|
||||
}
|
||||
|
||||
export interface Notice {
|
||||
level: 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InputAck {
|
||||
action: string;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface SampleProfile {
|
||||
name: string;
|
||||
summary: string;
|
||||
text: string;
|
||||
isGpx: boolean;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Shared uPlot styling and sizing helpers. */
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
export const INK_DIM = '#5d6c7d';
|
||||
export const GRID = '#141a23';
|
||||
export const FONT = '600 11px Inter, system-ui, sans-serif';
|
||||
|
||||
export function axis(overrides: Partial<uPlot.Axis> = {}): uPlot.Axis {
|
||||
return {
|
||||
stroke: INK_DIM,
|
||||
font: FONT,
|
||||
labelFont: FONT,
|
||||
ticks: { stroke: GRID, width: 1, size: 4 },
|
||||
grid: { stroke: GRID, width: 1 },
|
||||
gap: 4,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep a chart sized to its container without a resize storm. */
|
||||
export function observeSize(el: HTMLElement, apply: (w: number, h: number) => void): () => void {
|
||||
let frame = 0;
|
||||
const ro = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) apply(Math.round(rect.width), Math.round(rect.height));
|
||||
});
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
ro.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical "you are here" marker, drawn straight onto the canvas after the
|
||||
* series. Cheaper and steadier than a series with one point.
|
||||
*/
|
||||
export function positionMarker(getX: () => number | null, colour: string): uPlot.Plugin {
|
||||
return {
|
||||
hooks: {
|
||||
draw: (u: uPlot) => {
|
||||
const x = getX();
|
||||
if (x == null || !Number.isFinite(x)) return;
|
||||
const left = u.valToPos(x, 'x', true);
|
||||
if (!Number.isFinite(left)) return;
|
||||
const ctx = u.ctx;
|
||||
const top = u.bbox.top;
|
||||
const bottom = u.bbox.top + u.bbox.height;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = colour;
|
||||
ctx.lineWidth = Math.max(1, Math.round(devicePixelRatio));
|
||||
ctx.moveTo(left, top);
|
||||
ctx.lineTo(left, bottom);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = colour;
|
||||
ctx.arc(left, bottom, 4 * devicePixelRatio, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user