initial version of streba 2
This commit is contained in:
+120
@@ -0,0 +1,120 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--page: #f9f9f7;
|
||||
--surface-1: #fcfcfb;
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #898781;
|
||||
--grid: #e1e0d9;
|
||||
--baseline: #c3c2b7;
|
||||
--border: rgba(11, 11, 11, 0.1);
|
||||
--wash: rgba(11, 11, 11, 0.05);
|
||||
--accent: #2a78d6;
|
||||
--accent-strong: #1c5cab;
|
||||
--accent-wash: rgba(42, 120, 214, 0.1);
|
||||
--accent-track: #cde2fb;
|
||||
--good: #0ca30c;
|
||||
--good-text: #006300;
|
||||
--critical: #d03b3b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--page: #0d0d0d;
|
||||
--surface-1: #1a1a19;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--grid: #2c2c2a;
|
||||
--baseline: #383835;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--wash: rgba(255, 255, 255, 0.06);
|
||||
--accent: #3987e5;
|
||||
--accent-strong: #6da7ec;
|
||||
--accent-wash: rgba(57, 135, 229, 0.16);
|
||||
--accent-track: #184f95;
|
||||
--good: #0ca30c;
|
||||
--good-text: #0ca30c;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--page);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 2rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.925rem;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.875rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: filter 120ms;
|
||||
}
|
||||
.btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: var(--wash);
|
||||
color: var(--text-primary);
|
||||
filter: none;
|
||||
}
|
||||
.btn.danger {
|
||||
background: transparent;
|
||||
color: var(--critical);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn.danger:hover {
|
||||
background: rgba(208, 59, 59, 0.08);
|
||||
filter: none;
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { fmtDate, fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
||||
|
||||
let {
|
||||
activity
|
||||
}: {
|
||||
activity: {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
date: string | null;
|
||||
distance_m: number;
|
||||
moving_s: number | null;
|
||||
duration_s: number | null;
|
||||
elev_gain_m: number;
|
||||
};
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<a class="item" href="/activities/{activity.id}">
|
||||
<div class="head">
|
||||
<span class="name">{activity.name}</span>
|
||||
<span class="type">{activity.type}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span>{fmtDate(activity.date)}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{fmtDistance(activity.distance_m)}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{fmtElevation(activity.elev_gain_m)} ↑</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{fmtDuration(activity.moving_s ?? activity.duration_s)}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.item {
|
||||
display: block;
|
||||
padding: 0.85rem 1.15rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.item:hover {
|
||||
background: var(--wash);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-wash);
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
text-transform: capitalize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.dot {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
let { profile }: { profile: { d: number; ele: number }[] } = $props();
|
||||
|
||||
let width = $state(720);
|
||||
const height = 220;
|
||||
const pad = { top: 12, right: 12, bottom: 24, left: 46 };
|
||||
|
||||
const totalDist = $derived(profile.length ? profile[profile.length - 1].d : 1);
|
||||
const eleMin = $derived(Math.min(...profile.map((p) => p.ele)));
|
||||
const eleMax = $derived(Math.max(...profile.map((p) => p.ele)));
|
||||
|
||||
const yTicks = $derived.by(() => {
|
||||
const span = Math.max(eleMax - eleMin, 10);
|
||||
const step = [10, 20, 50, 100, 200, 250, 500, 1000].find((s) => span / s <= 5) ?? 1000;
|
||||
const start = Math.ceil(eleMin / step) * step;
|
||||
const ticks: number[] = [];
|
||||
for (let v = start; v <= eleMax; 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(ele: number): number {
|
||||
const span = Math.max(eleMax - eleMin, 10);
|
||||
return pad.top + (1 - (ele - eleMin) / span) * (height - pad.top - pad.bottom);
|
||||
}
|
||||
|
||||
const linePath = $derived(
|
||||
profile.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.d).toFixed(1)},${y(p.ele).toFixed(1)}`).join('')
|
||||
);
|
||||
const areaPath = $derived(
|
||||
`${linePath}L${x(totalDist).toFixed(1)},${height - pad.bottom}L${pad.left},${height - pad.bottom}Z`
|
||||
);
|
||||
|
||||
let hover = $state<{ d: number; ele: 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;
|
||||
// binary search closest point
|
||||
let lo = 0;
|
||||
let hi = profile.length - 1;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (profile[mid].d < target) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
hover = target - profile[lo].d < profile[hi].d - target ? profile[lo] : profile[hi];
|
||||
}
|
||||
|
||||
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
||||
</script>
|
||||
|
||||
<div class="chart-wrap" bind:clientWidth={width}>
|
||||
<svg viewBox="0 0 {width} {height}" role="img" aria-label="Elevation 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.toLocaleString('en-US')}
|
||||
</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.ele)} 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">{Math.round(hover.ele).toLocaleString('en-US')} m</span>
|
||||
<span class="tt-detail">at {(hover.d / 1000).toFixed(2)} km</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
export interface MapTrack {
|
||||
id?: number;
|
||||
name?: string;
|
||||
latlngs: [number, number][];
|
||||
}
|
||||
export interface MapPeak {
|
||||
id: number;
|
||||
name: string;
|
||||
elevation_m: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
climbed: boolean;
|
||||
}
|
||||
|
||||
const STYLE_URL = 'https://maptiler.servert.nl/styles/minimal-world-maps/style.json';
|
||||
const TRACK_COLOR = '#2a78d6';
|
||||
|
||||
let {
|
||||
tracks = [],
|
||||
peaks = [],
|
||||
height = '420px',
|
||||
onpeakclick
|
||||
}: {
|
||||
tracks?: MapTrack[];
|
||||
peaks?: MapPeak[];
|
||||
height?: string;
|
||||
onpeakclick?: (id: number) => void;
|
||||
} = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let map: import('maplibre-gl').Map | undefined;
|
||||
let lib: typeof import('maplibre-gl') | undefined;
|
||||
let markers: import('maplibre-gl').Marker[] = [];
|
||||
|
||||
function renderPeaks() {
|
||||
if (!lib || !map) return;
|
||||
for (const m of markers) m.remove();
|
||||
markers = peaks.map((p) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = `peak-marker ${p.climbed ? 'climbed' : ''}`;
|
||||
el.textContent = p.climbed ? '✓' : '▲';
|
||||
el.title = `${p.name} · ${p.elevation_m.toLocaleString('en-US')} m`;
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onpeakclick?.(p.id);
|
||||
});
|
||||
return new lib!.Marker({ element: el }).setLngLat([p.lon, p.lat]).addTo(map!);
|
||||
});
|
||||
}
|
||||
|
||||
// re-render markers when climbed state changes
|
||||
$effect(() => {
|
||||
void peaks;
|
||||
renderPeaks();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const maplibre = await import('maplibre-gl');
|
||||
if (cancelled) return;
|
||||
lib = maplibre;
|
||||
|
||||
map = new maplibre.Map({
|
||||
container,
|
||||
style: STYLE_URL,
|
||||
center: [9.5, 46.5], // the Alps
|
||||
zoom: 5,
|
||||
attributionControl: { compact: true }
|
||||
});
|
||||
map.addControl(new maplibre.NavigationControl({ showCompass: false }), 'top-right');
|
||||
|
||||
map.on('load', () => {
|
||||
if (!map) return;
|
||||
map.addSource('tracks', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: tracks.map((track) => ({
|
||||
type: 'Feature',
|
||||
properties: { name: track.name ?? '' },
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'tracks-casing',
|
||||
type: 'line',
|
||||
source: 'tracks',
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: { 'line-color': '#ffffff', 'line-width': 5, 'line-opacity': 0.6 }
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'tracks-line',
|
||||
type: 'line',
|
||||
source: 'tracks',
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: { 'line-color': TRACK_COLOR, 'line-width': 2.5 }
|
||||
});
|
||||
});
|
||||
|
||||
renderPeaks();
|
||||
|
||||
const bounds = new maplibre.LngLatBounds();
|
||||
for (const track of tracks) for (const [lat, lon] of track.latlngs) bounds.extend([lon, lat]);
|
||||
for (const p of peaks) bounds.extend([p.lon, p.lat]);
|
||||
if (!bounds.isEmpty()) {
|
||||
map.fitBounds(bounds, { padding: 40, maxZoom: 14, animate: false });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
map?.remove();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="map card" bind:this={container} style:height></div>
|
||||
|
||||
<style>
|
||||
.map {
|
||||
width: 100%;
|
||||
}
|
||||
:global(.peak-marker) {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-1);
|
||||
border: 1.5px solid var(--baseline);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
}
|
||||
:global(.peak-marker.climbed) {
|
||||
background: var(--good);
|
||||
border-color: var(--good);
|
||||
color: #fff;
|
||||
}
|
||||
:global(.maplibregl-map) {
|
||||
font: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
let { label, value, detail = '' }: { label: string; value: string; detail?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="tile card">
|
||||
<div class="label">{label}</div>
|
||||
<div class="value">{value}</div>
|
||||
{#if detail}<div class="detail">{detail}</div>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tile {
|
||||
padding: 1rem 1.15rem;
|
||||
}
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.value {
|
||||
font-size: 1.55rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.detail {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
export function fmtDistance(m: number): string {
|
||||
if (m < 1000) return `${Math.round(m)} m`;
|
||||
const km = m / 1000;
|
||||
return `${km >= 100 ? Math.round(km) : km.toFixed(1)} km`;
|
||||
}
|
||||
|
||||
export function fmtElevation(m: number | null | undefined): string {
|
||||
if (m === null || m === undefined) return '–';
|
||||
return `${Math.round(m).toLocaleString('en-US')} m`;
|
||||
}
|
||||
|
||||
export function fmtDuration(s: number | null | undefined): string {
|
||||
if (s === null || s === undefined) return '–';
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.round((s % 3600) / 60);
|
||||
if (h === 0) return `${m} min`;
|
||||
return `${h}:${String(m).padStart(2, '0')} h`;
|
||||
}
|
||||
|
||||
export function fmtDate(iso: string | null | undefined): string {
|
||||
if (!iso) return 'Unknown date';
|
||||
return new Date(iso + 'T12:00:00').toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
export function fmtSpeed(mPerS: number): string {
|
||||
return `${(mPerS * 3.6).toFixed(1)} km/h`;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { PEAKS } from './peaks-seed';
|
||||
|
||||
const DATA_DIR = process.env.STREBA_DATA_DIR ?? 'data';
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
export const db = new Database(path.join(DATA_DIR, 'streba.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'outdoor',
|
||||
date TEXT,
|
||||
distance_m REAL NOT NULL,
|
||||
duration_s REAL,
|
||||
moving_s REAL,
|
||||
elev_gain_m REAL NOT NULL DEFAULT 0,
|
||||
elev_loss_m REAL NOT NULL DEFAULT 0,
|
||||
elev_min_m REAL,
|
||||
elev_max_m REAL,
|
||||
points TEXT NOT NULL,
|
||||
bounds TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peaks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
elevation_m INTEGER NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
climbed_at TEXT,
|
||||
activity_id INTEGER REFERENCES activities(id) ON DELETE SET NULL,
|
||||
note TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
const seedPeak = db.prepare(`
|
||||
INSERT OR IGNORE INTO peaks (name, elevation_m, lat, lon, country, region)
|
||||
VALUES (@name, @elevation_m, @lat, @lon, @country, @region)
|
||||
`);
|
||||
db.transaction(() => {
|
||||
for (const peak of PEAKS) seedPeak.run(peak);
|
||||
})();
|
||||
|
||||
export interface ActivityRow {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
date: string | null;
|
||||
distance_m: number;
|
||||
duration_s: number | null;
|
||||
moving_s: number | null;
|
||||
elev_gain_m: number;
|
||||
elev_loss_m: number;
|
||||
elev_min_m: number | null;
|
||||
elev_max_m: number | null;
|
||||
points: string;
|
||||
bounds: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PeakRow {
|
||||
id: number;
|
||||
name: string;
|
||||
elevation_m: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
country: string;
|
||||
region: string;
|
||||
climbed_at: string | null;
|
||||
activity_id: number | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export function listActivities(): Omit<ActivityRow, 'points'>[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id, name, type, date, distance_m, duration_s, moving_s,
|
||||
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, bounds, created_at
|
||||
FROM activities ORDER BY date DESC, id DESC`
|
||||
)
|
||||
.all() as Omit<ActivityRow, 'points'>[];
|
||||
}
|
||||
|
||||
export function getActivity(id: number): ActivityRow | undefined {
|
||||
return db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow | undefined;
|
||||
}
|
||||
|
||||
export function deleteActivity(id: number): void {
|
||||
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function listPeaks(): PeakRow[] {
|
||||
return db.prepare('SELECT * FROM peaks ORDER BY elevation_m DESC').all() as PeakRow[];
|
||||
}
|
||||
|
||||
export function togglePeak(id: number, date?: string): PeakRow | undefined {
|
||||
const peak = db.prepare('SELECT * FROM peaks WHERE id = ?').get(id) as PeakRow | undefined;
|
||||
if (!peak) return undefined;
|
||||
if (peak.climbed_at) {
|
||||
db.prepare('UPDATE peaks SET climbed_at = NULL, activity_id = NULL WHERE id = ?').run(id);
|
||||
} else {
|
||||
db.prepare('UPDATE peaks SET climbed_at = ? WHERE id = ?').run(
|
||||
date ?? new Date().toISOString().slice(0, 10),
|
||||
id
|
||||
);
|
||||
}
|
||||
return db.prepare('SELECT * FROM peaks WHERE id = ?').get(id) as PeakRow;
|
||||
}
|
||||
|
||||
export function markPeakClimbed(peakId: number, activityId: number, date: string | null): void {
|
||||
db.prepare(
|
||||
'UPDATE peaks SET climbed_at = ?, activity_id = ? WHERE id = ? AND climbed_at IS NULL'
|
||||
).run(date ?? new Date().toISOString().slice(0, 10), activityId, peakId);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
export interface TrackPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
ele: number | null;
|
||||
/** seconds since the first timestamped point, or null */
|
||||
t: number | null;
|
||||
}
|
||||
|
||||
export interface ParsedGpx {
|
||||
name: string | null;
|
||||
type: string | null;
|
||||
date: string | null;
|
||||
points: TrackPoint[];
|
||||
distance_m: number;
|
||||
duration_s: number | null;
|
||||
moving_s: number | null;
|
||||
elev_gain_m: number;
|
||||
elev_loss_m: number;
|
||||
elev_min_m: number | null;
|
||||
elev_max_m: number | null;
|
||||
bounds: [[number, number], [number, number]];
|
||||
}
|
||||
|
||||
const EARTH_RADIUS_M = 6371000;
|
||||
|
||||
export function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const rad = Math.PI / 180;
|
||||
const dLat = (lat2 - lat1) * rad;
|
||||
const dLon = (lon2 - lon1) * rad;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
function asArray<T>(value: T | T[] | undefined): T[] {
|
||||
if (value === undefined) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/** Moving-average smoothing for elevation, to keep GPS noise out of gain totals. */
|
||||
function smooth(values: number[], window = 5): number[] {
|
||||
const half = Math.floor(window / 2);
|
||||
return values.map((_, i) => {
|
||||
const from = Math.max(0, i - half);
|
||||
const to = Math.min(values.length, i + half + 1);
|
||||
let sum = 0;
|
||||
for (let j = from; j < to; j++) sum += values[j];
|
||||
return sum / (to - from);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseGpx(xml: string): ParsedGpx {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
parseTagValue: false
|
||||
});
|
||||
const doc = parser.parse(xml);
|
||||
const gpx = doc.gpx;
|
||||
if (!gpx) throw new Error('Not a GPX file');
|
||||
|
||||
const points: TrackPoint[] = [];
|
||||
let firstTime: number | null = null;
|
||||
|
||||
for (const trk of asArray<any>(gpx.trk)) {
|
||||
for (const seg of asArray<any>(trk.trkseg)) {
|
||||
for (const pt of asArray<any>(seg.trkpt)) {
|
||||
const lat = parseFloat(pt['@_lat']);
|
||||
const lon = parseFloat(pt['@_lon']);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
const ele = pt.ele !== undefined ? parseFloat(pt.ele) : NaN;
|
||||
let t: number | null = null;
|
||||
if (pt.time) {
|
||||
const ms = Date.parse(pt.time);
|
||||
if (Number.isFinite(ms)) {
|
||||
if (firstTime === null) firstTime = ms;
|
||||
t = (ms - firstTime) / 1000;
|
||||
}
|
||||
}
|
||||
points.push({ lat, lon, ele: Number.isFinite(ele) ? ele : null, t });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length < 2) throw new Error('GPX file contains no usable track');
|
||||
|
||||
// distance + moving time
|
||||
let distance = 0;
|
||||
let moving = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1];
|
||||
const b = points[i];
|
||||
const d = haversine(a.lat, a.lon, b.lat, b.lon);
|
||||
distance += d;
|
||||
if (a.t !== null && b.t !== null) {
|
||||
const dt = b.t - a.t;
|
||||
if (dt > 0 && d / dt > 0.3) moving += dt;
|
||||
}
|
||||
}
|
||||
|
||||
// elevation stats on smoothed profile with hysteresis
|
||||
const eles = points.map((p) => p.ele).filter((e): e is number => e !== null);
|
||||
let gain = 0;
|
||||
let loss = 0;
|
||||
let min: number | null = null;
|
||||
let max: number | null = null;
|
||||
if (eles.length > 1) {
|
||||
min = Math.min(...eles);
|
||||
max = Math.max(...eles);
|
||||
const smoothed = smooth(eles);
|
||||
const threshold = 3; // metres of hysteresis
|
||||
let ref = smoothed[0];
|
||||
for (const e of smoothed) {
|
||||
const diff = e - ref;
|
||||
if (diff >= threshold) {
|
||||
gain += diff;
|
||||
ref = e;
|
||||
} else if (diff <= -threshold) {
|
||||
loss += -diff;
|
||||
ref = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const last = points[points.length - 1];
|
||||
const duration = last.t;
|
||||
|
||||
let minLat = Infinity, minLon = Infinity, maxLat = -Infinity, maxLon = -Infinity;
|
||||
for (const p of points) {
|
||||
if (p.lat < minLat) minLat = p.lat;
|
||||
if (p.lat > maxLat) maxLat = p.lat;
|
||||
if (p.lon < minLon) minLon = p.lon;
|
||||
if (p.lon > maxLon) maxLon = p.lon;
|
||||
}
|
||||
|
||||
const meta = gpx.metadata;
|
||||
const date =
|
||||
firstTime !== null
|
||||
? new Date(firstTime).toISOString().slice(0, 10)
|
||||
: meta?.time
|
||||
? String(meta.time).slice(0, 10)
|
||||
: null;
|
||||
|
||||
const trk0 = asArray<any>(gpx.trk)[0];
|
||||
return {
|
||||
name: trk0?.name ? String(trk0.name) : meta?.name ? String(meta.name) : null,
|
||||
type: trk0?.type ? String(trk0.type).toLowerCase() : null,
|
||||
date,
|
||||
points: simplify(points, 2500),
|
||||
distance_m: distance,
|
||||
duration_s: duration,
|
||||
moving_s: duration !== null ? moving : null,
|
||||
elev_gain_m: gain,
|
||||
elev_loss_m: loss,
|
||||
elev_min_m: min,
|
||||
elev_max_m: max,
|
||||
bounds: [
|
||||
[minLat, minLon],
|
||||
[maxLat, maxLon]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Downsample a track to at most `target` points, always keeping first/last
|
||||
* and locally extreme elevation points so profiles stay honest.
|
||||
*/
|
||||
function simplify(points: TrackPoint[], target: number): TrackPoint[] {
|
||||
if (points.length <= target) return points;
|
||||
const keep = new Set<number>([0, points.length - 1]);
|
||||
// local elevation extremes
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const prev = points[i - 1].ele;
|
||||
const cur = points[i].ele;
|
||||
const next = points[i + 1].ele;
|
||||
if (prev !== null && cur !== null && next !== null) {
|
||||
if ((cur > prev && cur > next) || (cur < prev && cur < next)) keep.add(i);
|
||||
}
|
||||
}
|
||||
const step = points.length / target;
|
||||
for (let i = 0; i < points.length; i += step) keep.add(Math.floor(i));
|
||||
const indexes = [...keep].sort((a, b) => a - b);
|
||||
// if extremes pushed us far over target, thin uniformly
|
||||
if (indexes.length > target * 1.5) {
|
||||
const thinned: number[] = [];
|
||||
const s = indexes.length / (target * 1.5);
|
||||
for (let i = 0; i < indexes.length; i += s) thinned.push(indexes[Math.floor(i)]);
|
||||
if (thinned[thinned.length - 1] !== points.length - 1) thinned.push(points.length - 1);
|
||||
return thinned.map((i) => points[i]);
|
||||
}
|
||||
return indexes.map((i) => points[i]);
|
||||
}
|
||||
|
||||
/** Peaks whose summit lies within `radius_m` of any track point. */
|
||||
export function matchPeaks<T extends { lat: number; lon: number }>(
|
||||
points: TrackPoint[],
|
||||
peaks: T[],
|
||||
radius_m = 150
|
||||
): T[] {
|
||||
const matched: T[] = [];
|
||||
for (const peak of peaks) {
|
||||
// cheap bounding-box prefilter (~1 degree lat = 111 km)
|
||||
const latMargin = radius_m / 111000;
|
||||
const lonMargin = radius_m / (111000 * Math.cos((peak.lat * Math.PI) / 180));
|
||||
for (const p of points) {
|
||||
if (
|
||||
Math.abs(p.lat - peak.lat) <= latMargin &&
|
||||
Math.abs(p.lon - peak.lon) <= lonMargin &&
|
||||
haversine(p.lat, p.lon, peak.lat, peak.lon) <= radius_m
|
||||
) {
|
||||
matched.push(peak);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Curated list of notable Alpine peaks - the Streba peak checklist.
|
||||
// Coordinates are approximate summit locations (WGS84).
|
||||
|
||||
export interface PeakSeed {
|
||||
name: string;
|
||||
elevation_m: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
country: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export const PEAKS: PeakSeed[] = [
|
||||
// Mont Blanc massif
|
||||
{ name: 'Mont Blanc', elevation_m: 4808, lat: 45.8326, lon: 6.8652, country: 'FR/IT', region: 'Mont Blanc Massif' },
|
||||
{ name: 'Mont Maudit', elevation_m: 4465, lat: 45.8477, lon: 6.8757, country: 'FR/IT', region: 'Mont Blanc Massif' },
|
||||
{ name: 'Mont Blanc du Tacul', elevation_m: 4248, lat: 45.8567, lon: 6.8875, country: 'FR', region: 'Mont Blanc Massif' },
|
||||
{ name: 'Grandes Jorasses', elevation_m: 4208, lat: 45.8686, lon: 6.986, country: 'FR/IT', region: 'Mont Blanc Massif' },
|
||||
{ name: 'Aiguille Verte', elevation_m: 4122, lat: 45.9346, lon: 6.97, country: 'FR', region: 'Mont Blanc Massif' },
|
||||
{ name: 'Aiguille de Bionnassay', elevation_m: 4052, lat: 45.828, lon: 6.818, country: 'FR/IT', region: 'Mont Blanc Massif' },
|
||||
{ name: 'Dent du Géant', elevation_m: 4013, lat: 45.8622, lon: 6.9518, country: 'FR/IT', region: 'Mont Blanc Massif' },
|
||||
{ name: 'Les Droites', elevation_m: 4000, lat: 45.9394, lon: 6.9847, country: 'FR', region: 'Mont Blanc Massif' },
|
||||
{ name: 'Aiguille du Dru', elevation_m: 3754, lat: 45.9325, lon: 6.9573, country: 'FR', region: 'Mont Blanc Massif' },
|
||||
|
||||
// Pennine Alps (Valais)
|
||||
{ name: 'Dufourspitze', elevation_m: 4634, lat: 45.9369, lon: 7.8669, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Nordend', elevation_m: 4609, lat: 45.9433, lon: 7.8714, country: 'CH/IT', region: 'Pennine Alps' },
|
||||
{ name: 'Zumsteinspitze', elevation_m: 4563, lat: 45.93, lon: 7.872, country: 'CH/IT', region: 'Pennine Alps' },
|
||||
{ name: 'Signalkuppe', elevation_m: 4554, lat: 45.9269, lon: 7.8768, country: 'CH/IT', region: 'Pennine Alps' },
|
||||
{ name: 'Dom', elevation_m: 4545, lat: 46.0937, lon: 7.8589, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Liskamm', elevation_m: 4527, lat: 45.9227, lon: 7.8352, country: 'CH/IT', region: 'Pennine Alps' },
|
||||
{ name: 'Weisshorn', elevation_m: 4506, lat: 46.1014, lon: 7.7159, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Täschhorn', elevation_m: 4491, lat: 46.0894, lon: 7.8617, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Matterhorn', elevation_m: 4478, lat: 45.9766, lon: 7.6585, country: 'CH/IT', region: 'Pennine Alps' },
|
||||
{ name: 'Dent Blanche', elevation_m: 4357, lat: 46.0344, lon: 7.6122, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Nadelhorn', elevation_m: 4327, lat: 46.1092, lon: 7.8639, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Grand Combin', elevation_m: 4314, lat: 45.9375, lon: 7.2986, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Lenzspitze', elevation_m: 4294, lat: 46.1042, lon: 7.8672, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Castor', elevation_m: 4223, lat: 45.9225, lon: 7.7794, country: 'CH/IT', region: 'Pennine Alps' },
|
||||
{ name: 'Zinalrothorn', elevation_m: 4221, lat: 46.0648, lon: 7.6902, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Alphubel', elevation_m: 4206, lat: 46.0632, lon: 7.8637, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Rimpfischhorn', elevation_m: 4199, lat: 46.0233, lon: 7.8845, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Strahlhorn', elevation_m: 4190, lat: 46.0125, lon: 7.901, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: "Dent d'Hérens", elevation_m: 4174, lat: 45.9702, lon: 7.6047, country: 'CH/IT', region: 'Pennine Alps' },
|
||||
{ name: 'Breithorn', elevation_m: 4164, lat: 45.9419, lon: 7.7484, country: 'CH/IT', region: 'Pennine Alps' },
|
||||
{ name: 'Bishorn', elevation_m: 4153, lat: 46.1129, lon: 7.7166, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Pollux', elevation_m: 4092, lat: 45.9298, lon: 7.7676, country: 'CH/IT', region: 'Pennine Alps' },
|
||||
{ name: 'Ober Gabelhorn', elevation_m: 4063, lat: 46.0387, lon: 7.6679, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Allalinhorn', elevation_m: 4027, lat: 46.0464, lon: 7.8964, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Weissmies', elevation_m: 4017, lat: 46.1276, lon: 8.0128, country: 'CH', region: 'Pennine Alps' },
|
||||
{ name: 'Lagginhorn', elevation_m: 4010, lat: 46.1571, lon: 8.0033, country: 'CH', region: 'Pennine Alps' },
|
||||
|
||||
// Bernese Alps
|
||||
{ name: 'Finsteraarhorn', elevation_m: 4274, lat: 46.5372, lon: 8.1261, country: 'CH', region: 'Bernese Alps' },
|
||||
{ name: 'Aletschhorn', elevation_m: 4193, lat: 46.4652, lon: 7.9938, country: 'CH', region: 'Bernese Alps' },
|
||||
{ name: 'Jungfrau', elevation_m: 4158, lat: 46.5367, lon: 7.9625, country: 'CH', region: 'Bernese Alps' },
|
||||
{ name: 'Mönch', elevation_m: 4107, lat: 46.5583, lon: 7.9977, country: 'CH', region: 'Bernese Alps' },
|
||||
{ name: 'Schreckhorn', elevation_m: 4078, lat: 46.5895, lon: 8.1188, country: 'CH', region: 'Bernese Alps' },
|
||||
{ name: 'Grosses Fiescherhorn', elevation_m: 4049, lat: 46.5519, lon: 8.0561, country: 'CH', region: 'Bernese Alps' },
|
||||
{ name: 'Eiger', elevation_m: 3967, lat: 46.5775, lon: 8.0053, country: 'CH', region: 'Bernese Alps' },
|
||||
{ name: 'Wetterhorn', elevation_m: 3690, lat: 46.6053, lon: 8.1175, country: 'CH', region: 'Bernese Alps' },
|
||||
{ name: 'Blüemlisalphorn', elevation_m: 3660, lat: 46.498, lon: 7.7683, country: 'CH', region: 'Bernese Alps' },
|
||||
|
||||
// Dauphiné / Écrins
|
||||
{ name: 'Barre des Écrins', elevation_m: 4102, lat: 44.9236, lon: 6.3597, country: 'FR', region: 'Écrins' },
|
||||
{ name: 'La Meije', elevation_m: 3983, lat: 45.0053, lon: 6.3084, country: 'FR', region: 'Écrins' },
|
||||
{ name: 'Mont Pelvoux', elevation_m: 3946, lat: 44.9089, lon: 6.3689, country: 'FR', region: 'Écrins' },
|
||||
|
||||
// Graian Alps / Vanoise
|
||||
{ name: 'Gran Paradiso', elevation_m: 4061, lat: 45.5163, lon: 7.2668, country: 'IT', region: 'Graian Alps' },
|
||||
{ name: 'Grande Casse', elevation_m: 3855, lat: 45.4025, lon: 6.8064, country: 'FR', region: 'Vanoise' },
|
||||
|
||||
// Bernina & Bregaglia
|
||||
{ name: 'Piz Bernina', elevation_m: 4049, lat: 46.3823, lon: 9.9083, country: 'CH/IT', region: 'Bernina Range' },
|
||||
{ name: 'Piz Palü', elevation_m: 3900, lat: 46.3783, lon: 9.9591, country: 'CH/IT', region: 'Bernina Range' },
|
||||
{ name: 'Piz Badile', elevation_m: 3308, lat: 46.2942, lon: 9.5851, country: 'CH/IT', region: 'Bregaglia' },
|
||||
|
||||
// Central & Eastern Switzerland
|
||||
{ name: 'Dammastock', elevation_m: 3630, lat: 46.6447, lon: 8.4204, country: 'CH', region: 'Urner Alps' },
|
||||
{ name: 'Tödi', elevation_m: 3614, lat: 46.8113, lon: 8.9151, country: 'CH', region: 'Glarus Alps' },
|
||||
{ name: 'Titlis', elevation_m: 3238, lat: 46.7716, lon: 8.4376, country: 'CH', region: 'Urner Alps' },
|
||||
{ name: 'Säntis', elevation_m: 2502, lat: 47.2493, lon: 9.343, country: 'CH', region: 'Appenzell Alps' },
|
||||
{ name: 'Pilatus', elevation_m: 2128, lat: 46.979, lon: 8.2554, country: 'CH', region: 'Emmental Alps' },
|
||||
|
||||
// Ötztal & Stubai Alps
|
||||
{ name: 'Wildspitze', elevation_m: 3768, lat: 46.8853, lon: 10.8672, country: 'AT', region: 'Ötztal Alps' },
|
||||
{ name: 'Weißkugel', elevation_m: 3739, lat: 46.7975, lon: 10.7267, country: 'AT/IT', region: 'Ötztal Alps' },
|
||||
{ name: 'Similaun', elevation_m: 3599, lat: 46.7658, lon: 10.9033, country: 'AT/IT', region: 'Ötztal Alps' },
|
||||
{ name: 'Zuckerhütl', elevation_m: 3507, lat: 46.9633, lon: 11.1522, country: 'AT', region: 'Stubai Alps' },
|
||||
|
||||
// Ortler Alps
|
||||
{ name: 'Ortler', elevation_m: 3905, lat: 46.5089, lon: 10.5429, country: 'IT', region: 'Ortler Alps' },
|
||||
{ name: 'Königspitze', elevation_m: 3851, lat: 46.4794, lon: 10.5675, country: 'IT', region: 'Ortler Alps' },
|
||||
|
||||
// Zillertal Alps & Hohe Tauern
|
||||
{ name: 'Hochfeiler', elevation_m: 3509, lat: 46.9722, lon: 11.7267, country: 'AT/IT', region: 'Zillertal Alps' },
|
||||
{ name: 'Großglockner', elevation_m: 3798, lat: 47.0742, lon: 12.6947, country: 'AT', region: 'Hohe Tauern' },
|
||||
{ name: 'Großvenediger', elevation_m: 3657, lat: 47.1092, lon: 12.3464, country: 'AT', region: 'Hohe Tauern' },
|
||||
|
||||
// Northern Limestone Alps
|
||||
{ name: 'Zugspitze', elevation_m: 2962, lat: 47.4211, lon: 10.9853, country: 'DE/AT', region: 'Wetterstein' },
|
||||
{ name: 'Watzmann', elevation_m: 2713, lat: 47.555, lon: 12.92, country: 'DE', region: 'Berchtesgaden Alps' },
|
||||
{ name: 'Hoher Dachstein', elevation_m: 2995, lat: 47.4753, lon: 13.606, country: 'AT', region: 'Dachstein' },
|
||||
{ name: 'Hochkönig', elevation_m: 2941, lat: 47.4203, lon: 13.0631, country: 'AT', region: 'Berchtesgaden Alps' },
|
||||
{ name: 'Birkkarspitze', elevation_m: 2749, lat: 47.4116, lon: 11.437, country: 'AT', region: 'Karwendel' },
|
||||
{ name: 'Parseierspitze', elevation_m: 3036, lat: 47.1758, lon: 10.4712, country: 'AT', region: 'Lechtal Alps' },
|
||||
|
||||
// Rätikon & Silvretta
|
||||
{ name: 'Schesaplana', elevation_m: 2964, lat: 47.0533, lon: 9.7078, country: 'AT/CH', region: 'Rätikon' },
|
||||
{ name: 'Piz Buin', elevation_m: 3312, lat: 46.8442, lon: 10.1188, country: 'AT/CH', region: 'Silvretta' },
|
||||
|
||||
// Dolomites
|
||||
{ name: 'Marmolada', elevation_m: 3343, lat: 46.4343, lon: 11.8514, country: 'IT', region: 'Dolomites' },
|
||||
{ name: 'Antelao', elevation_m: 3264, lat: 46.4517, lon: 12.262, country: 'IT', region: 'Dolomites' },
|
||||
{ name: 'Langkofel', elevation_m: 3181, lat: 46.5175, lon: 11.7061, country: 'IT', region: 'Dolomites' },
|
||||
{ name: 'Monte Pelmo', elevation_m: 3168, lat: 46.4172, lon: 12.1219, country: 'IT', region: 'Dolomites' },
|
||||
{ name: 'Cima Grande di Lavaredo', elevation_m: 2999, lat: 46.6188, lon: 12.3025, country: 'IT', region: 'Dolomites' },
|
||||
|
||||
// Julian Alps
|
||||
{ name: 'Triglav', elevation_m: 2864, lat: 46.3783, lon: 13.8367, country: 'SI', region: 'Julian Alps' }
|
||||
];
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import '@fontsource-variable/inter';
|
||||
import '../app.css';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const links = [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/activities', label: 'Activities' },
|
||||
{ href: '/peaks', label: 'Peaks' },
|
||||
{ href: '/upload', label: 'Upload' }
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return page.url.pathname === '/';
|
||||
return page.url.pathname.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Streba</title>
|
||||
</svelte:head>
|
||||
|
||||
<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
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
{#each links as link (link.href)}
|
||||
<a href={link.href} class:active={isActive(link.href)}>{link.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="footer">Streba - the GPX analyser</footer>
|
||||
|
||||
<style>
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1100;
|
||||
background: color-mix(in srgb, var(--surface-1) 82%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-inner {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wordmark {
|
||||
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);
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.nav-links a {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 120ms, color 120ms;
|
||||
}
|
||||
.nav-links a:hover {
|
||||
background: var(--wash);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-links a.active {
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
main {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
}
|
||||
.footer {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 2.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { db, listActivities, listPeaks } from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
const activities = listActivities();
|
||||
const peaks = listPeaks();
|
||||
|
||||
// all tracks, thinned for the overview map
|
||||
const rows = db.prepare('SELECT id, name, points FROM activities').all() as {
|
||||
id: number;
|
||||
name: string;
|
||||
points: string;
|
||||
}[];
|
||||
const tracks = rows.map((row) => {
|
||||
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
|
||||
const step = Math.max(1, Math.floor(points.length / 300));
|
||||
const latlngs = points
|
||||
.filter((_, i) => i % step === 0 || i === points.length - 1)
|
||||
.map((p) => [p.lat, p.lon] as [number, number]);
|
||||
return { id: row.id, name: row.name, latlngs };
|
||||
});
|
||||
|
||||
return {
|
||||
activities,
|
||||
tracks,
|
||||
totals: {
|
||||
count: activities.length,
|
||||
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0),
|
||||
elev_gain_m: activities.reduce((sum, a) => sum + a.elev_gain_m, 0),
|
||||
moving_s: activities.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
|
||||
},
|
||||
peaksClimbed: peaks.filter((p) => p.climbed_at).length,
|
||||
peaksTotal: peaks.length
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import Map from '$lib/components/Map.svelte';
|
||||
import StatTile from '$lib/components/StatTile.svelte';
|
||||
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
||||
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard · Streba</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Dashboard</h1>
|
||||
<p class="page-sub">Every track you've recorded, in one place.</p>
|
||||
|
||||
{#if data.totals.count === 0}
|
||||
<div class="card empty">
|
||||
<p><strong>Welcome to Streba.</strong></p>
|
||||
<p>Upload your first GPX track to get going, or start ticking off Alpine peaks right away.</p>
|
||||
<div class="empty-actions">
|
||||
<a class="btn" href="/upload">Upload a GPX file</a>
|
||||
<a class="btn ghost" href="/peaks">Browse the peaks</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="kpis">
|
||||
<StatTile label="Activities" value={String(data.totals.count)} />
|
||||
<StatTile label="Total distance" value={fmtDistance(data.totals.distance_m)} />
|
||||
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
|
||||
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
|
||||
</div>
|
||||
|
||||
<h2>Worldmap</h2>
|
||||
<Map tracks={data.tracks} height="440px" />
|
||||
|
||||
<div class="lower">
|
||||
<section>
|
||||
<h2>Recent activities</h2>
|
||||
<div class="card list">
|
||||
{#each data.activities.slice(0, 6) as activity (activity.id)}
|
||||
<ActivityItem {activity} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Peak bagging</h2>
|
||||
<div class="card peak-progress">
|
||||
<div class="meter-nums">
|
||||
<span class="big">{data.peaksClimbed}</span>
|
||||
<span class="of">of {data.peaksTotal} peaks climbed</span>
|
||||
</div>
|
||||
<div class="meter" role="meter" aria-valuemin="0" aria-valuemax={data.peaksTotal} aria-valuenow={data.peaksClimbed} aria-label="Peaks climbed">
|
||||
<div class="fill" style:width="{(data.peaksClimbed / data.peaksTotal) * 100}%"></div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/peaks">Open the checklist</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.empty {
|
||||
padding: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty p {
|
||||
margin: 0.25rem 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.empty p strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.peak-progress {
|
||||
padding: 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 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
.meter {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-track);
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
min-width: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { listActivities } from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { activities: listActivities() };
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Activities · Streba</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Activities</h1>
|
||||
<p class="page-sub">
|
||||
{data.activities.length} recorded {data.activities.length === 1 ? 'activity' : 'activities'}.
|
||||
</p>
|
||||
|
||||
{#if data.activities.length === 0}
|
||||
<div class="card empty">
|
||||
<p>No activities yet.</p>
|
||||
<a class="btn" href="/upload">Upload a GPX file</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card list">
|
||||
{#each data.activities as activity (activity.id)}
|
||||
<ActivityItem {activity} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list :global(.item:not(:last-child)) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.empty {
|
||||
padding: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { db, deleteActivity, getActivity } from '$lib/server/db';
|
||||
import { haversine } from '$lib/server/gpx';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import type { PeakRow } from '$lib/server/db';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
const activity = getActivity(Number(params.id));
|
||||
if (!activity) error(404, 'Activity not found');
|
||||
|
||||
const points = JSON.parse(activity.points) as {
|
||||
lat: number;
|
||||
lon: number;
|
||||
ele: number | null;
|
||||
t: number | null;
|
||||
}[];
|
||||
|
||||
const latlngs = points.map((p) => [p.lat, p.lon] as [number, number]);
|
||||
|
||||
// cumulative-distance elevation profile
|
||||
const profile: { d: number; ele: 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! });
|
||||
}
|
||||
|
||||
const bagged = db
|
||||
.prepare('SELECT * FROM peaks WHERE activity_id = ?')
|
||||
.all(activity.id) as PeakRow[];
|
||||
|
||||
return {
|
||||
activity: { ...activity, points: undefined },
|
||||
latlngs,
|
||||
profile,
|
||||
bagged: bagged.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
elevation_m: p.elevation_m,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
climbed: true
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
delete: async ({ params }) => {
|
||||
deleteActivity(Number(params.id));
|
||||
redirect(303, '/activities');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Map from '$lib/components/Map.svelte';
|
||||
import ElevationChart from '$lib/components/ElevationChart.svelte';
|
||||
import StatTile from '$lib/components/StatTile.svelte';
|
||||
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
|
||||
|
||||
let { data } = $props();
|
||||
const a = $derived(data.activity);
|
||||
|
||||
const avgSpeed = $derived(
|
||||
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{a.name} · Streba</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1>{a.name}</h1>
|
||||
<p class="page-sub">{fmtDate(a.date)} · <span class="type">{a.type}</span></p>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm('Delete this activity? Peaks it bagged stay climbed.')) cancel();
|
||||
}}
|
||||
>
|
||||
<button class="btn danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
|
||||
<StatTile label="Ascent" value={fmtElevation(a.elev_gain_m)} detail="↓ {fmtElevation(a.elev_loss_m)}" />
|
||||
<StatTile
|
||||
label="Moving time"
|
||||
value={fmtDuration(a.moving_s ?? a.duration_s)}
|
||||
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ''}
|
||||
/>
|
||||
{#if avgSpeed}
|
||||
<StatTile label="Avg moving speed" value={avgSpeed} />
|
||||
{:else}
|
||||
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.bagged.length > 0}
|
||||
<div class="card bagged">
|
||||
⛰ This track bagged
|
||||
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ', ' : ' '}<strong>{peak.name}</strong> ({peak.elevation_m} m){/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2>Map</h2>
|
||||
<Map tracks={[{ id: a.id, name: a.name, latlngs: data.latlngs }]} peaks={data.bagged} height="440px" />
|
||||
|
||||
{#if data.profile.length > 1}
|
||||
<h2>Elevation profile</h2>
|
||||
<div class="card chart">
|
||||
<ElevationChart profile={data.profile} />
|
||||
<div class="chart-meta">
|
||||
<span>Low {fmtElevation(a.elev_min_m)}</span>
|
||||
<span>High {fmtElevation(a.elev_max_m)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
.type {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.bagged {
|
||||
margin-top: 1rem;
|
||||
padding: 0.85rem 1.15rem;
|
||||
border-left: 3px solid var(--good);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.bagged strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.chart {
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
}
|
||||
.chart-meta {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
padding: 0.5rem 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { togglePeak } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = ({ params }) => {
|
||||
const peak = togglePeak(Number(params.id));
|
||||
if (!peak) error(404, 'Peak not found');
|
||||
return json(peak);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { listPeaks } from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { peaks: listPeaks() };
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import Map from '$lib/components/Map.svelte';
|
||||
import { fmtDate } from '$lib/format';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally -- local copy so toggles update instantly; resynced below
|
||||
let peaks = $state(data.peaks);
|
||||
$effect(() => {
|
||||
peaks = data.peaks;
|
||||
});
|
||||
let filter: 'all' | 'climbed' | 'remaining' = $state('all');
|
||||
|
||||
const climbed = $derived(peaks.filter((p) => p.climbed_at).length);
|
||||
const highest = $derived(peaks.filter((p) => p.climbed_at).sort((a, b) => b.elevation_m - a.elevation_m)[0]);
|
||||
|
||||
const mapPeaks = $derived(
|
||||
peaks.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
elevation_m: p.elevation_m,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
climbed: !!p.climbed_at
|
||||
}))
|
||||
);
|
||||
|
||||
const regions = $derived.by(() => {
|
||||
const visible = peaks.filter((p) =>
|
||||
filter === 'all' ? true : filter === 'climbed' ? !!p.climbed_at : !p.climbed_at
|
||||
);
|
||||
const byRegion = new globalThis.Map<string, typeof visible>();
|
||||
for (const p of visible) {
|
||||
if (!byRegion.has(p.region)) byRegion.set(p.region, []);
|
||||
byRegion.get(p.region)!.push(p);
|
||||
}
|
||||
return [...byRegion.entries()].sort(
|
||||
(a, b) => Math.max(...b[1].map((p) => p.elevation_m)) - Math.max(...a[1].map((p) => p.elevation_m))
|
||||
);
|
||||
});
|
||||
|
||||
async function toggle(id: number) {
|
||||
const res = await fetch(`/api/peaks/${id}/toggle`, { method: 'POST' });
|
||||
if (!res.ok) return;
|
||||
const updated = await res.json();
|
||||
peaks = peaks.map((p) => (p.id === id ? updated : p));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Peaks · Streba</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Alpine peaks</h1>
|
||||
<p class="page-sub">
|
||||
{climbed} of {peaks.length} peaks climbed{#if highest} · highest so far: {highest.name} ({highest.elevation_m.toLocaleString('en-US')} m){/if}.
|
||||
Click a peak on the map or in the list to cross it off.
|
||||
</p>
|
||||
|
||||
<div class="meter" role="meter" aria-valuemin="0" aria-valuemax={peaks.length} aria-valuenow={climbed} aria-label="Peaks climbed">
|
||||
<div class="fill" style:width="{(climbed / peaks.length) * 100}%"></div>
|
||||
</div>
|
||||
|
||||
<Map peaks={mapPeaks} height="480px" onpeakclick={toggle} />
|
||||
|
||||
<div class="filters" role="group" aria-label="Filter peaks">
|
||||
{#each [['all', 'All'], ['remaining', 'To climb'], ['climbed', 'Climbed']] as [key, label] (key)}
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:on={filter === key}
|
||||
onclick={() => (filter = key as typeof filter)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each regions as [region, list] (region)}
|
||||
<h2>{region}</h2>
|
||||
<div class="card grid">
|
||||
{#each list as peak (peak.id)}
|
||||
<button class="peak" class:done={peak.climbed_at} onclick={() => toggle(peak.id)}>
|
||||
<span class="check" aria-hidden="true">{peak.climbed_at ? '✓' : ''}</span>
|
||||
<span class="info">
|
||||
<span class="name">{peak.name}</span>
|
||||
<span class="sub">
|
||||
{peak.elevation_m.toLocaleString('en-US')} m · {peak.country}
|
||||
{#if peak.climbed_at}
|
||||
· climbed {fmtDate(peak.climbed_at)}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card empty-filter">Nothing here - try another filter.</div>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.meter {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-track);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
min-width: 2px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-btn:hover {
|
||||
background: var(--wash);
|
||||
}
|
||||
.filter-btn.on {
|
||||
background: var(--accent-wash);
|
||||
border-color: transparent;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
.peak {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.peak:hover {
|
||||
background: var(--wash);
|
||||
}
|
||||
.check {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid var(--baseline);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.peak.done .check {
|
||||
background: var(--good);
|
||||
border-color: var(--good);
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.peak.done .name {
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-decoration-color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty-filter {
|
||||
margin-top: 1rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { db, listPeaks, markPeakClimbed } from '$lib/server/db';
|
||||
import { matchPeaks, parseGpx } from '$lib/server/gpx';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const files = form.getAll('gpx').filter((f): f is File => f instanceof File && f.size > 0);
|
||||
if (files.length === 0) return fail(400, { error: 'No GPX files selected.' });
|
||||
|
||||
const uploaded: { id: number; name: string; newPeaks: string[] }[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO activities
|
||||
(name, type, date, distance_m, duration_s, moving_s,
|
||||
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, points, bounds)
|
||||
VALUES
|
||||
(@name, @type, @date, @distance_m, @duration_s, @moving_s,
|
||||
@elev_gain_m, @elev_loss_m, @elev_min_m, @elev_max_m, @points, @bounds)
|
||||
`);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const gpx = parseGpx(await file.text());
|
||||
const result = insert.run({
|
||||
name: gpx.name ?? file.name.replace(/\.gpx$/i, ''),
|
||||
type: gpx.type ?? 'outdoor',
|
||||
date: gpx.date,
|
||||
distance_m: gpx.distance_m,
|
||||
duration_s: gpx.duration_s,
|
||||
moving_s: gpx.moving_s,
|
||||
elev_gain_m: gpx.elev_gain_m,
|
||||
elev_loss_m: gpx.elev_loss_m,
|
||||
elev_min_m: gpx.elev_min_m,
|
||||
elev_max_m: gpx.elev_max_m,
|
||||
points: JSON.stringify(gpx.points),
|
||||
bounds: JSON.stringify(gpx.bounds)
|
||||
});
|
||||
const activityId = Number(result.lastInsertRowid);
|
||||
|
||||
const unclimbed = listPeaks().filter((p) => !p.climbed_at);
|
||||
const bagged = matchPeaks(gpx.points, unclimbed);
|
||||
for (const peak of bagged) markPeakClimbed(peak.id, activityId, gpx.date);
|
||||
|
||||
uploaded.push({
|
||||
id: activityId,
|
||||
name: gpx.name ?? file.name,
|
||||
newPeaks: bagged.map((p) => p.name)
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploaded.length === 0) return fail(422, { error: errors.join(' ') });
|
||||
return { uploaded, errors };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
let dragging = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
let formEl: HTMLFormElement;
|
||||
let selectedNames: string[] = $state([]);
|
||||
let submitting = $state(false);
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
if (!event.dataTransfer) return;
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
updateNames();
|
||||
}
|
||||
|
||||
function updateNames() {
|
||||
selectedNames = [...(fileInput.files ?? [])].map((f) => f.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Upload · Streba</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Upload</h1>
|
||||
<p class="page-sub">Drop GPX files here - peaks along the track are ticked off automatically.</p>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
bind:this={formEl}
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
submitting = false;
|
||||
selectedNames = [];
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="dropzone card"
|
||||
class:dragging
|
||||
onclick={() => fileInput.click()}
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="36" height="36" aria-hidden="true">
|
||||
<path d="M2 20 L9 6 L13 13 L16 9 L22 20 Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
|
||||
<path d="M12 2 v0" stroke="currentColor" />
|
||||
</svg>
|
||||
{#if selectedNames.length > 0}
|
||||
<strong>{selectedNames.length} file{selectedNames.length > 1 ? 's' : ''} selected</strong>
|
||||
<span class="hint">{selectedNames.join(', ')}</span>
|
||||
{:else}
|
||||
<strong>Drop GPX files here</strong>
|
||||
<span class="hint">or click to browse</span>
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
name="gpx"
|
||||
accept=".gpx,application/gpx+xml"
|
||||
multiple
|
||||
hidden
|
||||
bind:this={fileInput}
|
||||
onchange={updateNames}
|
||||
/>
|
||||
<button class="btn submit" type="submit" disabled={submitting || selectedNames.length === 0}>
|
||||
{submitting ? 'Analysing…' : 'Analyse'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="card notice error">{form.error}</div>
|
||||
{/if}
|
||||
{#if form?.uploaded}
|
||||
<div class="card notice">
|
||||
{#each form.uploaded as item (item.id)}
|
||||
<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>
|
||||
{/if}
|
||||
</p>
|
||||
{/each}
|
||||
{#each form.errors as message (message)}
|
||||
<p class="err-line">{message}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.dropzone {
|
||||
width: 100%;
|
||||
padding: 3rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1.5px dashed var(--baseline);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, background 120ms;
|
||||
}
|
||||
.dropzone:hover,
|
||||
.dropzone.dragging {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
}
|
||||
.dropzone svg {
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.dropzone strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.notice p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.notice a {
|
||||
color: var(--accent-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
.bagged {
|
||||
color: var(--good-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
.error,
|
||||
.err-line {
|
||||
color: var(--critical);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user