speed chart, old logo revival, softer hillshade, peaks as side feature

This commit is contained in:
Vincent van der Wal
2026-07-22 11:49:17 +02:00
parent bca5154459
commit b288daf251
13 changed files with 314 additions and 94 deletions
+3 -3
View File
@@ -1,11 +1,11 @@
# Streba
The GPX analyser, reborn. A self-hosted web app for analysing GPX tracks and
bagging Alpine peaks.
The GPX analyser, reborn. A self-hosted web app for analysing GPX tracks -
with Alpine peak collecting on the side.
- **Accounts** - anyone can sign up; activities and climbed peaks are per user.
The home page is a public community overview: every member's tracks on one
worldmap plus a recent-activity feed. Uploading and peak bagging need an
worldmap plus a recent-activity feed. Uploading and peak tracking need an
account.
- **Upload** GPX files (drag & drop, multiple at once) - distance, ascent,
moving time and an elevation profile are computed on the spot.
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="any" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// apply the stored theme before first paint to avoid a flash
+2 -2
View File
@@ -97,8 +97,8 @@
type: 'hillshade',
source: 'hillshade-dem',
paint: {
'hillshade-exaggeration': 0.3,
'hillshade-shadow-color': dark ? '#000000' : '#5a5a50',
'hillshade-exaggeration': dark ? 0.3 : 0.22,
'hillshade-shadow-color': dark ? '#000000' : '#8b8a80',
'hillshade-highlight-color': dark ? '#3a3a38' : '#ffffff'
}
},
+245
View File
@@ -0,0 +1,245 @@
<script lang="ts">
import { browser } from '$app/environment';
let { timed }: { timed: { d: number; t: number }[] } = $props();
const WINDOWS = [
{ label: '10 s', w: 10 },
{ label: '30 s', w: 30 },
{ label: '1 min', w: 60 },
{ label: '5 min', w: 300 }
];
const STORAGE_KEY = 'streba:speed-window';
let windowS = $state(60);
if (browser) {
const stored = parseInt(localStorage.getItem(STORAGE_KEY) ?? '', 10);
if (WINDOWS.some((o) => o.w === stored)) windowS = stored;
}
function pick(w: number) {
windowS = w;
if (browser) localStorage.setItem(STORAGE_KEY, String(w));
}
/** centered rolling average: distance covered over the time window around each point */
const series = $derived.by(() => {
const half = windowS / 2;
const out: { d: number; v: number }[] = [];
let j1 = 0;
let j2 = 0;
for (let i = 0; i < timed.length; i++) {
while (j1 < i && timed[j1].t < timed[i].t - half) j1++;
while (j2 < timed.length - 1 && timed[j2 + 1].t <= timed[i].t + half) j2++;
const dt = timed[j2].t - timed[j1].t;
if (dt > 0) out.push({ d: timed[i].d, v: ((timed[j2].d - timed[j1].d) / dt) * 3.6 });
}
return out;
});
let width = $state(720);
const height = 200;
const pad = { top: 12, right: 12, bottom: 24, left: 46 };
const totalDist = $derived(series.length ? series[series.length - 1].d : 1);
const vMax = $derived(Math.max(...series.map((p) => p.v), 1));
const yTicks = $derived.by(() => {
const step = [0.5, 1, 2, 5, 10, 20, 50].find((s) => vMax / s <= 5) ?? 50;
const ticks: number[] = [];
for (let v = step; v <= vMax; v += step) ticks.push(v);
return ticks;
});
const xTicks = $derived.by(() => {
const km = totalDist / 1000;
const step = [0.5, 1, 2, 5, 10, 20, 50, 100].find((s) => km / s <= 6) ?? 100;
const ticks: number[] = [];
for (let v = 0; v <= km; v += step) ticks.push(v);
return ticks;
});
function x(d: number): number {
return pad.left + (d / totalDist) * (width - pad.left - pad.right);
}
function y(v: number): number {
return pad.top + (1 - v / vMax) * (height - pad.top - pad.bottom);
}
const linePath = $derived(
series.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.d).toFixed(1)},${y(p.v).toFixed(1)}`).join('')
);
const areaPath = $derived(
series.length
? `${linePath}L${x(totalDist).toFixed(1)},${height - pad.bottom}L${pad.left},${height - pad.bottom}Z`
: ''
);
let hover = $state<{ d: number; v: number } | null>(null);
function onmove(event: PointerEvent) {
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
const target = frac * totalDist;
let lo = 0;
let hi = series.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (series[mid].d < target) lo = mid;
else hi = mid;
}
hover = target - series[lo].d < series[hi].d - target ? series[lo] : series[hi];
}
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
</script>
<div class="head">
<span class="label">Rolling average</span>
<div class="windows" role="group" aria-label="Rolling average window">
{#each WINDOWS as option (option.w)}
<button class="win" class:on={windowS === option.w} onclick={() => pick(option.w)}>
{option.label}
</button>
{/each}
</div>
</div>
<div class="chart-wrap" bind:clientWidth={width}>
<svg viewBox="0 0 {width} {height}" role="img" aria-label="Speed profile">
{#each yTicks as tick (tick)}
<line x1={pad.left} x2={width - pad.right} y1={y(tick)} y2={y(tick)} class="grid" />
<text x={pad.left - 8} y={y(tick) + 3.5} class="tick" text-anchor="end">{tick}</text>
{/each}
{#each xTicks as tick (tick)}
<text x={x(tick * 1000)} y={height - 6} class="tick" text-anchor="middle">{tick} km</text>
{/each}
<line
x1={pad.left}
x2={width - pad.right}
y1={height - pad.bottom}
y2={height - pad.bottom}
class="baseline"
/>
<path d={areaPath} class="area" />
<path d={linePath} class="line" />
{#if hover}
<line x1={x(hover.d)} x2={x(hover.d)} y1={pad.top} y2={height - pad.bottom} class="crosshair" />
<circle cx={x(hover.d)} cy={y(hover.v)} r="5" class="dot" />
{/if}
<rect
role="presentation"
x={pad.left}
y={pad.top}
width={width - pad.left - pad.right}
height={height - pad.top - pad.bottom}
fill="transparent"
onpointermove={onmove}
onpointerleave={() => (hover = null)}
/>
</svg>
{#if hover}
<div class="tooltip" style:left="{tooltipLeft}px">
<span class="tt-value">{hover.v.toFixed(1)} km/h</span>
<span class="tt-detail">at {(hover.d / 1000).toFixed(2)} km</span>
</div>
{/if}
</div>
<style>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0 0.25rem 0.75rem;
}
.label {
font-size: 0.8rem;
color: var(--text-muted);
}
.windows {
display: flex;
gap: 0.3rem;
}
.win {
padding: 0.2rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
}
.win:hover {
background: var(--wash);
}
.win.on {
background: var(--accent-wash);
border-color: transparent;
color: var(--accent-strong);
}
.chart-wrap {
position: relative;
width: 100%;
}
svg {
display: block;
width: 100%;
}
.grid {
stroke: var(--grid);
stroke-width: 1;
}
.baseline {
stroke: var(--baseline);
stroke-width: 1;
}
.tick {
fill: var(--text-muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.area {
fill: var(--accent);
opacity: 0.1;
}
.line {
fill: none;
stroke: var(--accent);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.crosshair {
stroke: var(--baseline);
stroke-width: 1;
}
.dot {
fill: var(--accent);
stroke: var(--surface-1);
stroke-width: 2;
}
.tooltip {
position: absolute;
top: 0;
transform: translateX(-50%);
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.3rem 0.6rem;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.tt-value {
font-weight: 600;
font-size: 0.85rem;
}
.tt-detail {
color: var(--text-secondary);
font-size: 0.8rem;
margin-left: 0.35rem;
}
</style>
+1 -1
View File
@@ -300,7 +300,7 @@ export function recordAscent(
return result.changes > 0;
}
export function baggedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
export function reachedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
return db
.prepare(
`SELECT p.*, a.climbed_at FROM ascents a JOIN peaks p ON p.id = a.peak_id
+10 -13
View File
@@ -34,10 +34,7 @@
<header class="topbar">
<nav class="topbar-inner">
<a href="/" class="wordmark">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M2 20 L9 6 L13 13 L16 9 L22 20 Z" fill="currentColor" />
</svg>
Streba
<img src="/logo.png" alt="Streba" height="34" />
</a>
{#if data.user}
<div class="nav-links">
@@ -105,15 +102,9 @@
margin-right: auto;
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.02em;
color: var(--text-primary);
text-decoration: none;
}
.wordmark svg {
color: var(--accent);
.wordmark img {
display: block;
}
.nav-links {
display: flex;
@@ -136,9 +127,15 @@
background: var(--accent-wash);
color: var(--accent-strong);
}
.signup-btn {
.nav-links a.signup-btn {
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
color: #fff;
}
.nav-links a.signup-btn:hover {
background: var(--accent);
color: #fff;
filter: brightness(1.08);
}
.theme-toggle {
display: flex;
+26 -55
View File
@@ -19,7 +19,7 @@
<div>
<h1>The GPX analyser</h1>
<p class="page-sub">
Upload your tracks, analyse every climb, and bag Alpine peaks - together.
Upload your tracks, analyse every climb - and tick off Alpine peaks along the way.
</p>
</div>
<div class="hero-actions">
@@ -39,41 +39,31 @@
<h2>Worldmap</h2>
<Map tracks={data.tracks} height="440px" />
<div class="lower">
<section>
<h2>Recent activities</h2>
{#if data.activities.length === 0}
<h2>Recent activities</h2>
{#if data.activities.length === 0}
<div class="card empty">
<p>Nothing here yet - be the first to upload a track.</p>
{#if data.user}<a class="btn" href="/upload">Upload a GPX file</a>{/if}
</div>
{:else}
{:else}
<div class="card list">
{#each data.activities.slice(0, 8) as activity (activity.id)}
{#each data.activities.slice(0, 10) as activity (activity.id)}
<ActivityItem {activity} />
{/each}
</div>
{/if}
</section>
{#if data.mine}
<section>
<h2>Your peak bagging</h2>
<div class="card peak-progress">
<div class="meter-nums">
<span class="big">{data.mine.peaksClimbed}</span>
<span class="of">peak{data.mine.peaksClimbed === 1 ? '' : 's'} climbed</span>
{/if}
{#if data.mine && data.mine.peaksClimbed > 0}
<div class="card peak-strip">
<span>
⛰ You've reached <strong>{data.mine.peaksClimbed}</strong>
peak{data.mine.peaksClimbed === 1 ? '' : 's'}{#if data.mine.highestAscent},
highest: <strong>{data.mine.highestAscent.name}</strong>
({Math.round(data.mine.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m){/if}.
</span>
<a class="btn ghost" href="/peaks">Peak map</a>
</div>
{#if data.mine.highestAscent}
<p class="highest">
Highest so far: <strong>{data.mine.highestAscent.name}</strong>
({Math.round(data.mine.highestAscent.elevation_m ?? 0).toLocaleString('en-US')} m)
</p>
{/if}
<a class="btn ghost" href="/peaks">Open the peak map</a>
</div>
</section>
{/if}
</div>
{/if}
<style>
.hero {
@@ -94,17 +84,6 @@
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.lower {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 1.25rem;
align-items: start;
}
@media (max-width: 720px) {
.lower {
grid-template-columns: 1fr;
}
}
.list :global(.item:not(:last-child)) {
border-bottom: 1px solid var(--border);
}
@@ -116,26 +95,18 @@
margin: 0 0 1rem;
color: var(--text-secondary);
}
.peak-progress {
padding: 1.15rem;
.peak-strip {
margin-top: 1.25rem;
padding: 0.85rem 1.15rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
align-items: flex-start;
}
.meter-nums .big {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.meter-nums .of {
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
color: var(--text-secondary);
font-size: 0.9rem;
margin-left: 0.4rem;
}
.highest {
margin: 0;
font-size: 0.88rem;
color: var(--text-secondary);
.peak-strip strong {
color: var(--text-primary);
}
</style>
+6 -3
View File
@@ -1,5 +1,5 @@
import { error, redirect } from '@sveltejs/kit';
import { baggedPeaks, deleteActivity, getActivity } from '$lib/server/db';
import { deleteActivity, getActivity, reachedPeaks } from '$lib/server/db';
import { haversine } from '$lib/server/gpx';
import type { Actions, PageServerLoad } from './$types';
@@ -16,23 +16,26 @@ export const load: PageServerLoad = ({ params, locals }) => {
const latlngs = points.map((p) => [p.lat, p.lon] as [number, number]);
// cumulative-distance elevation profile
// cumulative-distance profiles for the elevation and speed charts
const profile: { d: number; ele: number }[] = [];
const timed: { d: number; t: number }[] = [];
let dist = 0;
for (let i = 0; i < points.length; i++) {
if (i > 0) {
dist += haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon);
}
if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! });
if (points[i].t !== null) timed.push({ d: dist, t: points[i].t! });
}
const bagged = baggedPeaks(activity.id, activity.user_id);
const bagged = reachedPeaks(activity.id, activity.user_id);
return {
activity: { ...activity, points: undefined },
canDelete: locals.user?.id === activity.user_id,
latlngs,
profile,
timed: timed.length > 2 ? timed : [],
bagged: bagged.map((p) => ({
id: p.id,
name: p.name,
+10 -2
View File
@@ -2,6 +2,7 @@
import { enhance } from '$app/forms';
import Map from '$lib/components/Map.svelte';
import ElevationChart from '$lib/components/ElevationChart.svelte';
import SpeedChart from '$lib/components/SpeedChart.svelte';
import StatTile from '$lib/components/StatTile.svelte';
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
@@ -29,7 +30,7 @@
method="POST"
action="?/delete"
use:enhance={({ cancel }) => {
if (!confirm('Delete this activity? Peaks it bagged stay climbed.')) cancel();
if (!confirm('Delete this activity? Peaks it reached stay in your list.')) cancel();
}}
>
<button class="btn danger" type="submit">Delete</button>
@@ -54,7 +55,7 @@
{#if data.bagged.length > 0}
<div class="card bagged">
This track bagged
Peaks reached:
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ', ' : ' '}<strong>{peak.name}</strong> ({peak.elevation_m} m){/each}
</div>
{/if}
@@ -73,6 +74,13 @@
</div>
{/if}
{#if data.timed.length > 2}
<h2>Speed</h2>
<div class="card chart">
<SpeedChart timed={data.timed} />
</div>
{/if}
<style>
.head {
display: flex;
+1 -1
View File
@@ -89,7 +89,7 @@
<p>
<a href="/activities/{item.id}"><strong>{item.name}</strong></a> analysed.
{#if item.newPeaks.length > 0}
<span class="bagged">New peak{item.newPeaks.length > 1 ? 's' : ''} bagged: {item.newPeaks.join(', ')}!</span>
<span class="bagged">Peak{item.newPeaks.length > 1 ? 's' : ''} reached: {item.newPeaks.join(', ')}!</span>
{/if}
</p>
{/each}
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#1c5cab" />
<path d="M3 19 L9.5 7 L13 13 L15.5 9.5 L21 19 Z" fill="#fcfcfb" />
</svg>

Before

Width:  |  Height:  |  Size: 191 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB