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,143 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { app } from './lib/app.svelte';
|
||||
import { api, inTauri } from './lib/bridge';
|
||||
import ConnectionScreen from './components/ConnectionScreen.svelte';
|
||||
import HelpOverlay from './components/HelpOverlay.svelte';
|
||||
import ProfileDrawer from './components/ProfileDrawer.svelte';
|
||||
import RideScreen from './components/RideScreen.svelte';
|
||||
import Toasts from './components/Toasts.svelte';
|
||||
|
||||
let ready = $state(false);
|
||||
let fatal = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!inTauri) {
|
||||
fatal = 'Not running inside the Tauri shell — start the app with `cargo tauri dev`.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await app.init();
|
||||
ready = true;
|
||||
} catch (e) {
|
||||
fatal = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Keyboard control (FR-3.19). Handled once, here, and dispatched straight to
|
||||
* Rust — the frontend never applies a step itself, it only asks.
|
||||
*/
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const step = e.shiftKey ? 2 : 0.5;
|
||||
const run = (fn: () => Promise<unknown>) => {
|
||||
e.preventDefault();
|
||||
app.run(fn);
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
return run(() => api.nudgeGradient(step));
|
||||
case 'ArrowDown':
|
||||
return run(() => api.nudgeGradient(-step));
|
||||
case '0':
|
||||
return run(() => api.resetGradient());
|
||||
case ' ':
|
||||
return run(() => api.togglePause());
|
||||
case 'm':
|
||||
case 'M':
|
||||
return run(() => api.cycleMode());
|
||||
case 'l':
|
||||
case 'L':
|
||||
return run(() => api.markLap());
|
||||
case ']':
|
||||
return run(() => bumpTarget(1));
|
||||
case '[':
|
||||
return run(() => bumpTarget(-1));
|
||||
case 'p':
|
||||
case 'P':
|
||||
e.preventDefault();
|
||||
app.showProfiles = !app.showProfiles;
|
||||
return;
|
||||
case 'd':
|
||||
case 'D':
|
||||
app.screen = 'connect';
|
||||
return;
|
||||
case 'r':
|
||||
case 'R':
|
||||
app.screen = 'ride';
|
||||
return;
|
||||
case '?':
|
||||
app.showHelp = !app.showHelp;
|
||||
return;
|
||||
case 'Escape':
|
||||
app.showHelp = false;
|
||||
app.showProfiles = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** `[` / `]` adjust whichever target the active mode actually uses. */
|
||||
async function bumpTarget(dir: number): Promise<unknown> {
|
||||
const ride = app.ride;
|
||||
if (!ride) return;
|
||||
if (ride.mode === 'Erg') return api.setPower(ride.powerTargetW + dir * 10);
|
||||
if (ride.mode === 'Resistance') return api.setResistance(ride.resistanceLevel + dir * 2);
|
||||
return api.nudgeGradient(dir * 0.5);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKey} />
|
||||
|
||||
<main>
|
||||
{#if fatal}
|
||||
<div class="fatal">
|
||||
<h1>BikeControl could not start</h1>
|
||||
<p>{fatal}</p>
|
||||
</div>
|
||||
{:else if !ready}
|
||||
<div class="boot"><span class="label">Starting…</span></div>
|
||||
{:else if app.screen === 'ride'}
|
||||
<RideScreen />
|
||||
{:else}
|
||||
<ConnectionScreen />
|
||||
{/if}
|
||||
|
||||
{#if app.showProfiles}<ProfileDrawer />{/if}
|
||||
{#if app.showHelp}<HelpOverlay />{/if}
|
||||
<Toasts />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.boot,
|
||||
.fatal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.fatal h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.fatal p {
|
||||
margin: 0;
|
||||
color: var(--ink-dim);
|
||||
max-width: 40rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Connection screen (FR-9.1–9.3).
|
||||
*
|
||||
* The one thing this screen must get right: **connected is not
|
||||
* controllable**. A trainer can be attached at the BLE level and still refuse
|
||||
* the FTMS control point, in which case nothing you do moves the resistance.
|
||||
* So every trainer row carries two independent states, side by side, and the
|
||||
* ride screen stays gated until control is real.
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { api } from '../lib/bridge';
|
||||
import { connectionText, rssiBars } from '../lib/format';
|
||||
import type { DeviceInfo, DeviceKind } from '../lib/types';
|
||||
|
||||
const devices = $derived(app.devices.devices);
|
||||
const scanning = $derived(app.devices.scanning);
|
||||
const trainerReady = $derived(
|
||||
devices.some((d) => d.kind === 'trainer' && d.controlAcquired),
|
||||
);
|
||||
|
||||
const KIND_LABEL: Record<DeviceKind, string> = {
|
||||
trainer: 'Smart trainer · FTMS',
|
||||
clickLeft: 'Zwift Click · left pod',
|
||||
clickRight: 'Zwift Click · right pod',
|
||||
heartRate: 'Heart rate monitor',
|
||||
unknown: 'Unidentified',
|
||||
};
|
||||
|
||||
function isConnected(d: DeviceInfo): boolean {
|
||||
return d.state === 'Connected' || d.state === 'Controlling';
|
||||
}
|
||||
|
||||
function busy(d: DeviceInfo): boolean {
|
||||
return d.state === 'Connecting' || d.state === 'Reconnecting';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Devices</h1>
|
||||
<p>
|
||||
{#if scanning}
|
||||
Scanning for Bluetooth peripherals…
|
||||
{:else}
|
||||
Scan stopped.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if scanning}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.stopScan())}>Stop scan</button>
|
||||
{:else}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.startScan())}>Scan</button>
|
||||
{/if}
|
||||
<button class="btn primary" onclick={() => (app.screen = 'ride')}>
|
||||
{trainerReady ? 'Go to ride' : 'Ride without a trainer'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if !trainerReady}
|
||||
<div class="gate">
|
||||
<span class="dot tone-warn"></span>
|
||||
<span
|
||||
>No trainer under control yet. The ride screen will show <strong>simulated</strong>
|
||||
telemetry until an FTMS trainer accepts the control point.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="list">
|
||||
{#each devices as device (device.id)}
|
||||
{@const conn = connectionText(device.state)}
|
||||
{@const bars = rssiBars(device.rssi)}
|
||||
<article class="row" class:live={isConnected(device)}>
|
||||
<div class="identity">
|
||||
<span class="name">{device.name}</span>
|
||||
<span class="meta">{KIND_LABEL[device.kind]} · {device.address}</span>
|
||||
{#if device.error}
|
||||
<span class="error">{device.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="signal" title="{device.rssi} dBm">
|
||||
<span class="bars">
|
||||
{#each [1, 2, 3, 4] as bar}
|
||||
<i class:on={bar <= bars} style:height="{bar * 25}%"></i>
|
||||
{/each}
|
||||
</span>
|
||||
<span class="dbm">{device.rssi}</span>
|
||||
</div>
|
||||
|
||||
<!-- Two states, never conflated (FR-9.3). -->
|
||||
<div class="states">
|
||||
<span class="state">
|
||||
<span class="label">Bluetooth</span>
|
||||
<span class="value {conn.tone === 'ok' ? 'tone-ok' : `tone-${conn.tone}`}">
|
||||
<span class="dot"></span>{busy(device) ? conn.label + '…' : conn.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if device.kind === 'trainer'}
|
||||
<span class="state">
|
||||
<span class="label">FTMS control</span>
|
||||
<span class="value" class:tone-ok={device.controlAcquired} class:tone-warn={!device.controlAcquired}>
|
||||
<span class="dot"></span>{device.controlAcquired ? 'Acquired' : 'Not acquired'}
|
||||
</span>
|
||||
</span>
|
||||
{:else if device.kind === 'clickLeft' || device.kind === 'clickRight'}
|
||||
<span class="state">
|
||||
<span class="label">Zwift unlock</span>
|
||||
<span
|
||||
class="value"
|
||||
class:tone-ok={(device.unlockExpiresInS ?? 0) > 0}
|
||||
class:tone-bad={(device.unlockExpiresInS ?? 0) <= 0}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
{#if (device.unlockExpiresInS ?? 0) > 0}
|
||||
{Math.round((device.unlockExpiresInS ?? 0) / 3600)} h left
|
||||
{:else}
|
||||
Expired — re-unlock in Zwift
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{:else if device.batteryPct != null}
|
||||
<span class="state">
|
||||
<span class="label">Battery</span>
|
||||
<span class="value tone-idle"><span class="dot"></span>{device.batteryPct}%</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
{#if isConnected(device)}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.disconnect(device.id))}>
|
||||
Disconnect
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn" disabled={busy(device)} onclick={() => app.run(() => api.connect(device.id))}>
|
||||
{busy(device) ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn ghost danger" onclick={() => app.run(() => api.forget(device.id))}>
|
||||
Forget
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
{#if devices.length === 0}
|
||||
<!-- A-4 / FR-1.8: absent is not the same as missing. -->
|
||||
<div class="empty">
|
||||
<h2>Nothing found yet</h2>
|
||||
<p>Most devices sleep until you touch them. To wake them:</p>
|
||||
<ul>
|
||||
<li><strong>Trainer</strong> — turn the pedals for a few seconds.</li>
|
||||
<li><strong>Zwift Click</strong> — press any button on the pod.</li>
|
||||
<li><strong>Heart rate strap</strong> — wet the contacts and put it on.</li>
|
||||
</ul>
|
||||
<p class="quiet">
|
||||
They will appear here as soon as they advertise. Scanning continues in the background.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--gap);
|
||||
padding: clamp(1.4rem, 3vw, 2.6rem) var(--edge) 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.6rem, 2.6vw, 2.4rem);
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0 var(--edge) 0.6rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 0.55rem;
|
||||
background: rgba(255, 207, 74, 0.06);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gate strong {
|
||||
color: var(--warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list {
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem var(--edge) 2rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) auto minmax(0, 1.4fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 1rem 0.25rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.row.live .name {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.12rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 1.15rem;
|
||||
}
|
||||
|
||||
.bars i {
|
||||
width: 3px;
|
||||
background: var(--ink-faint);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.bars i.on {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.dbm {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-dim);
|
||||
min-width: 2.3em;
|
||||
}
|
||||
|
||||
.states {
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.state .value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.empty {
|
||||
max-width: 42rem;
|
||||
margin: clamp(2rem, 8vh, 6rem) auto;
|
||||
text-align: left;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.empty h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.empty ul {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 1.1rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.empty strong {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
'identity signal'
|
||||
'states states'
|
||||
'controls controls';
|
||||
}
|
||||
.identity {
|
||||
grid-area: identity;
|
||||
}
|
||||
.signal {
|
||||
grid-area: signal;
|
||||
}
|
||||
.states {
|
||||
grid-area: states;
|
||||
}
|
||||
.controls {
|
||||
grid-area: controls;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* On-screen equivalents for every controller action (FR-3.19, FR-9.10), with
|
||||
* the keyboard shortcut shown on each so the rider learns them. Every press
|
||||
* flashes, because a rider needs to know the input registered (FR-9.9).
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { api } from '../lib/bridge';
|
||||
import { MODE_LABEL } from '../lib/format';
|
||||
|
||||
const ride = $derived(app.ride);
|
||||
const running = $derived(ride?.status === 'running');
|
||||
|
||||
let flash = $state<string | null>(null);
|
||||
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const ack = app.lastAck;
|
||||
if (!ack) return;
|
||||
flash = ack.action;
|
||||
if (flashTimer) clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => (flash = null), 220);
|
||||
});
|
||||
|
||||
const grade = (d: number) => app.run(() => api.nudgeGradient(d));
|
||||
</script>
|
||||
|
||||
<div class="bar">
|
||||
<div class="group">
|
||||
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(-0.5)}>
|
||||
<span class="glyph">−</span> Grade <span class="kbd">↓</span>
|
||||
</button>
|
||||
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(0.5)}>
|
||||
<span class="glyph">+</span> Grade <span class="kbd">↑</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn ghost"
|
||||
class:lit={flash === 'gradient-reset'}
|
||||
onclick={() => app.run(() => api.resetGradient())}
|
||||
>
|
||||
Zero <span class="kbd">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<button class="btn" class:lit={flash === 'mode'} onclick={() => app.run(() => api.cycleMode())}>
|
||||
Mode: {MODE_LABEL[ride?.mode ?? 'ManualGrade']} <span class="kbd">M</span>
|
||||
</button>
|
||||
<button class="btn ghost" onclick={() => (app.showProfiles = true)}>
|
||||
Profile <span class="kbd">P</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="group right">
|
||||
<button class="btn" class:lit={flash === 'lap'} onclick={() => app.run(() => api.markLap())}>
|
||||
Lap {ride?.lap ?? 1} <span class="kbd">L</span>
|
||||
</button>
|
||||
{#if running}
|
||||
<button class="btn" class:lit={flash === 'toggle-pause'} onclick={() => app.run(() => api.togglePause())}>
|
||||
Pause <span class="kbd">␣</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn primary" onclick={() => app.run(() => api.start())}>
|
||||
{ride?.status === 'paused' ? 'Resume' : 'Start ride'} <span class="kbd">␣</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
|
||||
<button class="btn ghost" onclick={() => (app.showHelp = !app.showHelp)} title="Keyboard shortcuts">
|
||||
<span class="kbd">?</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 0.7rem var(--edge) 0.85rem;
|
||||
border-top: 1px solid var(--hairline);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.group.right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
font-size: 1.15em;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lit {
|
||||
background: var(--route) !important;
|
||||
color: #04121a !important;
|
||||
}
|
||||
|
||||
.lit :global(.kbd) {
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: #04121a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Keyboard shortcuts (FR-3.19). These exist because there is no physical
|
||||
* controller in this phase — and because if a Click pod dies mid-ride, the
|
||||
* keyboard is the only way to keep the session going.
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
|
||||
const BINDINGS: [string, string][] = [
|
||||
['↑ / ↓', 'Gradient +0.5% / −0.5%'],
|
||||
['Shift + ↑ / ↓', 'Gradient ±2% (coarse)'],
|
||||
['0', 'Reset gradient trim to zero'],
|
||||
['Space', 'Pause / resume'],
|
||||
['M', 'Cycle control mode'],
|
||||
['L', 'Insert lap marker'],
|
||||
['P', 'Profiles and routes'],
|
||||
['D', 'Device / connection screen'],
|
||||
['R', 'Ride screen'],
|
||||
['[ / ]', 'Target down / up (resistance or ERG power)'],
|
||||
['?', 'This list'],
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="scrim"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={() => (app.showHelp = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (app.showHelp = false)}
|
||||
></div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Keyboard</h2>
|
||||
<dl>
|
||||
{#each BINDINGS as [key, what]}
|
||||
<div>
|
||||
<dt><span class="kbd">{key}</span></dt>
|
||||
<dd>{what}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
<p class="note">
|
||||
Every one of these has an on-screen equivalent in the control bar, and each will map to a
|
||||
Zwift Click button once the controller client lands.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 4, 7, 0.75);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 41;
|
||||
width: min(34rem, 92vw);
|
||||
padding: 1.6rem 1.8rem 1.4rem;
|
||||
border-radius: 0.9rem;
|
||||
background: #0a0e14;
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5rem 1fr;
|
||||
align-items: baseline;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 1.2rem 0 0;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--hairline);
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Load and inspect a profile: bundled samples, a file picker for GPX/YAML,
|
||||
* and a small editor that previews the YAML as you type (§5.5, §5.6, FR-6.7).
|
||||
*
|
||||
* Parsing and preview both happen in Rust — the editor sends text and gets a
|
||||
* `ProfileView` back, so what you see here is exactly what the ride engine
|
||||
* will do with it.
|
||||
*/
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { api } from '../lib/bridge';
|
||||
import { axisValue } from '../lib/format';
|
||||
import type { ProfileView } from '../lib/types';
|
||||
|
||||
let yaml = $state('');
|
||||
let preview = $state<ProfileView | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const loaded = $derived(app.ride?.profile ?? null);
|
||||
|
||||
function close() {
|
||||
app.showProfiles = false;
|
||||
}
|
||||
|
||||
async function refreshPreview(text: string) {
|
||||
if (!text.trim()) {
|
||||
preview = null;
|
||||
error = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
preview = await api.previewYaml(text);
|
||||
error = null;
|
||||
} catch (e) {
|
||||
preview = null;
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(text: string) {
|
||||
yaml = text;
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(() => refreshPreview(text), 220);
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: 'Route or profile', extensions: ['gpx', 'yaml', 'yml'] },
|
||||
{ name: 'All files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (typeof path === 'string') {
|
||||
const view = await app.run(() => api.loadProfilePath(path));
|
||||
if (view) {
|
||||
yaml = view.yaml;
|
||||
preview = view;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSample(name: string, text: string, isGpx: boolean) {
|
||||
const view = await app.run(() => api.loadProfileText(name, text, isGpx));
|
||||
if (view) {
|
||||
yaml = view.yaml;
|
||||
preview = view;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEdited() {
|
||||
if (!yaml.trim()) return;
|
||||
const view = await app.run(() => api.loadProfileText('Edited profile', yaml, false));
|
||||
if (view) preview = view;
|
||||
}
|
||||
|
||||
function sparkline(view: ProfileView): string {
|
||||
const pairs = view.elevation ?? view.series;
|
||||
if (!pairs || pairs.length < 2) return '';
|
||||
const step = Math.max(1, Math.floor(pairs.length / 220));
|
||||
const pts: [number, number][] = [];
|
||||
for (let i = 0; i < pairs.length; i += step) pts.push(pairs[i]);
|
||||
const xs = pts.map((p) => p[0]);
|
||||
const ys = pts.map((p) => p[1]);
|
||||
const x0 = Math.min(...xs);
|
||||
const x1 = Math.max(...xs);
|
||||
const y0 = Math.min(...ys);
|
||||
const y1 = Math.max(...ys);
|
||||
const sx = (v: number) => ((v - x0) / (x1 - x0 || 1)) * 100;
|
||||
const sy = (v: number) => 30 - ((v - y0) / (y1 - y0 || 1)) * 28;
|
||||
return `M ${pts.map((p) => `${sx(p[0]).toFixed(2)} ${sy(p[1]).toFixed(2)}`).join(' L ')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="scrim"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={close}
|
||||
onkeydown={(e) => e.key === 'Escape' && close()}
|
||||
></div>
|
||||
|
||||
<aside class="drawer">
|
||||
<header>
|
||||
<h2>Profiles & routes</h2>
|
||||
<button class="btn ghost" onclick={close}>Close <span class="kbd">Esc</span></button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<section class="pick">
|
||||
<div class="row-head">
|
||||
<span class="label">Load</span>
|
||||
<button class="btn" onclick={pickFile}>Open GPX or YAML…</button>
|
||||
</div>
|
||||
|
||||
<ul class="samples">
|
||||
{#each app.samples as s (s.name)}
|
||||
<li>
|
||||
<button class="sample" onclick={() => loadSample(s.name, s.text, s.isGpx)}>
|
||||
<span class="sample-name">
|
||||
{s.name}
|
||||
{#if s.isGpx}<span class="tag">GPX</span>{/if}
|
||||
</span>
|
||||
<span class="sample-sub">{s.summary}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if loaded}
|
||||
<div class="active">
|
||||
<span class="label">Loaded</span>
|
||||
<strong>{loaded.name}</strong>
|
||||
<span class="sample-sub">
|
||||
{loaded.totalMetres ? `${(loaded.totalMetres / 1000).toFixed(1)} km` : ''}
|
||||
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
|
||||
{loaded.totalAscentM != null ? `· ${loaded.totalAscentM.toFixed(0)} m up` : ''}
|
||||
{loaded.looping ? '· loops' : ''}
|
||||
</span>
|
||||
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
|
||||
Unload
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="edit">
|
||||
<div class="row-head">
|
||||
<span class="label">Editor — YAML profile</span>
|
||||
<button class="btn" disabled={!preview} onclick={loadEdited}>Load into ride</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
spellcheck="false"
|
||||
placeholder={'name: My profile\nblocks:\n - type: constant\n channel: gradient\n value: 4.0\n extent: { seconds: 600 }'}
|
||||
value={yaml}
|
||||
oninput={(e) => onEdit(e.currentTarget.value)}
|
||||
></textarea>
|
||||
|
||||
{#if error}
|
||||
<p class="err">{error}</p>
|
||||
{:else if preview}
|
||||
<div class="preview">
|
||||
<svg viewBox="0 0 100 32" preserveAspectRatio="none">
|
||||
<path d={sparkline(preview)} />
|
||||
</svg>
|
||||
<div class="blocks">
|
||||
{#each preview.blocks as b (b.index)}
|
||||
<span class="block">
|
||||
<em>{b.kind}</em>
|
||||
{b.label}
|
||||
<span class="quiet">
|
||||
{axisValue(b.unit, b.endX - b.startX)}
|
||||
</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 4, 7, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(56rem, 94vw);
|
||||
z-index: 21;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
background: var(--bg);
|
||||
border-left: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 1.1rem var(--edge) 0.9rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
header .btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: var(--edge);
|
||||
padding: 1.1rem var(--edge) 1.4rem;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
}
|
||||
|
||||
.row-head .btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.samples {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sample {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.7rem 0.6rem;
|
||||
border-radius: 0.45rem;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.sample:hover {
|
||||
background: var(--bg-lift);
|
||||
}
|
||||
|
||||
.sample-name {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 0.15em 0.45em;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(69, 208, 255, 0.14);
|
||||
color: var(--route);
|
||||
}
|
||||
|
||||
.sample-sub {
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.8rem 0.6rem;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.active .btn {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
min-height: 12rem;
|
||||
resize: none;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--hairline);
|
||||
background: #080b10;
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #23394a;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--bad);
|
||||
font-size: 0.85rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview svg {
|
||||
width: 100%;
|
||||
height: 4.5rem;
|
||||
}
|
||||
|
||||
.preview path {
|
||||
fill: none;
|
||||
stroke: var(--route);
|
||||
stroke-width: 0.8;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.blocks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
max-height: 6rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.28em 0.6em;
|
||||
border-radius: 0.35rem;
|
||||
background: var(--bg-lift);
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.block em {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
color: var(--ink-dim);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.body {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -113,25 +113,23 @@
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
build();
|
||||
return () => {
|
||||
disposeSize?.();
|
||||
plot?.destroy();
|
||||
};
|
||||
onMount(() => () => {
|
||||
disposeSize?.();
|
||||
plot?.destroy();
|
||||
});
|
||||
|
||||
// Rebuild only when the route itself changes; otherwise just re-split.
|
||||
let builtFor = $state<string | null>(null);
|
||||
// Rebuild only when the route itself changes; otherwise just re-split, which
|
||||
// is a single array walk rather than a chart teardown.
|
||||
let builtFor: 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;
|
||||
}
|
||||
const key = host && profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null;
|
||||
if (key === builtFor) return;
|
||||
builtFor = key;
|
||||
if (key) {
|
||||
build();
|
||||
} else {
|
||||
plot?.destroy();
|
||||
plot = null;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
: {},
|
||||
};
|
||||
plot = new uPlot(opts, history.view() as unknown as uPlot.AlignedData, host);
|
||||
plot = new uPlot(opts, seed() as unknown as uPlot.AlignedData, host);
|
||||
dispose = observeSize(host, (w, h) => plot?.setSize({ width: w, height: h }));
|
||||
return () => {
|
||||
dispose?.();
|
||||
@@ -86,6 +86,12 @@
|
||||
};
|
||||
});
|
||||
|
||||
/** uPlot dislikes zero-length series; seed with a single flat point. */
|
||||
function seed(): (Float64Array | number[])[] {
|
||||
if (history.length > 1) return history.view();
|
||||
return [[0], ...series.map(() => [0])];
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { app } from '../lib/app.svelte';
|
||||
</script>
|
||||
|
||||
<div class="toasts">
|
||||
{#each app.toasts as t (t.id)}
|
||||
<button class="toast {t.level}" onclick={() => app.dismiss(t.id)}>
|
||||
<span class="dot"></span>{t.message}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toasts {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 5.5rem;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 999px;
|
||||
background: #101720;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55);
|
||||
pointer-events: auto;
|
||||
max-width: min(60rem, 90vw);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info .dot {
|
||||
color: var(--route);
|
||||
}
|
||||
.warn .dot {
|
||||
color: var(--warn);
|
||||
}
|
||||
.error .dot {
|
||||
color: var(--bad);
|
||||
}
|
||||
.error {
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -48,6 +48,9 @@ class AppStore {
|
||||
this.ride = ride;
|
||||
this.devices = devices;
|
||||
this.samples = samples;
|
||||
// A ride already in progress (a reload, or an autostart) belongs on screen
|
||||
// immediately — nobody wants to click past a device list mid-effort.
|
||||
if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride';
|
||||
|
||||
await subscribe({
|
||||
onFrame: (f) => this.onFrame(f),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { mount } from 'svelte';
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
const target = document.getElementById('app');
|
||||
if (!target) throw new Error('#app mount point missing from index.html');
|
||||
|
||||
export default mount(App, { target });
|
||||
Reference in New Issue
Block a user