feat(route-builder): SAC-red trail + refit map on image drop

Trail polyline now uses the same SAC white-red-white red as the /hikes
overview and detail pages so the live preview reads as the final
published track. Dropping geolocated images now reframes the edit map
to the new bounds via a shared `mapView.fitTick` signal — covers GPX
imports too if they ever wire it up.
This commit is contained in:
2026-05-19 21:22:34 +02:00
parent 3b524e9c70
commit 7bede8cd64
4 changed files with 56 additions and 6 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.77.1", "version": "1.77.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -2,9 +2,11 @@
import type { Attachment } from 'svelte/attachments'; import type { Attachment } from 'svelte/attachments';
import { import {
builder, builder,
mapView,
nextWaypointId, nextWaypointId,
scheduleSave scheduleSave
} from './builderStore.svelte'; } from './builderStore.svelte';
import { SAC_TRAIL_COLOR } from '$lib/data/sacColors';
// Single-point Swisstopo elevation lookups are intentionally NOT used — // Single-point Swisstopo elevation lookups are intentionally NOT used —
// they returned 0 against WGS-84 inputs in practice, and image waypoints // they returned 0 against WGS-84 inputs in practice, and image waypoints
// don't need per-point altitudes anyway. Waypoint altitudes flow from // don't need per-point altitudes anyway. Waypoint altitudes flow from
@@ -135,14 +137,14 @@
// Lines: per-pair so each can carry a segIdx for inline insertion. // Lines: per-pair so each can carry a segIdx for inline insertion.
// Snapped + linear segments share the same visual styling — there's // Snapped + linear segments share the same visual styling — there's
// no need to call out the difference, the user picked the mode. // no need to call out the difference, the user picked the mode.
const primary = // SAC white-red-white red — matches /hikes overview + detail-page
getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim() || // trail colour so the live preview reads as the final published track.
'#5e81ac'; const trackColor = SAC_TRAIL_COLOR.T2;
if (builder.routedSegments.length > 0) { if (builder.routedSegments.length > 0) {
builder.routedSegments.forEach((seg, segIdx) => { builder.routedSegments.forEach((seg, segIdx) => {
const latLngs = seg.map((p) => [p[1], p[0]] as [number, number]); const latLngs = seg.map((p) => [p[1], p[0]] as [number, number]);
const poly = L.polyline(latLngs, { const poly = L.polyline(latLngs, {
color: primary, color: trackColor,
weight: 4, weight: 4,
opacity: 0.9 opacity: 0.9
}).addTo(lineLayer); }).addTo(lineLayer);
@@ -155,6 +157,23 @@
} }
} }
function fitToTrack() {
const points: [number, number][] = [];
for (const w of builder.waypoints) {
if (w.unplaced) continue;
points.push([w.lat, w.lng]);
}
for (const seg of builder.routedSegments) {
for (const p of seg) points.push([p[1], p[0]]);
}
if (points.length === 0) return;
if (points.length === 1) {
map.setView(points[0], 13);
return;
}
map.fitBounds(L.latLngBounds(points), { padding: [40, 40] });
}
// React to store changes. // React to store changes.
const stopRoot = $effect.root(() => { const stopRoot = $effect.root(() => {
$effect(() => { $effect(() => {
@@ -166,6 +185,17 @@
builder.routedSegments.length; builder.routedSegments.length;
render(); render();
}); });
// External fit-bounds requests (image drops, GPX imports).
// The map's own init-time auto-fit covers first-load; this
// effect handles every subsequent batch insertion.
let lastTick = mapView.fitTick;
$effect(() => {
const tick = mapView.fitTick;
if (tick === lastTick) return;
lastTick = tick;
fitToTrack();
});
}); });
// Click on blank map. In normal mode, append a new waypoint at the end. // Click on blank map. In normal mode, append a new waypoint at the end.
@@ -3,6 +3,7 @@
builder, builder,
insertWaypointChronologically, insertWaypointChronologically,
nextWaypointId, nextWaypointId,
requestFitBounds,
scheduleSave, scheduleSave,
type Waypoint type Waypoint
} from './builderStore.svelte'; } from './builderStore.svelte';
@@ -135,15 +136,23 @@
// by snap-to-route enrichment) is the only reliable elevation // by snap-to-route enrichment) is the only reliable elevation
// source against WGS-84 inputs, and its single-point variant kept // source against WGS-84 inputs, and its single-point variant kept
// returning 0 even with workaround attempts. // returning 0 even with workaround attempts.
let placedAny = false;
for (const p of prepared) { for (const p of prepared) {
if (!p.ok) continue; if (!p.ok) continue;
if (p.kind === 'new') insertWaypointChronologically(p.wp); if (p.kind === 'new') {
insertWaypointChronologically(p.wp);
if (p.hasGps) placedAny = true;
}
// Cache the original file so the waypoint table can show a // Cache the original file so the waypoint table can show a
// full-resolution preview this session (for both new + matched // full-resolution preview this session (for both new + matched
// waypoints). Persistence to localStorage keeps only the small // waypoints). Persistence to localStorage keeps only the small
// thumbnail. // thumbnail.
setFullImage(p.id, p.file); setFullImage(p.id, p.file);
} }
// Reframe the map to the new track. Only matters when the batch
// added at least one geolocated waypoint — unplaced images don't
// affect bounds, and matched-only drops leave coords unchanged.
if (placedAny) requestFitBounds();
} }
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
@@ -107,6 +107,17 @@ function defaultState(): BuilderState {
export const builder = $state<BuilderState>(loadDraft()); export const builder = $state<BuilderState>(loadDraft());
/**
* UI-only signal for the edit map: bumping `fitTick` asks the map to
* re-run `fitBounds()` on the current track. Used after batch insertions
* (image drops, GPX import) where the user expects the map to reframe to
* show every newly-added waypoint. Not persisted.
*/
export const mapView = $state({ fitTick: 0 });
export function requestFitBounds(): void {
mapView.fitTick++;
}
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;