A watt is a fact; a zone is what it costs you. The biggest number on the ride screen was the same shade of white at 90 W and at 400 W, which is a thing no training app has done in fifteen years. Zones, with two rules: - **No reference, no zone.** An unset FTP draws the plain number. A zone measured against a guessed threshold would paint every ride with a confident lie. - **Colour never carries it alone.** "Z4" renders beside the swatch, so the meaning survives a colour-blind rider, a phone in direct sun and a black-and-white screenshot. Read off the rolling average, not the instantaneous watts: at 4 Hz the raw figure crosses two boundaries every pedal stroke, and a colour that strobes is worse than no colour. Not pedalling is not zone 1. Units are a display preference applied at the last step before the glass. Everything computed, stored and recorded stays SI, so a FIT file never depends on what the screen was set to. `format.ts` takes the unit system as an argument rather than reading a module-level setting — pure functions are what let every readout redraw the moment it changes. The `km` helper is gone rather than left beside `dist`, so there is no second way to format a distance that ignores the preference. Rust's block labels lose their baked-in kilometres. The block already carries start_x and end_x and the frontend renders that span in the rider's units; a kilometre in the text sat inside a sentence saying miles everywhere else. Also on the ride screen: - The gradient gets a wedge beside the number. A signed decimal has to be read; a slope is seen. Exaggerated and clamped, because a true-scale 6% is indistinguishable from 3% at 40 px wide. - What is coming, from the profile's own block list — "2.1 km at 12% in 460 m". The chart says where the rider is; what is about to happen is what decides whether to shift now. The data was already computed Rust-side and thrown away here. Close in, the small unit reads better than a fraction of the big one. - Mode and target merge into one chip. They are a single fact, and splitting them spent a chip of header width repeating the word "target". - The pod chip no longer reports a missing `+` pod while the `−` pod is connected. The `−` pod relays its twin, so that is the intended configuration — the ride screen was calling it a fault, contradicting the device screen two keystrokes away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
398 lines
9.4 KiB
Svelte
398 lines
9.4 KiB
Svelte
<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, dist, distUnit, elev, elevUnit } 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 ? `${dist(loaded.totalMetres, app.units, 1)} ${distUnit(app.units)}` : ''}
|
|
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
|
|
{loaded.totalAscentM != null
|
|
? `· ${elev(loaded.totalAscentM, app.units)} ${elevUnit(app.units)} 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, app.units)}
|
|
</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);
|
|
}
|
|
|
|
/* One column below 900 px, from the measured size class rather than a
|
|
`max-width` of its own — see ui/src/lib/viewport.ts. */
|
|
:global([data-size='compact']) .body,
|
|
:global([data-size='medium']) .body {
|
|
grid-template-columns: 1fr;
|
|
overflow-y: auto;
|
|
}
|
|
</style>
|