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,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>
|
||||
Reference in New Issue
Block a user