initial version of streba 2
This commit is contained in:
@@ -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' }
|
||||
];
|
||||
Reference in New Issue
Block a user