feat(hikes): multi-day stages (separate GPX tracks, stage nav, builder)
Represent a multi-day hike as separate named GPX <trk> elements, one per stage, while still treating the whole thing as one route on the overview. GPX & build: - gpx.ts: parseGpxStages (one stage per <trk>) + multi-track buildGpx. - build-hikes.ts: per-stage stats with totals summed across stages so the overnight gaps (distance, time) and the altitude jump between stages are excluded; previewBreaks recorded where stages sit >1 km apart. - types: HikeStage, manifest `stages?` and `previewBreaks?` (both optional — single-stage hikes are unchanged). Detail page: - HikeStageNav: a light itinerary-stepper switcher (numbered nodes, active glows in the accent) writing a shared stageStore. - Selecting a stage scopes the metrics, elevation profile (x-window), map (highlight + zoom, dim the rest) and photo strip/markers; "Alle Etappen" shows the whole route. Overview: live map and the prerendered static composite both break the preview line across >1 km inter-stage transfers (previewBreaks). Route builder: - Mark any placed waypoint as a stage start (named) from the waypoint list or the detail panel; export assembles each stage independently into its own <trk>; import re-marks stage boundaries from a multi-track GPX.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.81.0",
|
"version": "1.82.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+117
-11
@@ -20,11 +20,13 @@ import crypto from 'node:crypto';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import {
|
import {
|
||||||
parseGpx,
|
parseGpxStages,
|
||||||
parseGpxImageRefs,
|
parseGpxImageRefs,
|
||||||
trackDistance,
|
trackDistance,
|
||||||
|
haversine,
|
||||||
type GpxImageRef,
|
type GpxImageRef,
|
||||||
type GpxPoint
|
type GpxPoint,
|
||||||
|
type GpxStage
|
||||||
} from '../src/lib/server/gpx.js';
|
} from '../src/lib/server/gpx.js';
|
||||||
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
|
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
|
||||||
import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
|
import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
|
||||||
@@ -33,6 +35,7 @@ import { computeElevationStats, computeElevationRange } from '../src/lib/hikes/e
|
|||||||
import type {
|
import type {
|
||||||
Difficulty,
|
Difficulty,
|
||||||
HikeManifestEntry,
|
HikeManifestEntry,
|
||||||
|
HikeStage,
|
||||||
HikesOverview,
|
HikesOverview,
|
||||||
ImagePoint,
|
ImagePoint,
|
||||||
ImageVariant
|
ImageVariant
|
||||||
@@ -207,6 +210,53 @@ function computeBboxAndCentroid(track: GpxPoint[]): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overview preview polyline. Stages whose join gap exceeds this are drawn as
|
||||||
|
// separate runs (a break) so the overview doesn't connect across an overnight
|
||||||
|
// transfer; closer stages stay one continuous line.
|
||||||
|
const PREVIEW_GAP_BREAK_KM = 1;
|
||||||
|
|
||||||
|
function buildPreview(stages: GpxStage[]): {
|
||||||
|
previewPolyline: [number, number][];
|
||||||
|
previewBreaks: number[];
|
||||||
|
} {
|
||||||
|
// Group consecutive stages into runs, splitting only at a significant gap.
|
||||||
|
const runs: GpxPoint[][] = [];
|
||||||
|
let current: GpxPoint[] = [];
|
||||||
|
for (let i = 0; i < stages.length; i++) {
|
||||||
|
if (i > 0 && current.length > 0) {
|
||||||
|
const prevEnd = stages[i - 1].points[stages[i - 1].points.length - 1];
|
||||||
|
const curStart = stages[i].points[0];
|
||||||
|
if (haversine(prevEnd, curStart) > PREVIEW_GAP_BREAK_KM) {
|
||||||
|
runs.push(current);
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.push(...stages[i].points);
|
||||||
|
}
|
||||||
|
if (current.length > 0) runs.push(current);
|
||||||
|
|
||||||
|
// One run (every single-stage hike, and multi-stage hikes with only small
|
||||||
|
// gaps): identical to the previous behaviour — one simplified line.
|
||||||
|
if (runs.length <= 1) {
|
||||||
|
return {
|
||||||
|
previewPolyline: simplifyTrack(runs[0] ?? [], PREVIEW_POLYLINE_MAX_POINTS) as [number, number][],
|
||||||
|
previewBreaks: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple runs: simplify each within a proportional point budget so the
|
||||||
|
// total stays near PREVIEW_POLYLINE_MAX_POINTS, recording the run starts.
|
||||||
|
const total = runs.reduce((a, r) => a + r.length, 0) || 1;
|
||||||
|
const previewPolyline: [number, number][] = [];
|
||||||
|
const previewBreaks: number[] = [];
|
||||||
|
for (const run of runs) {
|
||||||
|
if (previewPolyline.length > 0) previewBreaks.push(previewPolyline.length);
|
||||||
|
const budget = Math.max(2, Math.round((PREVIEW_POLYLINE_MAX_POINTS * run.length) / total));
|
||||||
|
previewPolyline.push(...(simplifyTrack(run, budget) as [number, number][]));
|
||||||
|
}
|
||||||
|
return { previewPolyline, previewBreaks };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Swisstopo reverse-geocode with disk cache
|
// Swisstopo reverse-geocode with disk cache
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -646,7 +696,8 @@ async function processOverview(
|
|||||||
.filter((h) => h.previewPolyline && h.previewPolyline.length >= 2)
|
.filter((h) => h.previewPolyline && h.previewPolyline.length >= 2)
|
||||||
.map((h) => ({
|
.map((h) => ({
|
||||||
points: h.previewPolyline,
|
points: h.previewPolyline,
|
||||||
color: SAC_TRAIL_COLOR[h.difficulty] ?? '#5e81ac'
|
color: SAC_TRAIL_COLOR[h.difficulty] ?? '#5e81ac',
|
||||||
|
breaks: h.previewBreaks
|
||||||
}));
|
}));
|
||||||
if (lines.length === 0) return undefined;
|
if (lines.length === 0) return undefined;
|
||||||
|
|
||||||
@@ -945,22 +996,75 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { data: fm } = parseFrontmatter(svxSource);
|
const { data: fm } = parseFrontmatter(svxSource);
|
||||||
const track = parseGpx(gpxSource);
|
// One stage per <trk>. The flat track is their concatenation — identical to
|
||||||
|
// the old `parseGpx` output for single-track GPX, so everything downstream
|
||||||
|
// (track JSON, hero map, images) is unchanged for normal hikes.
|
||||||
|
const gpxStages = parseGpxStages(gpxSource);
|
||||||
|
const track: GpxPoint[] = gpxStages.flatMap((s) => s.points);
|
||||||
if (track.length === 0) {
|
if (track.length === 0) {
|
||||||
console.warn(`[build-hikes] Skipping ${slug}: empty GPX`);
|
console.warn(`[build-hikes] Skipping ${slug}: empty GPX`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const gpxImageRefs = parseGpxImageRefs(gpxSource);
|
const gpxImageRefs = parseGpxImageRefs(gpxSource);
|
||||||
const gpxImageCount = Object.keys(gpxImageRefs).length;
|
const gpxImageCount = Object.keys(gpxImageRefs).length;
|
||||||
console.log(`[build-hikes:${slug}] parsed GPX (${track.length} track pts, ${gpxImageCount} image refs)`);
|
console.log(`[build-hikes:${slug}] parsed GPX (${track.length} track pts, ${gpxStages.length} stage(s), ${gpxImageCount} image refs)`);
|
||||||
|
|
||||||
const distanceKm = trackDistance(track);
|
// Per-stage stats + flat-track index ranges. Indices are contiguous and
|
||||||
const { gain, loss } = computeElevationStats(track);
|
// disjoint (endIdx + 1 === next.startIdx).
|
||||||
const { min: elevationMinM, max: elevationMaxM } = computeElevationRange(track);
|
const stageEntries: HikeStage[] = [];
|
||||||
const { bbox, centroid } = computeBboxAndCentroid(track);
|
{
|
||||||
const previewPolyline = simplifyTrack(track, PREVIEW_POLYLINE_MAX_POINTS) as [number, number][];
|
let offset = 0;
|
||||||
|
for (const s of gpxStages) {
|
||||||
|
const startIdx = offset;
|
||||||
|
const endIdx = offset + s.points.length - 1;
|
||||||
|
offset = endIdx + 1;
|
||||||
|
const range = computeElevationRange(s.points);
|
||||||
|
const { gain: sGain, loss: sLoss } = computeElevationStats(s.points);
|
||||||
|
const sDtMs = s.points[s.points.length - 1].timestamp - s.points[0].timestamp;
|
||||||
|
stageEntries.push({
|
||||||
|
name: s.name ?? `Etappe ${stageEntries.length + 1}`,
|
||||||
|
startIdx,
|
||||||
|
endIdx,
|
||||||
|
distanceKm: trackDistance(s.points),
|
||||||
|
durationMin: sDtMs > 0 ? Math.round(sDtMs / 60000) : null,
|
||||||
|
elevationGainM: sGain,
|
||||||
|
elevationLossM: sLoss,
|
||||||
|
elevationMaxM: range.max,
|
||||||
|
elevationMinM: range.min
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const multiStage = stageEntries.length >= 2;
|
||||||
|
|
||||||
|
// Totals: summed per-stage when multi-day, so overnight horizontal gaps
|
||||||
|
// (distance) and time gaps (duration) and the altitude jump between a
|
||||||
|
// stage's end and the next stage's start (gain/loss) are all excluded.
|
||||||
|
let distanceKm: number;
|
||||||
|
let gain: number;
|
||||||
|
let loss: number;
|
||||||
|
let durationMin: number | null;
|
||||||
|
let elevationMinM: number | null;
|
||||||
|
let elevationMaxM: number | null;
|
||||||
|
if (multiStage) {
|
||||||
|
distanceKm = stageEntries.reduce((a, s) => a + s.distanceKm, 0);
|
||||||
|
gain = stageEntries.reduce((a, s) => a + s.elevationGainM, 0);
|
||||||
|
loss = stageEntries.reduce((a, s) => a + s.elevationLossM, 0);
|
||||||
|
const durs = stageEntries.map((s) => s.durationMin).filter((d): d is number => d != null);
|
||||||
|
durationMin = durs.length > 0 ? durs.reduce((a, d) => a + d, 0) : null;
|
||||||
|
const mins = stageEntries.map((s) => s.elevationMinM).filter((v): v is number => v != null);
|
||||||
|
const maxs = stageEntries.map((s) => s.elevationMaxM).filter((v): v is number => v != null);
|
||||||
|
elevationMinM = mins.length > 0 ? Math.min(...mins) : null;
|
||||||
|
elevationMaxM = maxs.length > 0 ? Math.max(...maxs) : null;
|
||||||
|
} else {
|
||||||
|
distanceKm = trackDistance(track);
|
||||||
|
({ gain, loss } = computeElevationStats(track));
|
||||||
|
({ min: elevationMinM, max: elevationMaxM } = computeElevationRange(track));
|
||||||
const dtMs = track[track.length - 1].timestamp - track[0].timestamp;
|
const dtMs = track[track.length - 1].timestamp - track[0].timestamp;
|
||||||
const durationMin = dtMs > 0 ? Math.round(dtMs / 60000) : null;
|
durationMin = dtMs > 0 ? Math.round(dtMs / 60000) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bbox, centroid } = computeBboxAndCentroid(track);
|
||||||
|
const { previewPolyline, previewBreaks } = buildPreview(gpxStages);
|
||||||
console.log(`[build-hikes:${slug}] metrics: ${distanceKm.toFixed(2)} km · ↑${gain}m / ↓${loss}m · ${elevationMinM ?? '?'}–${elevationMaxM ?? '?'}m · ${durationMin ?? '?'} min`);
|
console.log(`[build-hikes:${slug}] metrics: ${distanceKm.toFixed(2)} km · ↑${gain}m / ↓${loss}m · ${elevationMinM ?? '?'}–${elevationMaxM ?? '?'}m · ${durationMin ?? '?'} min`);
|
||||||
|
|
||||||
const geoT0 = Date.now();
|
const geoT0 = Date.now();
|
||||||
@@ -1156,6 +1260,8 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
bbox,
|
bbox,
|
||||||
centroid,
|
centroid,
|
||||||
previewPolyline,
|
previewPolyline,
|
||||||
|
...(previewBreaks.length > 0 ? { previewBreaks } : {}),
|
||||||
|
...(multiStage ? { stages: stageEntries } : {}),
|
||||||
region: geo.region,
|
region: geo.region,
|
||||||
canton: geo.canton,
|
canton: geo.canton,
|
||||||
municipality: geo.municipality,
|
municipality: geo.municipality,
|
||||||
|
|||||||
@@ -361,6 +361,9 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolea
|
|||||||
export interface RenderOverviewPolyline {
|
export interface RenderOverviewPolyline {
|
||||||
points: Array<[number, number]>;
|
points: Array<[number, number]>;
|
||||||
color: string;
|
color: string;
|
||||||
|
/** Indices where a new disconnected sub-path begins (multi-day stage gaps
|
||||||
|
* >1 km), so the line isn't drawn across an overnight transfer. */
|
||||||
|
breaks?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderOverviewMapOpts {
|
export interface RenderOverviewMapOpts {
|
||||||
@@ -388,13 +391,16 @@ export async function renderOverviewMap(opts: RenderOverviewMapOpts): Promise<bo
|
|||||||
// zoomed-out, so even ≤150-point preview polylines stay compact.
|
// zoomed-out, so even ≤150-point preview polylines stay compact.
|
||||||
const paths = drawable
|
const paths = drawable
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
|
const breakSet = new Set(line.breaks ?? []);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (let i = 0; i < line.points.length; i++) {
|
for (let i = 0; i < line.points.length; i++) {
|
||||||
const [lat, lng] = line.points[i];
|
const [lat, lng] = line.points[i];
|
||||||
const p = lngLatToPx(lng, lat, zoom);
|
const p = lngLatToPx(lng, lat, zoom);
|
||||||
const px = p.x - originX;
|
const px = p.x - originX;
|
||||||
const py = p.y - originY;
|
const py = p.y - originY;
|
||||||
parts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
|
// Start a fresh sub-path at index 0 and at every stage break.
|
||||||
|
const cmd = i === 0 || breakSet.has(i) ? 'M' : 'L';
|
||||||
|
parts.push(cmd + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +
|
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +
|
||||||
|
|||||||
@@ -6,9 +6,21 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
track: HikeTrackPoint[];
|
track: HikeTrackPoint[];
|
||||||
|
/** Restrict the x-axis to a stage's index range (multi-day hikes). The
|
||||||
|
* dataset stays the full track so hover indices remain global. */
|
||||||
|
viewRange?: { startIdx: number; endIdx: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { track }: Props = $props();
|
const { track, viewRange = null }: Props = $props();
|
||||||
|
|
||||||
|
// x-axis window in km for the current view (whole track, or a stage slice).
|
||||||
|
function xBounds(): { min: number; max: number } {
|
||||||
|
const last = cumKm[cumKm.length - 1] ?? 0;
|
||||||
|
if (!viewRange) return { min: 0, max: last };
|
||||||
|
const lo = Math.max(0, Math.min(viewRange.startIdx, cumKm.length - 1));
|
||||||
|
const hi = Math.max(0, Math.min(viewRange.endIdx, cumKm.length - 1));
|
||||||
|
return { min: cumKm[lo] ?? 0, max: cumKm[hi] ?? last };
|
||||||
|
}
|
||||||
|
|
||||||
let canvas = $state<HTMLCanvasElement | undefined>(undefined);
|
let canvas = $state<HTMLCanvasElement | undefined>(undefined);
|
||||||
let chart: ChartType | null = null;
|
let chart: ChartType | null = null;
|
||||||
@@ -137,9 +149,10 @@
|
|||||||
type: 'linear',
|
type: 'linear',
|
||||||
// Pin the axis to the actual data range so Chart.js doesn't
|
// Pin the axis to the actual data range so Chart.js doesn't
|
||||||
// round up to the next nice tick — otherwise a 12.3 km hike
|
// round up to the next nice tick — otherwise a 12.3 km hike
|
||||||
// ends up with empty space out to 14 km.
|
// ends up with empty space out to 14 km. When a stage is
|
||||||
min: 0,
|
// selected, the window narrows to that stage.
|
||||||
max: cumKm[cumKm.length - 1] ?? 0,
|
min: xBounds().min,
|
||||||
|
max: xBounds().max,
|
||||||
bounds: 'data',
|
bounds: 'data',
|
||||||
title: { display: true, text: 'Distanz (km)', color: textColor },
|
title: { display: true, text: 'Distanz (km)', color: textColor },
|
||||||
ticks: { color: textColor },
|
ticks: { color: textColor },
|
||||||
@@ -220,6 +233,20 @@
|
|||||||
chart.update('none');
|
chart.update('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-window the x-axis when the active stage changes (reads `viewRange`).
|
||||||
|
$effect(() => {
|
||||||
|
const b = (() => {
|
||||||
|
void viewRange;
|
||||||
|
return xBounds();
|
||||||
|
})();
|
||||||
|
if (!chart) return;
|
||||||
|
const xScale = chart.options.scales?.x;
|
||||||
|
if (!xScale) return;
|
||||||
|
xScale.min = b.min;
|
||||||
|
xScale.max = b.max;
|
||||||
|
chart.update('none');
|
||||||
|
});
|
||||||
|
|
||||||
// Mouse-leave on the canvas clears the shared hover state so the map marker
|
// Mouse-leave on the canvas clears the shared hover state so the map marker
|
||||||
// disappears too.
|
// disappears too.
|
||||||
function onCanvasMouseLeave() {
|
function onCanvasMouseLeave() {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Attachment } from 'svelte/attachments';
|
import type { Attachment } from 'svelte/attachments';
|
||||||
import type { HikeTrackPoint, ImagePoint } from '$types/hikes';
|
import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
|
||||||
import { hover, setHover, clearHover } from './hoverStore.svelte';
|
import { hover, setHover, clearHover } from './hoverStore.svelte';
|
||||||
|
import { stage } from './stageStore.svelte';
|
||||||
import { focused, setFocused, clearFocused } from './focusedImageStore.svelte';
|
import { focused, setFocused, clearFocused } from './focusedImageStore.svelte';
|
||||||
import Map from '@lucide/svelte/icons/map';
|
import Map from '@lucide/svelte/icons/map';
|
||||||
import Satellite from '@lucide/svelte/icons/satellite';
|
import Satellite from '@lucide/svelte/icons/satellite';
|
||||||
@@ -34,6 +35,10 @@
|
|||||||
* route on the /hikes overview map (orange for T1, red for T2/T3,
|
* route on the /hikes overview map (orange for T1, red for T2/T3,
|
||||||
* blue for T4–T6). */
|
* blue for T4–T6). */
|
||||||
trackColor?: string;
|
trackColor?: string;
|
||||||
|
/** Stage ranges for a multi-day hike. When a stage is active (shared
|
||||||
|
* stageStore) the map highlights it, dims the rest, zooms to it, and
|
||||||
|
* scopes photo markers to that stage. */
|
||||||
|
stages?: HikeStage[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -43,7 +48,8 @@
|
|||||||
initialCenter,
|
initialCenter,
|
||||||
initialZoom,
|
initialZoom,
|
||||||
onReady,
|
onReady,
|
||||||
trackColor
|
trackColor,
|
||||||
|
stages = null
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// User-location toggle moved inside the map UI. localStorage-persisted so
|
// User-location toggle moved inside the map UI. localStorage-persisted so
|
||||||
@@ -236,6 +242,15 @@
|
|||||||
interactive: false
|
interactive: false
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Brighter overlay drawn over the active stage (multi-day hikes); the
|
||||||
|
// base line is dimmed underneath it. Empty until a stage is selected.
|
||||||
|
const stageOverlay = L.polyline([] as [number, number][], {
|
||||||
|
color: trailColor,
|
||||||
|
weight: 6,
|
||||||
|
opacity: 1,
|
||||||
|
interactive: false
|
||||||
|
});
|
||||||
|
|
||||||
L.circleMarker(latLngs[0], {
|
L.circleMarker(latLngs[0], {
|
||||||
radius: 6,
|
radius: 6,
|
||||||
fillColor: '#a3be8c',
|
fillColor: '#a3be8c',
|
||||||
@@ -313,6 +328,31 @@
|
|||||||
'<circle cx="12" cy="13" r="3"/>' +
|
'<circle cx="12" cy="13" r="3"/>' +
|
||||||
'</svg>';
|
'</svg>';
|
||||||
|
|
||||||
|
// Nearest track sample (by time) to a photo — used to test which
|
||||||
|
// stage a photo belongs to when scoping markers to the active stage.
|
||||||
|
function nearestTrackIdx(ts: number): number {
|
||||||
|
let best = -1;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (let i = 0; i < track.length; i++) {
|
||||||
|
const t = track[i][3];
|
||||||
|
if (typeof t !== 'number') continue;
|
||||||
|
const d = Math.abs(t - ts);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function photoInActiveStage(ip: ImagePoint): boolean {
|
||||||
|
const active = stage.active;
|
||||||
|
if (active === null || !stages || !stages[active]) return true;
|
||||||
|
if (typeof ip.timestamp !== 'number') return false;
|
||||||
|
const s = stages[active];
|
||||||
|
const idx = nearestTrackIdx(ip.timestamp);
|
||||||
|
return idx >= s.startIdx && idx <= s.endIdx;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPhotos() {
|
function renderPhotos() {
|
||||||
photoLayer.clearLayers();
|
photoLayer.clearLayers();
|
||||||
visiblePoints = [];
|
visiblePoints = [];
|
||||||
@@ -322,6 +362,9 @@
|
|||||||
if (ip.visibility === 'private' && !showPrivate) continue;
|
if (ip.visibility === 'private' && !showPrivate) continue;
|
||||||
const visibleIdx = visiblePoints.length;
|
const visibleIdx = visiblePoints.length;
|
||||||
visiblePoints.push(ip);
|
visiblePoints.push(ip);
|
||||||
|
// Keep `visiblePoints` aligned with the strip's index space, but
|
||||||
|
// only draw a marker when the photo is in the active stage.
|
||||||
|
if (!photoInActiveStage(ip)) continue;
|
||||||
const altSafe = ip.alt.replace(/"/g, '"');
|
const altSafe = ip.alt.replace(/"/g, '"');
|
||||||
const isPrivate = ip.visibility === 'private';
|
const isPrivate = ip.visibility === 'private';
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
@@ -540,10 +583,34 @@
|
|||||||
|
|
||||||
// React to user-toggle of photo markers, base-layer choice, and the
|
// React to user-toggle of photo markers, base-layer choice, and the
|
||||||
// enableUserLocation prop.
|
// enableUserLocation prop.
|
||||||
|
let stageInitialized = false;
|
||||||
const stopReactRoot = $effect.root(() => {
|
const stopReactRoot = $effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
renderPhotos();
|
renderPhotos();
|
||||||
});
|
});
|
||||||
|
// Active-stage highlight + zoom. The first run (active === null on
|
||||||
|
// mount) only normalises the base style — it must NOT fly, or it
|
||||||
|
// would clobber the static-hero → live handover above.
|
||||||
|
$effect(() => {
|
||||||
|
const active = stage.active;
|
||||||
|
if (active !== null && stages && stages[active]) {
|
||||||
|
const s = stages[active];
|
||||||
|
stageOverlay.setLatLngs(latLngs.slice(s.startIdx, s.endIdx + 1));
|
||||||
|
if (!map.hasLayer(stageOverlay)) stageOverlay.addTo(map);
|
||||||
|
polyline.setStyle({ opacity: 0.28 });
|
||||||
|
const b = stageOverlay.getBounds();
|
||||||
|
if (b.isValid()) {
|
||||||
|
map.flyToBounds(b, { padding: [40, 40], duration: 0.6, easeLinearity: 0.25 });
|
||||||
|
}
|
||||||
|
stageInitialized = true;
|
||||||
|
} else {
|
||||||
|
if (map.hasLayer(stageOverlay)) stageOverlay.remove();
|
||||||
|
polyline.setStyle({ opacity: 0.95 });
|
||||||
|
if (stageInitialized) {
|
||||||
|
map.flyToBounds(initialBounds, { padding: [24, 24], duration: 0.6, easeLinearity: 0.25 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (baseLayer === currentBase) return;
|
if (baseLayer === currentBase) return;
|
||||||
tileLayers[currentBase].remove();
|
tileLayers[currentBase].remove();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { HikeTrackPoint, ImagePoint } from '$types/hikes';
|
import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
|
||||||
import { focused, setFocused } from './focusedImageStore.svelte';
|
import { focused, setFocused } from './focusedImageStore.svelte';
|
||||||
|
import { stage } from './stageStore.svelte';
|
||||||
import MapPin from '@lucide/svelte/icons/map-pin';
|
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||||
import Lock from '@lucide/svelte/icons/lock';
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
@@ -12,9 +13,45 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
images: ImagePoint[];
|
images: ImagePoint[];
|
||||||
track: HikeTrackPoint[];
|
track: HikeTrackPoint[];
|
||||||
|
/** Stage ranges (multi-day hikes). When a stage is active, the strip
|
||||||
|
* shows only that stage's photos. Indices stay aligned with the full
|
||||||
|
* list so the shared focus store keeps matching the map. */
|
||||||
|
stages?: HikeStage[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { images, track }: Props = $props();
|
const { images, track, stages = null }: Props = $props();
|
||||||
|
|
||||||
|
// Nearest track index (by time) per image — for testing stage membership.
|
||||||
|
const imageTrackIdx = $derived(
|
||||||
|
images.map((ip) => {
|
||||||
|
if (typeof ip.timestamp !== 'number') return -1;
|
||||||
|
let best = -1;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (let i = 0; i < track.length; i++) {
|
||||||
|
const t = track[i][3];
|
||||||
|
if (typeof t !== 'number') continue;
|
||||||
|
const d = Math.abs(t - ip.timestamp);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeStageRange = $derived.by(() => {
|
||||||
|
if (stage.active === null || !stages || !stages[stage.active]) return null;
|
||||||
|
const s = stages[stage.active];
|
||||||
|
return { startIdx: s.startIdx, endIdx: s.endIdx };
|
||||||
|
});
|
||||||
|
|
||||||
|
function inActiveStage(i: number): boolean {
|
||||||
|
const r = activeStageRange;
|
||||||
|
if (!r) return true;
|
||||||
|
const idx = imageTrackIdx[i];
|
||||||
|
return idx >= r.startIdx && idx <= r.endIdx;
|
||||||
|
}
|
||||||
|
|
||||||
const startTimestamp = $derived.by(() => {
|
const startTimestamp = $derived.by(() => {
|
||||||
for (const p of track) {
|
for (const p of track) {
|
||||||
@@ -109,23 +146,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step to the next/previous photo that's in the active stage (skips photos
|
||||||
|
// hidden by stage scoping).
|
||||||
function advance(direction: -1 | 1): void {
|
function advance(direction: -1 | 1): void {
|
||||||
if (images.length === 0) return;
|
if (images.length === 0) return;
|
||||||
const current = focused.index;
|
let i = focused.index === null ? (direction === 1 ? -1 : images.length) : focused.index;
|
||||||
let next: number;
|
i += direction;
|
||||||
if (current === null) {
|
while (i >= 0 && i < images.length) {
|
||||||
next = direction === 1 ? 0 : images.length - 1;
|
if (inActiveStage(i)) {
|
||||||
} else {
|
setFocused(i, 'strip');
|
||||||
next = current + direction;
|
return;
|
||||||
if (next < 0 || next >= images.length) return;
|
}
|
||||||
|
i += direction;
|
||||||
}
|
}
|
||||||
setFocused(next, 'strip');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const canPrev = $derived(focused.index !== null && focused.index > 0);
|
const canPrev = $derived.by(() => {
|
||||||
const canNext = $derived(
|
if (focused.index === null) return false;
|
||||||
focused.index === null ? images.length > 0 : focused.index < images.length - 1
|
for (let i = focused.index - 1; i >= 0; i--) if (inActiveStage(i)) return true;
|
||||||
);
|
return false;
|
||||||
|
});
|
||||||
|
const canNext = $derived.by(() => {
|
||||||
|
const start = focused.index === null ? -1 : focused.index;
|
||||||
|
for (let i = start + 1; i < images.length; i++) if (inActiveStage(i)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent): void {
|
function onKey(e: KeyboardEvent): void {
|
||||||
if (images.length === 0) return;
|
if (images.length === 0) return;
|
||||||
@@ -174,6 +219,7 @@
|
|||||||
? formatElapsed(ip.timestamp - startTimestamp)
|
? formatElapsed(ip.timestamp - startTimestamp)
|
||||||
: null}
|
: null}
|
||||||
{@const active = focused.index === i}
|
{@const active = focused.index === i}
|
||||||
|
{#if inActiveStage(i)}
|
||||||
<div class="card-wrap" class:active bind:this={cardEls[i]}>
|
<div class="card-wrap" class:active bind:this={cardEls[i]}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -208,6 +254,7 @@
|
|||||||
<Expand size={15} strokeWidth={2} aria-hidden="true" />
|
<Expand size={15} strokeWidth={2} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Stage switcher styled as a hut-to-hut itinerary line: a leading "Alle"
|
||||||
|
// pill, then numbered nodes joined by thin connectors. The active stage's
|
||||||
|
// node glows in the accent and its name/distance shows alongside. Light and
|
||||||
|
// in-flow (no boxed/blurred bar) — writes the shared stageStore.
|
||||||
|
import type { HikeStage } from '$types/hikes';
|
||||||
|
import { stage, setActiveStage } from './stageStore.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stages: HikeStage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stages }: Props = $props();
|
||||||
|
|
||||||
|
const active = $derived(stage.active);
|
||||||
|
const totalKm = $derived(stages.reduce((a, s) => a + s.distanceKm, 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="stepper" aria-label="Etappen">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="all"
|
||||||
|
class:active={active === null}
|
||||||
|
aria-pressed={active === null}
|
||||||
|
onclick={() => setActiveStage(null)}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ol class="line">
|
||||||
|
{#each stages as s, i (i)}
|
||||||
|
{#if i > 0}
|
||||||
|
<li class="connector" class:lit={active === null} aria-hidden="true"></li>
|
||||||
|
{/if}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="node"
|
||||||
|
class:active={active === i}
|
||||||
|
class:lit={active === null}
|
||||||
|
aria-pressed={active === i}
|
||||||
|
aria-label={`Etappe ${i + 1}: ${s.name}`}
|
||||||
|
title={s.name}
|
||||||
|
onclick={() => setActiveStage(i)}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p class="label" aria-live="polite">
|
||||||
|
{#if active === null}
|
||||||
|
<span class="title">Alle Etappen</span>
|
||||||
|
<span class="dist">{totalKm.toFixed(1)} km</span>
|
||||||
|
{:else}
|
||||||
|
<span class="kicker">Etappe {active + 1}</span>
|
||||||
|
<span class="title">{stages[active].name}</span>
|
||||||
|
<span class="dist">{stages[active].distanceKm.toFixed(1)} km</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
/* (a) breathing room below the full-bleed hero map; horizontal inset
|
||||||
|
* matches the other detail sections. */
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition-fast), background-color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 2px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-border);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector.lit {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition-fast), background-color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast), scale var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node:hover {
|
||||||
|
scale: 1.1;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Alle" selected: whole line subtly lit so it reads as the full route. */
|
||||||
|
.node.lit {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.1rem 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dist {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -195,7 +195,22 @@
|
|||||||
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
|
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
|
||||||
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
|
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
|
||||||
const color = sacTrailColor(hike.difficulty);
|
const color = sacTrailColor(hike.difficulty);
|
||||||
const poly = L.polyline(latLngs, {
|
// Multi-day hikes with a big inter-stage gap ship `previewBreaks`
|
||||||
|
// (indices where a new run starts); split there so Leaflet draws
|
||||||
|
// disconnected segments instead of a line across the transfer.
|
||||||
|
const breaks = hike.previewBreaks;
|
||||||
|
let coords: [number, number][] | [number, number][][] = latLngs;
|
||||||
|
if (breaks && breaks.length > 0) {
|
||||||
|
const segs: [number, number][][] = [];
|
||||||
|
let start = 0;
|
||||||
|
for (const brk of breaks) {
|
||||||
|
if (brk > start) segs.push(latLngs.slice(start, brk));
|
||||||
|
start = brk;
|
||||||
|
}
|
||||||
|
segs.push(latLngs.slice(start));
|
||||||
|
coords = segs.filter((s) => s.length >= 2);
|
||||||
|
}
|
||||||
|
const poly = L.polyline(coords, {
|
||||||
color,
|
color,
|
||||||
weight: 4,
|
weight: 4,
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
focusWaypoint,
|
focusWaypoint,
|
||||||
mapView,
|
mapView,
|
||||||
placedSequence,
|
placedSequence,
|
||||||
scheduleSave
|
scheduleSave,
|
||||||
|
toggleStageBreak,
|
||||||
|
renameStage
|
||||||
} from './builderStore.svelte';
|
} from './builderStore.svelte';
|
||||||
import { generateImageHashClient } from '$lib/imageHashClient';
|
import { generateImageHashClient } from '$lib/imageHashClient';
|
||||||
import { readThumbnail } from './imageThumbnail';
|
import { readThumbnail } from './imageThumbnail';
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
import Globe from '@lucide/svelte/icons/globe';
|
import Globe from '@lucide/svelte/icons/globe';
|
||||||
import Lock from '@lucide/svelte/icons/lock';
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import Flag from '@lucide/svelte/icons/flag';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onCancelPlacement?: () => void;
|
onCancelPlacement?: () => void;
|
||||||
@@ -45,6 +48,27 @@
|
|||||||
});
|
});
|
||||||
const requiresTime = $derived(wpIdx !== -1 && (wpIdx === firstPlacedIdx || wpIdx === lastPlacedIdx));
|
const requiresTime = $derived(wpIdx !== -1 && (wpIdx === firstPlacedIdx || wpIdx === lastPlacedIdx));
|
||||||
|
|
||||||
|
// Stage info for the focused waypoint (multi-day hikes). Mirrors the
|
||||||
|
// per-row control in the waypoint list.
|
||||||
|
const stageInfo = $derived.by(() => {
|
||||||
|
if (!wp) return null;
|
||||||
|
let num = 0;
|
||||||
|
let name = '';
|
||||||
|
for (let i = 0; i < placed.length; i++) {
|
||||||
|
const w = placed[i];
|
||||||
|
const isStart = i === 0 || w.stageStart !== undefined;
|
||||||
|
if (isStart) {
|
||||||
|
num++;
|
||||||
|
name = w.stageStart || `Etappe ${num}`;
|
||||||
|
}
|
||||||
|
if (w.id === wp.id) return { isStart, num, name, isFirst: i === 0 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const stageCount = $derived(
|
||||||
|
placed.reduce((n, w, i) => n + (i === 0 || w.stageStart !== undefined ? 1 : 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
function nearestTimestamp(idx: number): number | undefined {
|
function nearestTimestamp(idx: number): number | undefined {
|
||||||
const wps = builder.waypoints;
|
const wps = builder.waypoints;
|
||||||
for (let dist = 1; dist < wps.length; dist++) {
|
for (let dist = 1; dist < wps.length; dist++) {
|
||||||
@@ -272,6 +296,31 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if !wp.unplaced}
|
||||||
|
{#if stageInfo?.isStart && stageCount > 1}
|
||||||
|
<div class="stage-block">
|
||||||
|
<span class="stage-cap"><Flag size={12} strokeWidth={2.25} />Etappe {stageInfo.num}</span>
|
||||||
|
<input
|
||||||
|
class="stage-name-input"
|
||||||
|
value={wp.stageStart ?? stageInfo.name}
|
||||||
|
placeholder={`Etappe ${stageInfo.num}`}
|
||||||
|
oninput={(e) => renameStage(wp.id, e.currentTarget.value)}
|
||||||
|
aria-label={`Name Etappe ${stageInfo.num}`}
|
||||||
|
/>
|
||||||
|
{#if !stageInfo.isFirst}
|
||||||
|
<button type="button" class="stage-dissolve" onclick={() => toggleStageBreak(wp.id)}>
|
||||||
|
Etappe auflösen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if !stageInfo?.isFirst}
|
||||||
|
<button type="button" class="stage-new" onclick={() => toggleStageBreak(wp.id)}>
|
||||||
|
<Flag size={15} strokeWidth={2} />
|
||||||
|
<span>Neue Etappe ab hier</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !wp.unplaced}
|
{#if !wp.unplaced}
|
||||||
<details class="coords-details">
|
<details class="coords-details">
|
||||||
<summary>Koordinaten anpassen</summary>
|
<summary>Koordinaten anpassen</summary>
|
||||||
@@ -596,6 +645,89 @@
|
|||||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--orange) 30%, transparent);
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--orange) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stage controls — start a new stage at this waypoint, or name/dissolve an
|
||||||
|
* existing stage start. Mirrors the waypoint-list affordance. */
|
||||||
|
.stage-new {
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed color-mix(in oklab, var(--color-primary) 40%, var(--color-border));
|
||||||
|
color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-new:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, var(--color-border));
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-cap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-name-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-dissolve {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-dissolve:hover {
|
||||||
|
color: var(--red);
|
||||||
|
border-color: color-mix(in oklab, var(--red) 40%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
/* Coords are a power-user adjustment — keep them out of the way unless
|
/* Coords are a power-user adjustment — keep them out of the way unless
|
||||||
* the user explicitly opens the disclosure. Dragging the marker on the
|
* the user explicitly opens the disclosure. Dragging the marker on the
|
||||||
* map is the primary editing affordance. */
|
* map is the primary editing affordance. */
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
focusWaypoint,
|
focusWaypoint,
|
||||||
mapView,
|
mapView,
|
||||||
placedSequence,
|
placedSequence,
|
||||||
scheduleSave
|
scheduleSave,
|
||||||
|
toggleStageBreak,
|
||||||
|
renameStage
|
||||||
} from './builderStore.svelte';
|
} from './builderStore.svelte';
|
||||||
import { generateImageHashClient } from '$lib/imageHashClient';
|
import { generateImageHashClient } from '$lib/imageHashClient';
|
||||||
import { readThumbnail } from './imageThumbnail';
|
import { readThumbnail } from './imageThumbnail';
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
import MapPinOff from '@lucide/svelte/icons/map-pin-off';
|
import MapPinOff from '@lucide/svelte/icons/map-pin-off';
|
||||||
import Globe from '@lucide/svelte/icons/globe';
|
import Globe from '@lucide/svelte/icons/globe';
|
||||||
import Lock from '@lucide/svelte/icons/lock';
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
|
import Flag from '@lucide/svelte/icons/flag';
|
||||||
|
|
||||||
const NUDGE_MINUTES = [-10, -5, 5, 10];
|
const NUDGE_MINUTES = [-10, -5, 5, 10];
|
||||||
|
|
||||||
@@ -46,6 +49,30 @@
|
|||||||
return -1;
|
return -1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Per-waypoint stage metadata (placed waypoints only): whether it begins a
|
||||||
|
// stage, the stage number/name, and whether it's the route start.
|
||||||
|
const stageMeta = $derived.by(() => {
|
||||||
|
const map = new Map<string, { isStart: boolean; num: number; name: string; first: boolean }>();
|
||||||
|
let num = 0;
|
||||||
|
let name = '';
|
||||||
|
let firstSeen = false;
|
||||||
|
for (const w of builder.waypoints) {
|
||||||
|
if (w.unplaced) continue;
|
||||||
|
const first = !firstSeen;
|
||||||
|
firstSeen = true;
|
||||||
|
const isStart = first || w.stageStart !== undefined;
|
||||||
|
if (isStart) {
|
||||||
|
num++;
|
||||||
|
name = w.stageStart || `Etappe ${num}`;
|
||||||
|
}
|
||||||
|
map.set(w.id, { isStart, num, name, first });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const stageCount = $derived(
|
||||||
|
[...stageMeta.values()].filter((m) => m.isStart).length
|
||||||
|
);
|
||||||
|
|
||||||
/** Find the nearest waypoint *by index* that already carries a timestamp.
|
/** Find the nearest waypoint *by index* that already carries a timestamp.
|
||||||
* Used as the `inheritedValue` for click waypoints — searching by sequence
|
* Used as the `inheritedValue` for click waypoints — searching by sequence
|
||||||
* position (rather than geography) mirrors how authors typically insert
|
* position (rather than geography) mirrors how authors typically insert
|
||||||
@@ -152,13 +179,39 @@
|
|||||||
<ol>
|
<ol>
|
||||||
{#each builder.waypoints as wp, idx (wp.id)}
|
{#each builder.waypoints as wp, idx (wp.id)}
|
||||||
{@const seq = placedSequence(wp.id)}
|
{@const seq = placedSequence(wp.id)}
|
||||||
|
{@const sm = stageMeta.get(wp.id)}
|
||||||
<li
|
<li
|
||||||
class="wp"
|
class="wp"
|
||||||
|
class:stage-start={stageCount > 1 && sm?.isStart}
|
||||||
class:unplaced={wp.unplaced}
|
class:unplaced={wp.unplaced}
|
||||||
class:active={wp.id === pendingPlacementId}
|
class:active={wp.id === pendingPlacementId}
|
||||||
class:focused={wp.id === mapView.focusId && !wp.unplaced}
|
class:focused={wp.id === mapView.focusId && !wp.unplaced}
|
||||||
animate:flip={{ duration: 220 }}
|
animate:flip={{ duration: 220 }}
|
||||||
>
|
>
|
||||||
|
{#if stageCount > 1 && sm?.isStart}
|
||||||
|
<div class="stage-band">
|
||||||
|
<span class="stage-badge"><Flag size={11} strokeWidth={2.25} />Etappe {sm.num}</span>
|
||||||
|
<input
|
||||||
|
class="stage-name"
|
||||||
|
value={wp.stageStart ?? sm.name}
|
||||||
|
placeholder={`Etappe ${sm.num}`}
|
||||||
|
oninput={(e) => renameStage(wp.id, e.currentTarget.value)}
|
||||||
|
aria-label={`Name Etappe ${sm.num}`}
|
||||||
|
/>
|
||||||
|
{#if !sm.first}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stage-merge"
|
||||||
|
onclick={() => toggleStageBreak(wp.id)}
|
||||||
|
title="Mit vorheriger Etappe zusammenführen"
|
||||||
|
aria-label="Etappe auflösen"
|
||||||
|
>
|
||||||
|
<X size={13} strokeWidth={2.25} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if wp.thumbnail || getFullImageUrl(wp.id)}
|
{#if wp.thumbnail || getFullImageUrl(wp.id)}
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<img
|
<img
|
||||||
@@ -189,6 +242,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
|
{#if !wp.unplaced && !sm?.first}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stage-flag"
|
||||||
|
class:on={sm?.isStart}
|
||||||
|
onclick={() => toggleStageBreak(wp.id)}
|
||||||
|
aria-pressed={sm?.isStart}
|
||||||
|
aria-label={sm?.isStart ? 'Etappenbeginn entfernen' : 'Neue Etappe ab hier'}
|
||||||
|
title={sm?.isStart ? 'Etappenbeginn entfernen' : 'Neue Etappe ab hier'}
|
||||||
|
>
|
||||||
|
<Flag size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if !wp.unplaced}
|
{#if !wp.unplaced}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -365,6 +431,70 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stage band at the top of the first card of each stage. */
|
||||||
|
.stage-band {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, var(--color-primary) 22%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 0.22rem 0.55rem;
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-merge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
appearance: none;
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-merge:hover {
|
||||||
|
color: var(--red);
|
||||||
|
border-color: color-mix(in oklab, var(--red) 40%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button.stage-flag.on {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: color-mix(in oklab, var(--color-primary) 40%, var(--color-border));
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 10%, var(--color-bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
.wp {
|
.wp {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
@@ -382,6 +512,11 @@
|
|||||||
background: color-mix(in oklab, var(--orange) 6%, var(--color-bg-secondary));
|
background: color-mix(in oklab, var(--orange) 6%, var(--color-bg-secondary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mark the first card of each stage with a top accent. */
|
||||||
|
.wp.stage-start {
|
||||||
|
border-top: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.wp.active {
|
.wp.active {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 30%, transparent);
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 30%, transparent);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { parseGpx, parseGpxImageRefs } from '$lib/gpx';
|
import { parseGpx, parseGpxStages, parseGpxImageRefs } from '$lib/gpx';
|
||||||
|
|
||||||
export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road';
|
export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road';
|
||||||
|
|
||||||
@@ -35,6 +35,11 @@ export type Waypoint = {
|
|||||||
* Lat/lng are placeholders (0/0) and the waypoint is hidden from the map
|
* Lat/lng are placeholders (0/0) and the waypoint is hidden from the map
|
||||||
* and excluded from GPX export until placed. */
|
* and excluded from GPX export until placed. */
|
||||||
unplaced?: boolean;
|
unplaced?: boolean;
|
||||||
|
/** When set, this (placed) waypoint begins a new stage of a multi-day
|
||||||
|
* hike, with this string as the stage name. The first placed waypoint
|
||||||
|
* always begins stage 1 implicitly; setting `stageStart` on it just names
|
||||||
|
* that stage. Exported as separate `<trk>` elements. */
|
||||||
|
stageStart?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BuilderState = {
|
export type BuilderState = {
|
||||||
@@ -150,6 +155,59 @@ export function placedSequence(wpId: string): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stages (multi-day hikes). A new stage begins at the first placed waypoint
|
||||||
|
// and at any placed waypoint carrying `stageStart`. Each stage exports as its
|
||||||
|
// own named <trk>.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Placed waypoints split into stages, in placed-index ranges. */
|
||||||
|
export function deriveStageGroups(): { name: string; startIdx: number; endIdx: number }[] {
|
||||||
|
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
||||||
|
const groups: { name: string; startIdx: number; endIdx: number }[] = [];
|
||||||
|
for (let i = 0; i < placed.length; i++) {
|
||||||
|
if (groups.length === 0 || placed[i].stageStart !== undefined) {
|
||||||
|
groups.push({
|
||||||
|
name: placed[i].stageStart || `Etappe ${groups.length + 1}`,
|
||||||
|
startIdx: i,
|
||||||
|
endIdx: i
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
groups[groups.length - 1].endIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle whether a placed waypoint begins a new stage. The route start can't
|
||||||
|
* be a break (it always begins stage 1). */
|
||||||
|
export function toggleStageBreak(wpId: string): void {
|
||||||
|
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
||||||
|
if (placed.length === 0 || placed[0].id === wpId) return;
|
||||||
|
const wp = builder.waypoints.find((w) => w.id === wpId);
|
||||||
|
if (!wp) return;
|
||||||
|
if (wp.stageStart !== undefined) {
|
||||||
|
delete wp.stageStart;
|
||||||
|
} else {
|
||||||
|
const idxInPlaced = placed.findIndex((w) => w.id === wpId);
|
||||||
|
let n = 0;
|
||||||
|
for (let i = 0; i <= idxInPlaced; i++) {
|
||||||
|
if (i === 0 || placed[i].stageStart !== undefined || placed[i].id === wpId) n++;
|
||||||
|
}
|
||||||
|
wp.stageStart = `Etappe ${n}`;
|
||||||
|
}
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name (or rename) the stage that begins at this waypoint. Setting it on the
|
||||||
|
* first placed waypoint names stage 1 without creating an extra break. */
|
||||||
|
export function renameStage(firstWpId: string, name: string): void {
|
||||||
|
const wp = builder.waypoints.find((w) => w.id === firstWpId);
|
||||||
|
if (!wp) return;
|
||||||
|
wp.stageStart = name;
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
export function scheduleSave(): void {
|
export function scheduleSave(): void {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -387,6 +445,19 @@ export function importGpx(xml: string): ImportGpxResult {
|
|||||||
if (trk.length < 2) {
|
if (trk.length < 2) {
|
||||||
return { ok: false, error: 'GPX enthält keinen verwertbaren Track (mind. zwei trkpt nötig).' };
|
return { ok: false, error: 'GPX enthält keinen verwertbaren Track (mind. zwei trkpt nötig).' };
|
||||||
}
|
}
|
||||||
|
// Stage boundaries: a multi-<trk> GPX is a multi-day route. Map each
|
||||||
|
// stage's first flat-track index to its name so we can re-mark the
|
||||||
|
// corresponding waypoint as a stage start.
|
||||||
|
const gpxStages = parseGpxStages(xml);
|
||||||
|
const multiStage = gpxStages.length > 1;
|
||||||
|
const stageNameAt = new Map<number, string>();
|
||||||
|
{
|
||||||
|
let off = 0;
|
||||||
|
for (let k = 0; k < gpxStages.length; k++) {
|
||||||
|
stageNameAt.set(off, gpxStages[k].name ?? `Etappe ${k + 1}`);
|
||||||
|
off += gpxStages[k].points.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
const imageRefs = parseGpxImageRefs(xml);
|
const imageRefs = parseGpxImageRefs(xml);
|
||||||
const imageList = Object.values(imageRefs);
|
const imageList = Object.values(imageRefs);
|
||||||
|
|
||||||
@@ -430,9 +501,11 @@ export function importGpx(xml: string): ImportGpxResult {
|
|||||||
}
|
}
|
||||||
imageAnchors.sort((a, b) => a.trkIdx - b.trkIdx);
|
imageAnchors.sort((a, b) => a.trkIdx - b.trkIdx);
|
||||||
|
|
||||||
// Build the set of anchor trkpt indices: first, last, all image anchors.
|
// Build the set of anchor trkpt indices: first, last, all image anchors,
|
||||||
|
// plus every stage boundary so multi-day breaks survive the round-trip.
|
||||||
const anchorIndices = new Set<number>([0, trk.length - 1]);
|
const anchorIndices = new Set<number>([0, trk.length - 1]);
|
||||||
for (const ia of imageAnchors) anchorIndices.add(ia.trkIdx);
|
for (const ia of imageAnchors) anchorIndices.add(ia.trkIdx);
|
||||||
|
if (multiStage) for (const idx of stageNameAt.keys()) anchorIndices.add(idx);
|
||||||
const sortedAnchorIdx = [...anchorIndices].sort((a, b) => a - b);
|
const sortedAnchorIdx = [...anchorIndices].sort((a, b) => a - b);
|
||||||
|
|
||||||
// Assemble waypoints in traversal order.
|
// Assemble waypoints in traversal order.
|
||||||
@@ -450,6 +523,11 @@ export function importGpx(xml: string): ImportGpxResult {
|
|||||||
wp.imageHash = ia.hash;
|
wp.imageHash = ia.hash;
|
||||||
wp.imageVisibility = ia.visibility;
|
wp.imageVisibility = ia.visibility;
|
||||||
}
|
}
|
||||||
|
// Re-mark stage starts (skip index 0 — the route start is stage 1
|
||||||
|
// implicitly; naming it is harmless but unnecessary).
|
||||||
|
if (multiStage && i > 0 && stageNameAt.has(i)) {
|
||||||
|
wp.stageStart = stageNameAt.get(i);
|
||||||
|
}
|
||||||
return wp;
|
return wp;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Active-stage selection for a multi-day hike detail page.
|
||||||
|
*
|
||||||
|
* `active` is the index into the hike's `stages[]`, or `null` for the
|
||||||
|
* "Alle Etappen" (whole route) view. The stage nav writes it; the map,
|
||||||
|
* elevation profile, metrics row and photo strip read it to scope themselves
|
||||||
|
* to one stage. A shared rune (like hoverStore / focusedImageStore) avoids
|
||||||
|
* prop-drilling through the two map instances.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const stage = $state<{ active: number | null }>({ active: null });
|
||||||
|
|
||||||
|
export function setActiveStage(index: number | null): void {
|
||||||
|
stage.active = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActiveStage(): void {
|
||||||
|
stage.active = null;
|
||||||
|
}
|
||||||
+63
-12
@@ -70,6 +70,38 @@ export function parseGpx(xml: string): GpxPoint[] {
|
|||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GpxStage {
|
||||||
|
/** `<name>` of the `<trk>`, or null when absent. */
|
||||||
|
name: string | null;
|
||||||
|
points: GpxPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a GPX into one stage per `<trk>` element (a multi-day route ships its
|
||||||
|
* stages as separate named tracks). Each stage keeps its own ordered points;
|
||||||
|
* concatenating them yields the same flat list `parseGpx` returns.
|
||||||
|
*
|
||||||
|
* Falls back to a single unnamed stage covering the whole document when there
|
||||||
|
* are no `<trk>` wrappers (e.g. an `<rtept>`-only route).
|
||||||
|
*/
|
||||||
|
export function parseGpxStages(xml: string): GpxStage[] {
|
||||||
|
const stages: GpxStage[] = [];
|
||||||
|
const trkRegex = /<trk>([\s\S]*?)<\/trk>/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = trkRegex.exec(xml)) !== null) {
|
||||||
|
const body = match[1];
|
||||||
|
const nameMatch = body.match(/<name>([^<]*)<\/name>/i);
|
||||||
|
const name = nameMatch ? nameMatch[1].trim() : null;
|
||||||
|
const points = parseGpx(body);
|
||||||
|
if (points.length > 0) stages.push({ name: name || null, points });
|
||||||
|
}
|
||||||
|
if (stages.length === 0) {
|
||||||
|
const points = parseGpx(xml);
|
||||||
|
if (points.length > 0) stages.push({ name: null, points });
|
||||||
|
}
|
||||||
|
return stages;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GpxImageRef {
|
export interface GpxImageRef {
|
||||||
hash: string;
|
hash: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -314,12 +346,8 @@ export interface GpxImageWaypoint {
|
|||||||
* the image's EXIF GPS — letting a contributor correct an image's position
|
* the image's EXIF GPS — letting a contributor correct an image's position
|
||||||
* by simply dragging the matching waypoint in the route-builder.
|
* by simply dragging the matching waypoint in the route-builder.
|
||||||
*/
|
*/
|
||||||
export function buildGpx(opts: {
|
function serializeTrkpts(points: GpxWritePoint[]): string {
|
||||||
name: string;
|
return points
|
||||||
trackPoints: GpxWritePoint[];
|
|
||||||
imageWaypoints?: GpxImageWaypoint[];
|
|
||||||
}): string {
|
|
||||||
const trkpts = opts.trackPoints
|
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const ele = typeof p.altitude === 'number' ? ` <ele>${p.altitude.toFixed(1)}</ele>\n` : '';
|
const ele = typeof p.altitude === 'number' ? ` <ele>${p.altitude.toFixed(1)}</ele>\n` : '';
|
||||||
const time = typeof p.timestamp === 'number'
|
const time = typeof p.timestamp === 'number'
|
||||||
@@ -328,6 +356,34 @@ export function buildGpx(opts: {
|
|||||||
return ` <trkpt lat="${p.lat}" lon="${p.lng}">\n${ele}${time} </trkpt>`;
|
return ` <trkpt lat="${p.lat}" lon="${p.lng}">\n${ele}${time} </trkpt>`;
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GpxTrack {
|
||||||
|
name: string;
|
||||||
|
points: GpxWritePoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGpx(opts: {
|
||||||
|
name: string;
|
||||||
|
/** Single-track convenience. Ignored when `tracks` is given. */
|
||||||
|
trackPoints?: GpxWritePoint[];
|
||||||
|
/** One `<trk>` per stage. Each gets its own `<name>`. */
|
||||||
|
tracks?: GpxTrack[];
|
||||||
|
imageWaypoints?: GpxImageWaypoint[];
|
||||||
|
}): string {
|
||||||
|
const tracks: GpxTrack[] =
|
||||||
|
opts.tracks && opts.tracks.length > 0
|
||||||
|
? opts.tracks
|
||||||
|
: [{ name: opts.name, points: opts.trackPoints ?? [] }];
|
||||||
|
|
||||||
|
const trksXml = tracks
|
||||||
|
.map(
|
||||||
|
(t) =>
|
||||||
|
` <trk>\n <name>${escapeXml(t.name)}</name>\n <trkseg>\n` +
|
||||||
|
`${serializeTrkpts(t.points)}\n` +
|
||||||
|
` </trkseg>\n </trk>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
const hasImages = (opts.imageWaypoints?.length ?? 0) > 0;
|
const hasImages = (opts.imageWaypoints?.length ?? 0) > 0;
|
||||||
const wpts = hasImages
|
const wpts = hasImages
|
||||||
@@ -352,12 +408,7 @@ export function buildGpx(opts: {
|
|||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<gpx version="1.1" creator="Bocken Route Builder" xmlns="http://www.topografix.com/GPX/1/1"${ns}>
|
<gpx version="1.1" creator="Bocken Route Builder" xmlns="http://www.topografix.com/GPX/1/1"${ns}>
|
||||||
${wpts} <trk>
|
${wpts}${trksXml}
|
||||||
<name>${escapeXml(opts.name)}</name>
|
|
||||||
<trkseg>
|
|
||||||
${trkpts}
|
|
||||||
</trkseg>
|
|
||||||
</trk>
|
|
||||||
</gpx>
|
</gpx>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
parseGpx,
|
parseGpx,
|
||||||
|
parseGpxStages,
|
||||||
parseGpxImageRefs,
|
parseGpxImageRefs,
|
||||||
trackDistance,
|
trackDistance,
|
||||||
haversineKm as haversine,
|
haversineKm as haversine,
|
||||||
type GpxPoint,
|
type GpxPoint,
|
||||||
|
type GpxStage,
|
||||||
type GpxImageRef
|
type GpxImageRef
|
||||||
} from '$lib/gpx';
|
} from '$lib/gpx';
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import HikeMap from '$lib/components/hikes/HikeMap.svelte';
|
import HikeMap from '$lib/components/hikes/HikeMap.svelte';
|
||||||
import HikePhotoStrip from '$lib/components/hikes/HikePhotoStrip.svelte';
|
import HikePhotoStrip from '$lib/components/hikes/HikePhotoStrip.svelte';
|
||||||
|
import HikeStageNav from '$lib/components/hikes/HikeStageNav.svelte';
|
||||||
import ElevationProfile from '$lib/components/hikes/ElevationProfile.svelte';
|
import ElevationProfile from '$lib/components/hikes/ElevationProfile.svelte';
|
||||||
|
import { stage, clearActiveStage } from '$lib/components/hikes/stageStore.svelte';
|
||||||
import Seo from '$lib/components/Seo.svelte';
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
import { setHikeContext } from '$lib/components/hikes/hikeContext.svelte';
|
import { setHikeContext } from '$lib/components/hikes/hikeContext.svelte';
|
||||||
import { listScrollAnchors } from '$lib/components/hikes/scrollAnchors';
|
import { listScrollAnchors } from '$lib/components/hikes/scrollAnchors';
|
||||||
@@ -98,9 +100,24 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Active-stage scoping (multi-day hikes). When a stage is selected, the
|
||||||
|
// metrics row + elevation view switch to that stage; "Alle Etappen" (null)
|
||||||
|
// shows the whole route. Single-stage hikes never show the nav.
|
||||||
|
const stages = $derived(hike.stages ?? null);
|
||||||
|
const hasStages = $derived(!!stages && stages.length > 1);
|
||||||
|
const activeStage = $derived(hasStages && stage.active !== null ? stages![stage.active] : null);
|
||||||
|
/** Metric source: the active stage, or the whole hike on "Alle Etappen". */
|
||||||
|
const m = $derived(activeStage ?? hike);
|
||||||
|
const stageViewRange = $derived(
|
||||||
|
activeStage ? { startIdx: activeStage.startIdx, endIdx: activeStage.endIdx } : null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset the shared selection when leaving the page.
|
||||||
|
$effect(() => () => clearActiveStage());
|
||||||
|
|
||||||
const durationLabel = $derived(
|
const durationLabel = $derived(
|
||||||
hike.durationMin !== null && hike.durationMin > 0
|
m.durationMin !== null && m.durationMin > 0
|
||||||
? `${Math.floor(hike.durationMin / 60)}h ${hike.durationMin % 60}m`
|
? `${Math.floor(m.durationMin / 60)}h ${m.durationMin % 60}m`
|
||||||
: '—'
|
: '—'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -339,6 +356,7 @@
|
|||||||
imagePoints={visibleImagePoints}
|
imagePoints={visibleImagePoints}
|
||||||
showPrivate
|
showPrivate
|
||||||
{trackColor}
|
{trackColor}
|
||||||
|
{stages}
|
||||||
initialCenter={heroPose?.center}
|
initialCenter={heroPose?.center}
|
||||||
initialZoom={heroPose?.zoom}
|
initialZoom={heroPose?.zoom}
|
||||||
onReady={() => (heroMapReady = true)}
|
onReady={() => (heroMapReady = true)}
|
||||||
@@ -372,9 +390,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if hasStages && stages}
|
||||||
|
<HikeStageNav {stages} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if track && track.length > 0 && visibleImagePoints.length > 0}
|
{#if track && track.length > 0 && visibleImagePoints.length > 0}
|
||||||
<section class="strip-area">
|
<section class="strip-area">
|
||||||
<HikePhotoStrip images={visibleImagePoints} {track} />
|
<HikePhotoStrip images={visibleImagePoints} {track} {stages} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -384,7 +406,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<Route size={20} strokeWidth={1.75} aria-hidden="true" />
|
<Route size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
<span class="value">{hike.distanceKm.toFixed(1)}<span class="value-unit">km</span></span>
|
<span class="value">{m.distanceKm.toFixed(1)}<span class="value-unit">km</span></span>
|
||||||
<span class="unit">Distanz</span>
|
<span class="unit">Distanz</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
@@ -394,25 +416,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
|
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
<span class="value">{hike.elevationGainM}<span class="value-unit">m</span></span>
|
<span class="value">{m.elevationGainM}<span class="value-unit">m</span></span>
|
||||||
<span class="unit">Aufstieg</span>
|
<span class="unit">Aufstieg</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" />
|
<TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
<span class="value">{hike.elevationLossM}<span class="value-unit">m</span></span>
|
<span class="value">{m.elevationLossM}<span class="value-unit">m</span></span>
|
||||||
<span class="unit">Abstieg</span>
|
<span class="unit">Abstieg</span>
|
||||||
</div>
|
</div>
|
||||||
{#if hike.elevationMaxM !== null}
|
{#if m.elevationMaxM !== null}
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
<span class="value">{hike.elevationMaxM}<span class="value-unit">m</span></span>
|
<span class="value">{m.elevationMaxM}<span class="value-unit">m</span></span>
|
||||||
<span class="unit">höchster</span>
|
<span class="unit">höchster</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hike.elevationMinM !== null}
|
{#if m.elevationMinM !== null}
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
<span class="value">{hike.elevationMinM}<span class="value-unit">m</span></span>
|
<span class="value">{m.elevationMinM}<span class="value-unit">m</span></span>
|
||||||
<span class="unit">tiefster</span>
|
<span class="unit">tiefster</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -447,15 +469,15 @@
|
|||||||
|
|
||||||
{#if track && track.length > 0}
|
{#if track && track.length > 0}
|
||||||
<section class="elev-area">
|
<section class="elev-area">
|
||||||
<ElevationProfile {track} />
|
<ElevationProfile {track} viewRange={stageViewRange} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="scroll-area">
|
<section class="scroll-area">
|
||||||
<aside class="trail-col">
|
<aside class="trail-col">
|
||||||
{#if track && track.length > 0}
|
{#if track && track.length > 0}
|
||||||
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate {trackColor} />
|
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate {trackColor} {stages} />
|
||||||
<ElevationProfile {track} />
|
<ElevationProfile {track} viewRange={stageViewRange} />
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import WaypointDetailPanel from '$lib/components/hikes/route-builder/WaypointDetailPanel.svelte';
|
import WaypointDetailPanel from '$lib/components/hikes/route-builder/WaypointDetailPanel.svelte';
|
||||||
import ImageDropzone from '$lib/components/hikes/route-builder/ImageDropzone.svelte';
|
import ImageDropzone from '$lib/components/hikes/route-builder/ImageDropzone.svelte';
|
||||||
import RouteStatsBar from '$lib/components/hikes/route-builder/RouteStatsBar.svelte';
|
import RouteStatsBar from '$lib/components/hikes/route-builder/RouteStatsBar.svelte';
|
||||||
import { assembleTrackPoints, buildGpx, type GpxImageWaypoint } from '$lib/gpx';
|
import { assembleTrackPoints, buildGpx, type GpxImageWaypoint, type GpxTrack } from '$lib/gpx';
|
||||||
import {
|
import {
|
||||||
builder,
|
builder,
|
||||||
focusWaypoint,
|
focusWaypoint,
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
clearDraft,
|
clearDraft,
|
||||||
reconcileSegments,
|
reconcileSegments,
|
||||||
densifyLinearSegments,
|
densifyLinearSegments,
|
||||||
|
deriveStageGroups,
|
||||||
importGpx
|
importGpx
|
||||||
} from '$lib/components/hikes/route-builder/builderStore.svelte';
|
} from '$lib/components/hikes/route-builder/builderStore.svelte';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
@@ -151,19 +152,29 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
||||||
|
// Assemble each stage independently so timestamps interpolate within a
|
||||||
|
// stage and the overnight gap between stages is never bridged. One
|
||||||
|
// <trk> per stage; a single-stage route yields one track (== before).
|
||||||
|
const groups = deriveStageGroups();
|
||||||
|
const tracks: GpxTrack[] = [];
|
||||||
|
for (const g of groups) {
|
||||||
const assembled = assembleTrackPoints({
|
const assembled = assembleTrackPoints({
|
||||||
waypoints: placed.map((w) => ({
|
waypoints: placed.slice(g.startIdx, g.endIdx + 1).map((w) => ({
|
||||||
lat: w.lat,
|
lat: w.lat,
|
||||||
lng: w.lng,
|
lng: w.lng,
|
||||||
altitude: w.altitude,
|
altitude: w.altitude,
|
||||||
timestamp: w.timestamp ?? null
|
timestamp: w.timestamp ?? null
|
||||||
})),
|
})),
|
||||||
routedSegments: builder.routedSegments
|
// Only the segments *within* the stage (between its consecutive
|
||||||
|
// waypoints); the boundary segment to the next stage is excluded.
|
||||||
|
routedSegments: builder.routedSegments.slice(g.startIdx, g.endIdx)
|
||||||
});
|
});
|
||||||
if (!assembled.ok) {
|
if (!assembled.ok) {
|
||||||
error = assembled.error;
|
error = groups.length > 1 ? `Etappe „${g.name}“: ${assembled.error}` : assembled.error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
tracks.push({ name: g.name, points: assembled.points });
|
||||||
|
}
|
||||||
error = null;
|
error = null;
|
||||||
// Look up altitude for a placed-waypoint index from the routed segments.
|
// Look up altitude for a placed-waypoint index from the routed segments.
|
||||||
// Mirrors the trkpt fallback in `assembleTrackPoints`: image waypoints
|
// Mirrors the trkpt fallback in `assembleTrackPoints`: image waypoints
|
||||||
@@ -199,7 +210,7 @@
|
|||||||
}));
|
}));
|
||||||
const gpx = buildGpx({
|
const gpx = buildGpx({
|
||||||
name: builder.name || 'Neue Wanderung',
|
name: builder.name || 'Neue Wanderung',
|
||||||
trackPoints: assembled.points,
|
tracks,
|
||||||
imageWaypoints
|
imageWaypoints
|
||||||
});
|
});
|
||||||
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
|
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ export type ImagePoint = {
|
|||||||
// [lng, lat, elevation?, unixMs?]
|
// [lng, lat, elevation?, unixMs?]
|
||||||
export type HikeTrackPoint = [number, number, number?, number?];
|
export type HikeTrackPoint = [number, number, number?, number?];
|
||||||
|
|
||||||
|
/** One stage of a multi-day hike. Index ranges point into the flat track JSON
|
||||||
|
* (`trackUrl`); stats are computed per stage so totals exclude the overnight
|
||||||
|
* gaps between them. Stages are disjoint and contiguous
|
||||||
|
* (`endIdx + 1 === next.startIdx`). */
|
||||||
|
export type HikeStage = {
|
||||||
|
name: string;
|
||||||
|
/** Inclusive start/end indices into the flat track. */
|
||||||
|
startIdx: number;
|
||||||
|
endIdx: number;
|
||||||
|
distanceKm: number;
|
||||||
|
durationMin: number | null;
|
||||||
|
elevationGainM: number;
|
||||||
|
elevationLossM: number;
|
||||||
|
elevationMaxM: number | null;
|
||||||
|
elevationMinM: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type HikeManifestEntry = {
|
export type HikeManifestEntry = {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -49,6 +66,15 @@ export type HikeManifestEntry = {
|
|||||||
bbox: [number, number, number, number]; // [minLat, minLng, maxLat, maxLng]
|
bbox: [number, number, number, number]; // [minLat, minLng, maxLat, maxLng]
|
||||||
centroid: [number, number];
|
centroid: [number, number];
|
||||||
previewPolyline: [number, number][];
|
previewPolyline: [number, number][];
|
||||||
|
/** Indices into `previewPolyline` where a new disconnected run begins —
|
||||||
|
* set only where the gap between two stages exceeds ~1 km, so the overview
|
||||||
|
* doesn't draw a straight line across an overnight transfer. Absent/empty
|
||||||
|
* ⇒ one continuous line. */
|
||||||
|
previewBreaks?: number[];
|
||||||
|
|
||||||
|
/** Present only for multi-day hikes (≥2 GPX `<trk>` elements). Single-stage
|
||||||
|
* hikes omit it and render exactly as before. */
|
||||||
|
stages?: HikeStage[];
|
||||||
|
|
||||||
// Reverse-geocoded from the centroid (Swisstopo):
|
// Reverse-geocoded from the centroid (Swisstopo):
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user