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:
2026-08-05 13:50:01 +02:00
co-authored by Claude Opus 5
parent 7c17ca6158
commit 3a2a787b7d
23 changed files with 3297 additions and 46 deletions
+394
View File
@@ -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 &amp; 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>