initial version of streba 2

This commit is contained in:
Vincent van der Wal
2026-07-22 09:24:18 +02:00
commit 196f30521c
31 changed files with 5056 additions and 0 deletions
+77
View File
@@ -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>
+172
View File
@@ -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>
+154
View File
@@ -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>
+31
View File
@@ -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>
+31
View File
@@ -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`;
}
+123
View File
@@ -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);
}
+220
View File
@@ -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;
}
+121
View File
@@ -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' }
];