feat(route-builder): stats bar, waypoint detail panel, elevation refactor
Work-in-progress route-builder checkpoint: - New RouteStatsBar and WaypointDetailPanel components. - EditMap / ImageDropzone / WaypointTable / builderStore updates. - Hoist the elevation gain/loss/range helpers out of build-hikes.ts into src/lib/hikes/elevation.ts so the builder and the build share one implementation. Also bundled here (same file, couldn't be split cleanly): build-hikes.ts now detects each hike's country at build time — 'CH' when a Swiss canton matched, otherwise an OSM/Nominatim reverse-geocode — and writes it to the manifest, feeding the new Kanton/Land filter.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.80.0",
|
"version": "1.81.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+38
-63
@@ -29,6 +29,7 @@ import {
|
|||||||
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';
|
||||||
import { sacTrailColor, SAC_TRAIL_COLOR } from '../src/lib/data/sacColors.js';
|
import { sacTrailColor, SAC_TRAIL_COLOR } from '../src/lib/data/sacColors.js';
|
||||||
|
import { computeElevationStats, computeElevationRange } from '../src/lib/hikes/elevation.js';
|
||||||
import type {
|
import type {
|
||||||
Difficulty,
|
Difficulty,
|
||||||
HikeManifestEntry,
|
HikeManifestEntry,
|
||||||
@@ -55,8 +56,6 @@ const CACHE_DIR = path.join(ROOT, 'scripts', '.cache');
|
|||||||
const GEOCODE_CACHE_FILE = path.join(CACHE_DIR, 'hikes-geocode.json');
|
const GEOCODE_CACHE_FILE = path.join(CACHE_DIR, 'hikes-geocode.json');
|
||||||
const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'hikes.generated.ts');
|
const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'hikes.generated.ts');
|
||||||
|
|
||||||
const ELEV_SMOOTH_WINDOW = 5; // moving-average window for altitude denoising
|
|
||||||
const ELEV_MIN_STEP_M = 3; // discard altitude deltas below this
|
|
||||||
const PREVIEW_POLYLINE_MAX_POINTS = 150;
|
const PREVIEW_POLYLINE_MAX_POINTS = 150;
|
||||||
const IMAGE_WIDTHS = [480, 960, 1600] as const;
|
const IMAGE_WIDTHS = [480, 960, 1600] as const;
|
||||||
const IMAGE_THUMBNAIL_WIDTH = 240; // popup thumbnail for map markers
|
const IMAGE_THUMBNAIL_WIDTH = 240; // popup thumbnail for map markers
|
||||||
@@ -184,67 +183,9 @@ function parseSeasonRange(raw: unknown): { seasonStart: number | null; seasonEnd
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Elevation helpers
|
// Bounding box / centroid
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Returns `null` for indices where no defined altitude exists in the ±half
|
|
||||||
// window. The previous behaviour (defaulting to 0) silently turned missing
|
|
||||||
// `<ele>` tags into huge synthetic gain spikes against the next real altitude.
|
|
||||||
function smoothAltitudes(track: GpxPoint[]): (number | null)[] {
|
|
||||||
const n = track.length;
|
|
||||||
const out = new Array<number | null>(n);
|
|
||||||
const half = Math.floor(ELEV_SMOOTH_WINDOW / 2);
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
let sum = 0;
|
|
||||||
let count = 0;
|
|
||||||
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
|
|
||||||
const a = track[j].altitude;
|
|
||||||
if (typeof a === 'number') {
|
|
||||||
sum += a;
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out[i] = count > 0 ? sum / count : null;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeElevationStats(track: GpxPoint[]): { gain: number; loss: number } {
|
|
||||||
if (track.length < 2) return { gain: 0, loss: 0 };
|
|
||||||
const altitudes = smoothAltitudes(track);
|
|
||||||
let gain = 0;
|
|
||||||
let loss = 0;
|
|
||||||
let prev: number | null = null;
|
|
||||||
for (const a of altitudes) {
|
|
||||||
if (a === null) continue;
|
|
||||||
if (prev === null) {
|
|
||||||
prev = a;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const diff = a - prev;
|
|
||||||
if (diff >= ELEV_MIN_STEP_M) {
|
|
||||||
gain += diff;
|
|
||||||
prev = a;
|
|
||||||
} else if (diff <= -ELEV_MIN_STEP_M) {
|
|
||||||
loss += -diff;
|
|
||||||
prev = a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { gain: Math.round(gain), loss: Math.round(loss) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeElevationRange(track: GpxPoint[]): { min: number | null; max: number | null } {
|
|
||||||
let min = Infinity;
|
|
||||||
let max = -Infinity;
|
|
||||||
for (const p of track) {
|
|
||||||
if (typeof p.altitude !== 'number') continue;
|
|
||||||
if (p.altitude < min) min = p.altitude;
|
|
||||||
if (p.altitude > max) max = p.altitude;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return { min: null, max: null };
|
|
||||||
return { min: Math.round(min), max: Math.round(max) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeBboxAndCentroid(track: GpxPoint[]): {
|
function computeBboxAndCentroid(track: GpxPoint[]): {
|
||||||
bbox: [number, number, number, number];
|
bbox: [number, number, number, number];
|
||||||
centroid: [number, number];
|
centroid: [number, number];
|
||||||
@@ -274,6 +215,9 @@ type GeocodeResult = {
|
|||||||
canton: string | null;
|
canton: string | null;
|
||||||
municipality: string | null;
|
municipality: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
|
/** ISO 3166-1 alpha-2 code. 'CH' whenever a Swiss canton matched;
|
||||||
|
* otherwise resolved via an OSM/Nominatim country lookup. */
|
||||||
|
country: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GeocodeCache = Record<string, GeocodeResult>;
|
type GeocodeCache = Record<string, GeocodeResult>;
|
||||||
@@ -293,6 +237,31 @@ async function saveGeocodeCache(cache: GeocodeCache): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SWISSTOPO_UA = 'bocken-homepage build-hikes';
|
const SWISSTOPO_UA = 'bocken-homepage build-hikes';
|
||||||
|
const NOMINATIM_UA = 'bocken-homepage build-hikes (https://bocken.org)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Country detection for hikes outside Switzerland. Swisstopo only covers CH,
|
||||||
|
* so when no canton matched we ask OSM/Nominatim for the country at the
|
||||||
|
* centroid. Returns an uppercase ISO 3166-1 alpha-2 code, or null on failure.
|
||||||
|
*/
|
||||||
|
async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
|
||||||
|
const url =
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?format=jsonv2` +
|
||||||
|
`&lat=${lat}&lon=${lng}&zoom=3&addressdetails=1`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': NOMINATIM_UA } });
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[build-hikes] Nominatim country lookup failed (${res.status})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as { address?: { country_code?: string } };
|
||||||
|
const cc = json.address?.country_code;
|
||||||
|
return typeof cc === 'string' ? cc.toUpperCase() : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[build-hikes] Nominatim country lookup error:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchFeatureName(layerBodId: string, featureId: number | string): Promise<string | null> {
|
async function fetchFeatureName(layerBodId: string, featureId: number | string): Promise<string | null> {
|
||||||
const url = `https://api3.geo.admin.ch/rest/services/api/MapServer/${layerBodId}/${featureId}/htmlPopup?lang=de`;
|
const url = `https://api3.geo.admin.ch/rest/services/api/MapServer/${layerBodId}/${featureId}/htmlPopup?lang=de`;
|
||||||
@@ -315,7 +284,8 @@ async function reverseGeocode(
|
|||||||
cache: GeocodeCache
|
cache: GeocodeCache
|
||||||
): Promise<GeocodeResult> {
|
): Promise<GeocodeResult> {
|
||||||
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
|
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
|
||||||
if (cache[key]) return cache[key];
|
// `country` post-dates the cache format — re-resolve entries that predate it.
|
||||||
|
if (cache[key] && cache[key].country !== undefined) return cache[key];
|
||||||
|
|
||||||
const layers =
|
const layers =
|
||||||
'all:ch.swisstopo.swissboundaries3d-kanton-flaeche.fill,' +
|
'all:ch.swisstopo.swissboundaries3d-kanton-flaeche.fill,' +
|
||||||
@@ -332,7 +302,7 @@ async function reverseGeocode(
|
|||||||
`&mapExtent=${lng - eps},${lat - eps},${lng + eps},${lat + eps}` +
|
`&mapExtent=${lng - eps},${lat - eps},${lng + eps},${lat + eps}` +
|
||||||
`&tolerance=1&layers=${layers}&sr=4326`;
|
`&tolerance=1&layers=${layers}&sr=4326`;
|
||||||
|
|
||||||
const result: GeocodeResult = { canton: null, municipality: null, region: null };
|
const result: GeocodeResult = { canton: null, municipality: null, region: null, country: null };
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: { 'User-Agent': SWISSTOPO_UA } });
|
const res = await fetch(url, { headers: { 'User-Agent': SWISSTOPO_UA } });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -359,6 +329,10 @@ async function reverseGeocode(
|
|||||||
console.warn(`[build-hikes] Swisstopo identify error for ${key}:`, err);
|
console.warn(`[build-hikes] Swisstopo identify error for ${key}:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Country: 'CH' when a Swiss canton matched (no extra request needed),
|
||||||
|
// otherwise an OSM/Nominatim lookup for hikes abroad.
|
||||||
|
result.country = result.canton ? 'CH' : await reverseGeocodeCountry(lat, lng);
|
||||||
|
|
||||||
cache[key] = result;
|
cache[key] = result;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -1185,6 +1159,7 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
region: geo.region,
|
region: geo.region,
|
||||||
canton: geo.canton,
|
canton: geo.canton,
|
||||||
municipality: geo.municipality,
|
municipality: geo.municipality,
|
||||||
|
country: geo.country,
|
||||||
trackUrl: `/hikes/${slug}/track.${trackHash}.json`,
|
trackUrl: `/hikes/${slug}/track.${trackHash}.json`,
|
||||||
pointCount: track.length,
|
pointCount: track.length,
|
||||||
cover,
|
cover,
|
||||||
|
|||||||
@@ -37,6 +37,54 @@
|
|||||||
const DEFAULT_CENTER: [number, number] = [46.8, 8.3];
|
const DEFAULT_CENTER: [number, number] = [46.8, 8.3];
|
||||||
const DEFAULT_ZOOM = 8;
|
const DEFAULT_ZOOM = 8;
|
||||||
|
|
||||||
|
const TRACK_COLOR = SAC_TRAIL_COLOR.T2;
|
||||||
|
const ACCENT_COLOR = '#2965c8'; // SAC T4 blue — used for the focused-marker accent ring
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin geometry:
|
||||||
|
// - Solo pin: 28 wide × 36 tall, head r=10 at (14,14), tip at (14,36).
|
||||||
|
// - Image pin: 44 wide × 52 tall, head r=15 (clip) inside r=18 (frame)
|
||||||
|
// at (22,22), tip at (22,52).
|
||||||
|
// Both anchor at the tip so `iconAnchor = [width/2, height]`.
|
||||||
|
function makePinIcon(num: number, opts: { active: boolean }) {
|
||||||
|
const ring = opts.active ? ACCENT_COLOR : 'white';
|
||||||
|
const ringWidth = opts.active ? 3 : 2;
|
||||||
|
const html = `
|
||||||
|
<svg viewBox="0 0 28 36" width="28" height="36" class="rb-pin solo${opts.active ? ' is-active' : ''}" aria-hidden="true">
|
||||||
|
<path d="M14 36 L5.1 18.5 A10 10 0 1 1 22.9 18.5 Z"
|
||||||
|
fill="${TRACK_COLOR}" stroke="${ring}" stroke-width="${ringWidth}" stroke-linejoin="round" />
|
||||||
|
<text x="14" y="17.6" text-anchor="middle" font-size="11" font-weight="700"
|
||||||
|
fill="white" font-family="ui-sans-serif,system-ui,Helvetica,Arial,sans-serif">${num}</text>
|
||||||
|
</svg>`;
|
||||||
|
return { html, size: [28, 36] as [number, number], anchor: [14, 36] as [number, number] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeImagePinIcon(num: number, thumb: string, opts: { active: boolean }) {
|
||||||
|
const safeThumb = escapeAttr(thumb);
|
||||||
|
const ring = opts.active ? ACCENT_COLOR : TRACK_COLOR;
|
||||||
|
const ringWidth = opts.active ? 3 : 2.5;
|
||||||
|
const clipId = `rb-pin-head-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const html = `
|
||||||
|
<svg viewBox="0 0 44 52" width="44" height="52" class="rb-pin image${opts.active ? ' is-active' : ''}" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="${clipId}"><circle cx="22" cy="22" r="15" /></clipPath>
|
||||||
|
</defs>
|
||||||
|
<path d="M22 52 L7.6 32.8 A18 18 0 1 1 36.4 32.8 Z"
|
||||||
|
fill="white" stroke="${ring}" stroke-width="${ringWidth}" stroke-linejoin="round" />
|
||||||
|
<image href="${safeThumb}" x="7" y="7" width="30" height="30"
|
||||||
|
clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice" />
|
||||||
|
<g transform="translate(34 9)">
|
||||||
|
<circle r="7.5" fill="${ring}" stroke="white" stroke-width="1.5" />
|
||||||
|
<text y="3" text-anchor="middle" font-size="9" font-weight="700" fill="white"
|
||||||
|
font-family="ui-sans-serif,system-ui,Helvetica,Arial,sans-serif">${num}</text>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
return { html, size: [44, 52] as [number, number], anchor: [22, 52] as [number, number] };
|
||||||
|
}
|
||||||
|
|
||||||
const editAttachment: Attachment<HTMLElement> = (node) => {
|
const editAttachment: Attachment<HTMLElement> = (node) => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let cleanup: (() => void) | undefined;
|
let cleanup: (() => void) | undefined;
|
||||||
@@ -61,20 +109,20 @@
|
|||||||
const markerLayer = L.layerGroup().addTo(map);
|
const markerLayer = L.layerGroup().addTo(map);
|
||||||
const lineLayer = L.layerGroup().addTo(map);
|
const lineLayer = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
function makeNumberedIcon(num: number, thumbnail?: string) {
|
// Map of waypointId → marker, kept in sync by render(). Used by the
|
||||||
if (thumbnail) {
|
// focus effect so it can pan/zoom + style the marker for `mapView.focusId`
|
||||||
return L.divIcon({
|
// without forcing a full re-render of every marker.
|
||||||
className: 'rb-waypoint with-thumb',
|
const markerByWp = new Map<string, ReturnType<typeof L.marker>>();
|
||||||
html: `<span class="thumb"><img src="${thumbnail}" alt="" /></span><span class="num">${num}</span>`,
|
|
||||||
iconSize: [56, 56],
|
function buildIcon(num: number, wp: { thumbnail?: string }, active: boolean) {
|
||||||
iconAnchor: [28, 28]
|
const spec = wp.thumbnail
|
||||||
});
|
? makeImagePinIcon(num, wp.thumbnail, { active })
|
||||||
}
|
: makePinIcon(num, { active });
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: 'rb-waypoint',
|
className: 'rb-waypoint',
|
||||||
html: `<span class="num solo">${num}</span>`,
|
html: spec.html,
|
||||||
iconSize: [28, 28],
|
iconSize: spec.size,
|
||||||
iconAnchor: [14, 28]
|
iconAnchor: spec.anchor
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +152,7 @@
|
|||||||
function render() {
|
function render() {
|
||||||
markerLayer.clearLayers();
|
markerLayer.clearLayers();
|
||||||
lineLayer.clearLayers();
|
lineLayer.clearLayers();
|
||||||
|
markerByWp.clear();
|
||||||
|
|
||||||
// Markers per waypoint. Skip unplaced ones — they don't have a
|
// Markers per waypoint. Skip unplaced ones — they don't have a
|
||||||
// usable lat/lng and live only in the waypoint table.
|
// usable lat/lng and live only in the waypoint table.
|
||||||
@@ -112,11 +161,16 @@
|
|||||||
if (w.unplaced) return;
|
if (w.unplaced) return;
|
||||||
placedIndices.push(idx);
|
placedIndices.push(idx);
|
||||||
});
|
});
|
||||||
|
const focusId = mapView.focusId;
|
||||||
placedIndices.forEach((idx, displayPos) => {
|
placedIndices.forEach((idx, displayPos) => {
|
||||||
const w = builder.waypoints[idx];
|
const w = builder.waypoints[idx];
|
||||||
|
const seqNum = displayPos + 1;
|
||||||
const marker = L.marker([w.lat, w.lng], {
|
const marker = L.marker([w.lat, w.lng], {
|
||||||
icon: makeNumberedIcon(displayPos + 1, w.thumbnail),
|
icon: buildIcon(seqNum, w, w.id === focusId),
|
||||||
draggable: true
|
draggable: true,
|
||||||
|
// Lift the focused marker above its neighbours so its accent
|
||||||
|
// ring isn't covered by an adjacent unfocused pin.
|
||||||
|
zIndexOffset: w.id === focusId ? 1000 : 0
|
||||||
}).addTo(markerLayer);
|
}).addTo(markerLayer);
|
||||||
marker.on('dragend', () => {
|
marker.on('dragend', () => {
|
||||||
const p = marker.getLatLng();
|
const p = marker.getLatLng();
|
||||||
@@ -132,6 +186,11 @@
|
|||||||
scheduleSave();
|
scheduleSave();
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
marker.on('click', () => {
|
||||||
|
mapView.focusId = w.id;
|
||||||
|
mapView.focusTick++;
|
||||||
|
});
|
||||||
|
markerByWp.set(w.id, marker);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lines: per-pair so each can carry a segIdx for inline insertion.
|
// Lines: per-pair so each can carry a segIdx for inline insertion.
|
||||||
@@ -139,12 +198,11 @@
|
|||||||
// no need to call out the difference, the user picked the mode.
|
// no need to call out the difference, the user picked the mode.
|
||||||
// SAC white-red-white red — matches /hikes overview + detail-page
|
// SAC white-red-white red — matches /hikes overview + detail-page
|
||||||
// trail colour so the live preview reads as the final published track.
|
// trail colour so the live preview reads as the final published track.
|
||||||
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: trackColor,
|
color: TRACK_COLOR,
|
||||||
weight: 4,
|
weight: 4,
|
||||||
opacity: 0.9
|
opacity: 0.9
|
||||||
}).addTo(lineLayer);
|
}).addTo(lineLayer);
|
||||||
@@ -174,28 +232,51 @@
|
|||||||
map.fitBounds(L.latLngBounds(points), { padding: [40, 40] });
|
map.fitBounds(L.latLngBounds(points), { padding: [40, 40] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusOnWaypoint(id: string | null) {
|
||||||
|
if (!id) return;
|
||||||
|
const wp = builder.waypoints.find((w) => w.id === id);
|
||||||
|
if (!wp || wp.unplaced) return;
|
||||||
|
// Zoom in but don't over-zoom — 16 reads as "this trail junction"
|
||||||
|
// without losing surrounding context. flyTo gives smooth motion.
|
||||||
|
const targetZoom = Math.max(map.getZoom(), 16);
|
||||||
|
map.flyTo([wp.lat, wp.lng], targetZoom, { duration: 0.6 });
|
||||||
|
}
|
||||||
|
|
||||||
// React to store changes.
|
// React to store changes.
|
||||||
const stopRoot = $effect.root(() => {
|
const stopRoot = $effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Touch each reactive field so we re-render on any mutation.
|
// Touch each reactive field so we re-render on any mutation,
|
||||||
|
// including focus changes (so the active marker re-styles).
|
||||||
builder.waypoints.length;
|
builder.waypoints.length;
|
||||||
for (const w of builder.waypoints) {
|
for (const w of builder.waypoints) {
|
||||||
w.lat; w.lng; w.thumbnail;
|
w.lat; w.lng; w.thumbnail;
|
||||||
}
|
}
|
||||||
builder.routedSegments.length;
|
builder.routedSegments.length;
|
||||||
|
mapView.focusId;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
// External fit-bounds requests (image drops, GPX imports).
|
// External fit-bounds requests (image drops, GPX imports).
|
||||||
// The map's own init-time auto-fit covers first-load; this
|
// The map's own init-time auto-fit covers first-load; this
|
||||||
// effect handles every subsequent batch insertion.
|
// effect handles every subsequent batch insertion.
|
||||||
let lastTick = mapView.fitTick;
|
let lastFitTick = mapView.fitTick;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const tick = mapView.fitTick;
|
const tick = mapView.fitTick;
|
||||||
if (tick === lastTick) return;
|
if (tick === lastFitTick) return;
|
||||||
lastTick = tick;
|
lastFitTick = tick;
|
||||||
fitToTrack();
|
fitToTrack();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Focus requests (table row "fokussieren", prev/next nav bar).
|
||||||
|
// Tick is bumped on every request even if the id stays the same
|
||||||
|
// so repeated clicks re-center even if the user panned away.
|
||||||
|
let lastFocusTick = mapView.focusTick;
|
||||||
|
$effect(() => {
|
||||||
|
const tick = mapView.focusTick;
|
||||||
|
if (tick === lastFocusTick) return;
|
||||||
|
lastFocusTick = tick;
|
||||||
|
focusOnWaypoint(mapView.focusId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -263,7 +344,7 @@
|
|||||||
|
|
||||||
.edit-map {
|
.edit-map {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 600px;
|
height: 640px;
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
@@ -272,7 +353,7 @@
|
|||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.edit-map {
|
.edit-map {
|
||||||
height: 480px;
|
height: 520px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,58 +392,40 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DON'T override `position` here — Leaflet sets `.leaflet-marker-icon` to
|
/* Leaflet wraps each marker in `.leaflet-marker-icon` with its own
|
||||||
* `position: absolute` for placement, and the inner `.num` badge relies on
|
* absolute positioning. We just neutralise its default frame/background
|
||||||
* that same ancestor as its abs-positioning context. Reassigning to
|
* so the SVG pin shows through cleanly. */
|
||||||
* `position: relative` causes markers to fall into normal flow and stack
|
|
||||||
* vertically instead of sitting at their lat/lng. */
|
|
||||||
:global(.rb-waypoint) {
|
:global(.rb-waypoint) {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.rb-waypoint .num) {
|
:global(.rb-pin) {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 0.4em;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-on-primary);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
border: 2px solid var(--color-surface);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.rb-waypoint .num.solo) {
|
|
||||||
min-width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.rb-waypoint.with-thumb .num) {
|
|
||||||
position: absolute;
|
|
||||||
top: -6px;
|
|
||||||
right: -6px;
|
|
||||||
min-width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.rb-waypoint .thumb) {
|
|
||||||
display: block;
|
display: block;
|
||||||
width: 56px;
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
|
||||||
height: 56px;
|
transition: filter 200ms ease, transform 200ms ease;
|
||||||
border-radius: var(--radius-sm);
|
transform-origin: 50% 100%;
|
||||||
overflow: hidden;
|
|
||||||
border: 2px solid var(--color-surface);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.rb-waypoint .thumb img) {
|
:global(.rb-waypoint:hover .rb-pin) {
|
||||||
width: 100%;
|
transform: scale(1.08);
|
||||||
height: 100%;
|
}
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
:global(.rb-pin.is-active) {
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklab, #2965c8 70%, transparent))
|
||||||
|
drop-shadow(0 2px 3px rgba(0, 0, 0, 0.4));
|
||||||
|
animation: rb-pin-bounce 0.55s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rb-pin-bounce {
|
||||||
|
0% { transform: scale(0.85) translateY(-4px); }
|
||||||
|
60% { transform: scale(1.12); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:global(.rb-pin.is-active) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
import { generateImageHashClient } from '$lib/imageHashClient';
|
import { generateImageHashClient } from '$lib/imageHashClient';
|
||||||
import { readThumbnail } from './imageThumbnail';
|
import { readThumbnail } from './imageThumbnail';
|
||||||
import { setFullImage } from './fullImageCache.svelte';
|
import { setFullImage } from './fullImageCache.svelte';
|
||||||
|
import ImagePlus from '@lucide/svelte/icons/image-plus';
|
||||||
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import '$lib/css/action_button.css';
|
||||||
|
|
||||||
type Status = 'pending' | 'placed' | 'unplaced' | 'matched' | 'error';
|
type Status = 'pending' | 'placed' | 'unplaced' | 'matched' | 'error';
|
||||||
|
|
||||||
@@ -24,31 +29,71 @@
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Called when the user picks/drops a `.gpx` file via the FAB.
|
||||||
|
* Owning page handles the import + draft-replacement confirm.
|
||||||
|
* When absent, GPX files are silently ignored. */
|
||||||
|
onGpxImport?: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onGpxImport }: Props = $props();
|
||||||
|
|
||||||
|
function isGpxFile(file: File): boolean {
|
||||||
|
if (file.name.toLowerCase().endsWith('.gpx')) return true;
|
||||||
|
return (
|
||||||
|
file.type === 'application/gpx+xml' ||
|
||||||
|
file.type === 'application/xml' ||
|
||||||
|
file.type === 'text/xml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let entries = $state<Entry[]>([]);
|
let entries = $state<Entry[]>([]);
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
|
let showFailDetails = $state(false);
|
||||||
|
|
||||||
// Counts hash-only image waypoints (typically restored from a GPX
|
|
||||||
// import) that don't yet have a thumbnail — surfaces a contextual
|
|
||||||
// hint in the dropzone header so the user knows that dropping the
|
|
||||||
// source JPEGs here will attach previews to those rows in the table.
|
|
||||||
const orphanImageCount = $derived(
|
const orphanImageCount = $derived(
|
||||||
builder.waypoints.filter((w) => w.imageHash && !w.thumbnail).length
|
builder.waypoints.filter((w) => w.imageHash && !w.thumbnail).length
|
||||||
);
|
);
|
||||||
|
const pendingCount = $derived(entries.filter((e) => e.status === 'pending').length);
|
||||||
|
const failedEntries = $derived(
|
||||||
|
entries.filter((e) => e.status === 'error' || e.status === 'unplaced')
|
||||||
|
);
|
||||||
|
const failCount = $derived(failedEntries.length);
|
||||||
|
|
||||||
|
// Numeric badge on the FAB. Pending wins (in-flight work), then
|
||||||
|
// failures (need attention), then orphan hash-only waypoints from a
|
||||||
|
// GPX import (waiting for their source images).
|
||||||
|
const badge = $derived(
|
||||||
|
pendingCount > 0
|
||||||
|
? pendingCount
|
||||||
|
: failCount > 0
|
||||||
|
? failCount
|
||||||
|
: orphanImageCount > 0
|
||||||
|
? orphanImageCount
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
const badgeTone = $derived<'pending' | 'fail' | 'info'>(
|
||||||
|
pendingCount > 0 ? 'pending' : failCount > 0 ? 'fail' : 'info'
|
||||||
|
);
|
||||||
|
|
||||||
type Prepared =
|
type Prepared =
|
||||||
| { ok: true; kind: 'new'; wp: Waypoint; hasGps: boolean; id: string; file: File }
|
| { ok: true; kind: 'new'; wp: Waypoint; hasGps: boolean; id: string; file: File }
|
||||||
| { ok: true; kind: 'matched'; id: string; file: File }
|
| { ok: true; kind: 'matched'; id: string; file: File }
|
||||||
| { ok: false };
|
| { ok: false };
|
||||||
|
|
||||||
|
// Auto-clear successful entries after 4s so the badge counter doesn't
|
||||||
|
// pile up. Failures stay until the user dismisses them.
|
||||||
|
function scheduleAutoDismiss(id: string, ms = 4000) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const e = entries.find((x) => x.id === id);
|
||||||
|
if (!e) return;
|
||||||
|
if (e.status === 'placed' || e.status === 'matched') dismiss(id);
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFiles(files: File[]) {
|
async function handleFiles(files: File[]) {
|
||||||
const exifr = (await import('exifr')).default;
|
const exifr = (await import('exifr')).default;
|
||||||
|
|
||||||
// Prep every file in parallel (EXIF + hash + thumbnail). The result
|
|
||||||
// is staged in `prepared` rather than pushed into `builder.waypoints`
|
|
||||||
// one at a time — that way the snap-to-route effect (which fires on
|
|
||||||
// every waypoint insertion) sees a single synchronous batch insertion
|
|
||||||
// at the end instead of N consecutive ones. The Brouter / Swisstopo
|
|
||||||
// routing API only gets hit once per bulk upload.
|
|
||||||
const prepared = await Promise.all(
|
const prepared = await Promise.all(
|
||||||
files.map(async (file): Promise<Prepared> => {
|
files.map(async (file): Promise<Prepared> => {
|
||||||
const id = nextWaypointId();
|
const id = nextWaypointId();
|
||||||
@@ -64,40 +109,32 @@
|
|||||||
} catch { /* preview is optional */ }
|
} catch { /* preview is optional */ }
|
||||||
const imageHash = await generateImageHashClient(file);
|
const imageHash = await generateImageHashClient(file);
|
||||||
|
|
||||||
// Match path: if a previously-imported (or earlier-dropped)
|
// Match path: re-attach to an existing waypoint with the
|
||||||
// waypoint already carries this content hash, attach the
|
// same content hash (covers the GPX-roundtrip flow).
|
||||||
// thumbnail to it instead of creating a duplicate marker.
|
|
||||||
// Covers the GPX-roundtrip flow where the user loads an
|
|
||||||
// existing GPX (image hashes restored as bare waypoints)
|
|
||||||
// and then drops the source images to give them previews.
|
|
||||||
const existing = untrack(() =>
|
const existing = untrack(() =>
|
||||||
builder.waypoints.find((w) => w.imageHash === imageHash)
|
builder.waypoints.find((w) => w.imageHash === imageHash)
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (thumbnail && !existing.thumbnail) existing.thumbnail = thumbnail;
|
if (thumbnail && !existing.thumbnail) existing.thumbnail = thumbnail;
|
||||||
// Trust the imported visibility if the existing waypoint
|
|
||||||
// already has one set — re-dropping shouldn't silently
|
|
||||||
// flip a private photo to public.
|
|
||||||
if (!existing.imageVisibility) existing.imageVisibility = 'public';
|
if (!existing.imageVisibility) existing.imageVisibility = 'public';
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
entries[entryIdx].status = 'matched';
|
entries[entryIdx].status = 'matched';
|
||||||
entries[entryIdx].message = existing.unplaced
|
entries[entryIdx].message = existing.unplaced
|
||||||
? 'noch nicht auf der Karte platziert'
|
? 'noch nicht auf der Karte platziert'
|
||||||
: undefined;
|
: undefined;
|
||||||
|
scheduleAutoDismiss(entries[entryIdx].id);
|
||||||
return { ok: true, kind: 'matched', id: existing.id, file };
|
return { ok: true, kind: 'matched', id: existing.id, file };
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp =
|
const timestamp =
|
||||||
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
|
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
|
||||||
|
|
||||||
const hasGps =
|
const hasGps =
|
||||||
exif &&
|
exif &&
|
||||||
typeof exif.latitude === 'number' &&
|
typeof exif.latitude === 'number' &&
|
||||||
typeof exif.longitude === 'number';
|
typeof exif.longitude === 'number';
|
||||||
|
|
||||||
// Note: we deliberately ignore `exif.GPSAltitude` even when
|
// EXIF GPSAltitude is intentionally ignored (too noisy);
|
||||||
// present. Phone GPS altitude has metre-scale noise; we backfill
|
// terrain-model altitude from Swisstopo is backfilled later.
|
||||||
// the terrain-model altitude from Swisstopo after insertion.
|
|
||||||
const wp: Waypoint = hasGps
|
const wp: Waypoint = hasGps
|
||||||
? {
|
? {
|
||||||
id,
|
id,
|
||||||
@@ -120,6 +157,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
entries[entryIdx].status = hasGps ? 'placed' : 'unplaced';
|
entries[entryIdx].status = hasGps ? 'placed' : 'unplaced';
|
||||||
|
if (hasGps) scheduleAutoDismiss(entries[entryIdx].id);
|
||||||
return { ok: true, kind: 'new', wp, hasGps, id, file };
|
return { ok: true, kind: 'new', wp, hasGps, id, file };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
entries[entryIdx].status = 'error';
|
entries[entryIdx].status = 'error';
|
||||||
@@ -129,13 +167,6 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// One synchronous batch of waypoint insertions → one snap-to-route
|
|
||||||
// debounce cycle for the whole upload. No per-image altitude fetch:
|
|
||||||
// image waypoints inherit the elevation of the routed segment they
|
|
||||||
// sit on once the route is snapped — Swisstopo's profile.json (used
|
|
||||||
// by snap-to-route enrichment) is the only reliable elevation
|
|
||||||
// source against WGS-84 inputs, and its single-point variant kept
|
|
||||||
// returning 0 even with workaround attempts.
|
|
||||||
let placedAny = false;
|
let placedAny = false;
|
||||||
for (const p of prepared) {
|
for (const p of prepared) {
|
||||||
if (!p.ok) continue;
|
if (!p.ok) continue;
|
||||||
@@ -143,29 +174,36 @@
|
|||||||
insertWaypointChronologically(p.wp);
|
insertWaypointChronologically(p.wp);
|
||||||
if (p.hasGps) placedAny = true;
|
if (p.hasGps) placedAny = true;
|
||||||
}
|
}
|
||||||
// Cache the original file so the waypoint table can show a
|
|
||||||
// full-resolution preview this session (for both new + matched
|
|
||||||
// waypoints). Persistence to localStorage keeps only the small
|
|
||||||
// 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();
|
if (placedAny) requestFitBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeFiles(files: File[]) {
|
||||||
|
const gpx = files.find(isGpxFile);
|
||||||
|
if (gpx && onGpxImport) {
|
||||||
|
// GPX import REPLACES the draft, so we hand off the first one
|
||||||
|
// and ignore everything else in the batch — combining a GPX
|
||||||
|
// import with an image batch would race the snap-to-route
|
||||||
|
// reactor against a draft reset.
|
||||||
|
onGpxImport(gpx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const images = files.filter((f) => f.type.startsWith('image/'));
|
||||||
|
if (images.length > 0) handleFiles(images);
|
||||||
|
}
|
||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
function onDrop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
const files = [...(e.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith('image/'));
|
const files = [...(e.dataTransfer?.files ?? [])];
|
||||||
if (files.length > 0) handleFiles(files);
|
if (files.length > 0) routeFiles(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFileInput(e: Event) {
|
function onFileInput(e: Event) {
|
||||||
const input = e.currentTarget as HTMLInputElement;
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
const files = [...(input.files ?? [])];
|
const files = [...(input.files ?? [])];
|
||||||
if (files.length > 0) handleFiles(files);
|
if (files.length > 0) routeFiles(files);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,201 +211,328 @@
|
|||||||
const idx = entries.findIndex((e) => e.id === entryId);
|
const idx = entries.findIndex((e) => e.id === entryId);
|
||||||
if (idx >= 0) entries.splice(idx, 1);
|
if (idx >= 0) entries.splice(idx, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearFailed() {
|
||||||
|
entries = entries.filter((e) => e.status !== 'error' && e.status !== 'unplaced');
|
||||||
|
showFailDetails = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileInput: HTMLInputElement | undefined = $state();
|
||||||
|
function openPicker() {
|
||||||
|
fileInput?.click();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<div
|
||||||
class="dropzone"
|
class="bulk-fab-wrap"
|
||||||
class:active={isDragging}
|
class:dragging={isDragging}
|
||||||
aria-label="Bild-Drop"
|
role="region"
|
||||||
|
aria-label="Bilder-Upload"
|
||||||
ondragenter={(e) => {
|
ondragenter={(e) => {
|
||||||
|
const types = e.dataTransfer?.types;
|
||||||
|
if (types && Array.from(types).includes('Files')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
|
const types = e.dataTransfer?.types;
|
||||||
|
if (types && Array.from(types).includes('Files')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
ondragleave={() => {
|
ondragleave={(e) => {
|
||||||
isDragging = false;
|
if (e.currentTarget === e.target) isDragging = false;
|
||||||
}}
|
}}
|
||||||
ondrop={onDrop}
|
ondrop={onDrop}
|
||||||
>
|
>
|
||||||
<header>
|
<input
|
||||||
<h2>Bilder</h2>
|
bind:this={fileInput}
|
||||||
<p class="hint">
|
type="file"
|
||||||
Bilder mit GPS-EXIF werden chronologisch platziert. Bilder ohne GPS
|
accept="image/*,.gpx,application/gpx+xml,application/xml,text/xml"
|
||||||
erscheinen in der Wegpunkt-Liste und können dort auf der Karte platziert
|
multiple
|
||||||
werden. Die Bilder verlassen dein Gerät nicht.
|
onchange={onFileInput}
|
||||||
</p>
|
hidden
|
||||||
{#if orphanImageCount > 0}
|
/>
|
||||||
<p class="hint import-hint">
|
<button
|
||||||
<strong>{orphanImageCount}</strong>
|
type="button"
|
||||||
{orphanImageCount === 1 ? 'Bild-Wegpunkt' : 'Bild-Wegpunkte'} aus der
|
class="bulk-fab action_button"
|
||||||
geladenen GPX warten auf eine Vorschau — die Original-Bilder hier ablegen,
|
aria-label="Bilder oder GPX hinzufügen"
|
||||||
um sie über den Inhalts-Hash automatisch zuzuordnen.
|
title="Bilder oder GPX hinzufügen"
|
||||||
</p>
|
onclick={openPicker}
|
||||||
|
>
|
||||||
|
{#if pendingCount > 0}
|
||||||
|
<LoaderCircle size={30} strokeWidth={2.2} color="white" class="bulk-fab-icon spin" />
|
||||||
|
{:else}
|
||||||
|
<ImagePlus size={30} strokeWidth={2.2} color="white" class="bulk-fab-icon" />
|
||||||
{/if}
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if failCount > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bulk-fab-badge tone-fail"
|
||||||
|
onclick={() => (showFailDetails = !showFailDetails)}
|
||||||
|
aria-label="{failCount} {failCount === 1 ? 'Hinweis' : 'Hinweise'} anzeigen"
|
||||||
|
aria-expanded={showFailDetails}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</button>
|
||||||
|
{:else if badge > 0}
|
||||||
|
<span class="bulk-fab-badge tone-{badgeTone}" aria-label="{badge} aktiv">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showFailDetails && failedEntries.length > 0}
|
||||||
|
<aside class="bulk-fail-popover" aria-label="Bild-Hinweise">
|
||||||
|
<header>
|
||||||
|
<strong>Bild-Hinweise</strong>
|
||||||
|
<button type="button" class="link" onclick={clearFailed}>Alle ausblenden</button>
|
||||||
</header>
|
</header>
|
||||||
|
<ul>
|
||||||
<label class="file-input">
|
{#each failedEntries as e (e.id)}
|
||||||
<input type="file" accept="image/*" multiple onchange={onFileInput} />
|
<li class="bulk-fail status-{e.status}">
|
||||||
<span>Bilder auswählen oder hierher ziehen</span>
|
<span class="status-icon" aria-hidden="true">
|
||||||
</label>
|
<AlertTriangle size={12} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
{#if entries.length > 0}
|
|
||||||
<ul class="list">
|
|
||||||
{#each entries as e (e.id)}
|
|
||||||
<li class="entry status-{e.status}">
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="name">{e.name}</span>
|
<span class="name">{e.name}</span>
|
||||||
<span class="msg">
|
<span class="msg">
|
||||||
{#if e.status === 'pending'}wird gelesen…
|
{#if e.status === 'unplaced'}Position fehlt — Eintrag in der Wegpunktliste auf Karte platzieren.
|
||||||
{:else if e.status === 'placed'}✓ chronologisch platziert
|
{:else}Fehler: {e.message ?? 'unbekannt'}
|
||||||
{:else if e.status === 'matched'}✓ Bildvorschau ergänzt{e.message ? ` (${e.message})` : ''}
|
|
||||||
{:else if e.status === 'unplaced'}⚠ Position fehlt — in Liste platzieren
|
|
||||||
{:else if e.status === 'error'}Fehler: {e.message ?? 'unbekannt'}
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" class="dismiss" aria-label="Schließen" onclick={() => dismiss(e.id)}>×</button>
|
<button type="button" class="dismiss" aria-label="Schließen" onclick={() => dismiss(e.id)}>
|
||||||
|
<X size={13} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
</aside>
|
||||||
</section>
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.dropzone {
|
/* Wrapper holds the FAB + badge in a single positioning context so the
|
||||||
margin-top: 1rem;
|
* drag-target (full wrapper bounds) is larger than the button itself —
|
||||||
padding: 1rem;
|
* helps users dropping a stack of images. */
|
||||||
background: var(--color-surface);
|
.bulk-fab-wrap {
|
||||||
border: 2px dashed var(--color-border);
|
position: fixed;
|
||||||
border-radius: var(--radius-lg);
|
bottom: 2rem;
|
||||||
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
right: 2rem;
|
||||||
|
width: 3.75rem;
|
||||||
|
height: 3.75rem;
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropzone.active {
|
.bulk-fab-wrap.dragging {
|
||||||
border-color: var(--color-primary);
|
transform: scale(1.08);
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
/* FAB — mirrors the recipes-style ActionButton (same shake + shadow
|
||||||
margin: 0;
|
* via the shared action_button.css). */
|
||||||
font-size: 1rem;
|
.bulk-fab {
|
||||||
color: var(--color-text-primary);
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background-color: var(--red);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-fab :global(.bulk-fab-icon) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-fab :global(.bulk-fab-icon.spin) {
|
||||||
|
animation: bulk-fab-spin 0.85s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bulk-fab-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.bulk-fab-wrap.dragging .bulk-fab {
|
||||||
margin: 0.25rem 0 0.75rem;
|
background-color: var(--nord0);
|
||||||
font-size: 0.8rem;
|
box-shadow: 0 0 0 5px color-mix(in oklab, var(--red) 35%, transparent),
|
||||||
color: var(--color-text-tertiary);
|
0 0 1.6em 0.4em rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-hint {
|
@media (max-width: 500px) {
|
||||||
margin: 0 0 0.75rem;
|
.bulk-fab-wrap {
|
||||||
padding: 0.5rem 0.75rem;
|
bottom: 1rem;
|
||||||
background: color-mix(in oklab, var(--blue) 12%, var(--color-surface));
|
right: 1rem;
|
||||||
border-left: 3px solid var(--blue);
|
width: 3.25rem;
|
||||||
border-radius: var(--radius-sm);
|
height: 3.25rem;
|
||||||
color: var(--color-text-secondary);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-hint strong {
|
/* Numeric badge — pinned top-right of the FAB. Pending = primary blue,
|
||||||
color: var(--blue);
|
* fail = orange, info (orphan hashes) = nord blue. */
|
||||||
|
.bulk-fab-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.25rem;
|
||||||
|
right: -0.25rem;
|
||||||
|
min-width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
appearance: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.bulk-fab-badge {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-input {
|
.bulk-fab-badge.tone-fail {
|
||||||
display: block;
|
background: var(--orange);
|
||||||
text-align: center;
|
}
|
||||||
padding: 0.65rem 1rem;
|
|
||||||
background: var(--color-bg-tertiary);
|
.bulk-fab-badge.tone-info {
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Failure popover anchored above the FAB. Only opens when the user
|
||||||
|
* clicks the fail-tinted badge, so the FAB itself stays minimal. */
|
||||||
|
.bulk-fail-popover {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 6.5rem;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 101;
|
||||||
|
max-width: min(360px, calc(100vw - 3rem));
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 3px solid var(--orange);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
box-shadow: var(--shadow-lg);
|
||||||
font-size: 0.9rem;
|
padding: 0.6rem 0.7rem;
|
||||||
color: var(--color-text-secondary);
|
animation: bulk-fail-in 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bulk-fail-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-input input {
|
.bulk-fail-popover header {
|
||||||
display: none;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-input:hover {
|
.bulk-fail-popover .link {
|
||||||
background: var(--color-bg-elevated);
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.bulk-fail-popover ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0.75rem 0 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry {
|
.bulk-fail {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
gap: 0.6rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.4rem;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.bulk-fail .status-icon {
|
||||||
width: 0.55rem;
|
display: inline-flex;
|
||||||
height: 0.55rem;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-text-tertiary);
|
background: var(--orange);
|
||||||
|
color: white;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-placed .dot { background: var(--green); }
|
.bulk-fail.status-error .status-icon {
|
||||||
.status-matched .dot { background: var(--blue); }
|
background: var(--red);
|
||||||
.status-unplaced .dot { background: var(--orange); }
|
|
||||||
.status-error .dot { background: var(--red); }
|
|
||||||
.status-pending .dot {
|
|
||||||
background: var(--color-primary);
|
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
.bulk-fail .name {
|
||||||
0%, 100% { opacity: 0.4; }
|
grid-column: 2;
|
||||||
50% { opacity: 1; }
|
grid-row: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg {
|
.bulk-fail .msg {
|
||||||
text-align: right;
|
grid-column: 2;
|
||||||
font-size: 0.75rem;
|
grid-row: 2;
|
||||||
|
font-size: 0.72rem;
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
white-space: nowrap;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-error .msg { color: var(--red); }
|
.bulk-fail.status-error .msg {
|
||||||
.status-unplaced .msg { color: var(--orange); }
|
color: var(--red);
|
||||||
.status-placed .msg { color: var(--green); }
|
}
|
||||||
.status-matched .msg { color: var(--blue); }
|
|
||||||
|
|
||||||
.dismiss {
|
.bulk-fail .dismiss {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1 / span 2;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
font-size: 1.1rem;
|
padding: 0.2rem;
|
||||||
line-height: 1;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0 0.2rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dismiss:hover {
|
.bulk-fail .dismiss:hover {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { builder } from './builderStore.svelte';
|
||||||
|
import { haversineKm } from '$lib/gpx';
|
||||||
|
import { computeElevationStats, computeElevationRange } from '$lib/hikes/elevation';
|
||||||
|
import Route from '@lucide/svelte/icons/route';
|
||||||
|
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||||
|
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||||
|
import ArrowUpToLine from '@lucide/svelte/icons/arrow-up-to-line';
|
||||||
|
import ArrowDownToLine from '@lucide/svelte/icons/arrow-down-to-line';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** True while the snap-to-route / elevation-enrichment pipeline is still
|
||||||
|
* resolving. Stats are computed from whatever's already in the store so
|
||||||
|
* the user sees an evolving preview; the flag drives a subtle pulse so
|
||||||
|
* they know the numbers may still tick up. */
|
||||||
|
busy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { busy = false }: Props = $props();
|
||||||
|
|
||||||
|
type Pt = { lat: number; lng: number; altitude?: number };
|
||||||
|
|
||||||
|
// Flatten routedSegments → trkpt-shaped array. We dedupe the seam between
|
||||||
|
// adjacent segments (each segment repeats its end as the next segment's
|
||||||
|
// start) so distance + elevation don't double-count those vertices.
|
||||||
|
const flatTrack = $derived.by<Pt[]>(() => {
|
||||||
|
const out: Pt[] = [];
|
||||||
|
let prev: Pt | null = null;
|
||||||
|
for (const seg of builder.routedSegments) {
|
||||||
|
for (const p of seg) {
|
||||||
|
const point: Pt = {
|
||||||
|
lng: p[0],
|
||||||
|
lat: p[1],
|
||||||
|
altitude: typeof p[2] === 'number' ? p[2] : undefined
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
prev.lat === point.lat &&
|
||||||
|
prev.lng === point.lng &&
|
||||||
|
prev.altitude === point.altitude
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(point);
|
||||||
|
prev = point;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const distanceKm = $derived.by(() => {
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 1; i < flatTrack.length; i++) {
|
||||||
|
total += haversineKm(
|
||||||
|
{ ...flatTrack[i - 1], timestamp: 0 },
|
||||||
|
{ ...flatTrack[i], timestamp: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
});
|
||||||
|
|
||||||
|
const elevStats = $derived(computeElevationStats(flatTrack));
|
||||||
|
const elevRange = $derived(computeElevationRange(flatTrack));
|
||||||
|
const hasTrack = $derived(flatTrack.length >= 2);
|
||||||
|
|
||||||
|
function fmtNum(n: number | null | undefined, suffix = ''): string {
|
||||||
|
if (n === null || n === undefined) return '–';
|
||||||
|
return `${n}${suffix}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="stats-bar" class:busy class:idle={!hasTrack} aria-label="Routendaten">
|
||||||
|
<div class="metric">
|
||||||
|
<Route size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">
|
||||||
|
{hasTrack ? distanceKm.toFixed(1) : '–'}<span class="value-unit">km</span>
|
||||||
|
</span>
|
||||||
|
<span class="unit">Distanz</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">
|
||||||
|
{hasTrack ? fmtNum(elevStats.gain) : '–'}<span class="value-unit">m</span>
|
||||||
|
</span>
|
||||||
|
<span class="unit">Aufstieg</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">
|
||||||
|
{hasTrack ? fmtNum(elevStats.loss) : '–'}<span class="value-unit">m</span>
|
||||||
|
</span>
|
||||||
|
<span class="unit">Abstieg</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">
|
||||||
|
{hasTrack ? fmtNum(elevRange.max) : '–'}<span class="value-unit">m</span>
|
||||||
|
</span>
|
||||||
|
<span class="unit">höchster</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">
|
||||||
|
{hasTrack ? fmtNum(elevRange.min) : '–'}<span class="value-unit">m</span>
|
||||||
|
</span>
|
||||||
|
<span class="unit">tiefster</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem 2rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar.idle {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar.busy {
|
||||||
|
animation: stats-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stats-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.65; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.stats-bar.busy {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
column-gap: 0.55rem;
|
||||||
|
row-gap: 0.05rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric :global(svg) {
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar.idle .metric :global(svg) {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar.idle .value {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-unit {
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-left: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
builder,
|
||||||
|
focusWaypoint,
|
||||||
|
mapView,
|
||||||
|
placedSequence,
|
||||||
|
scheduleSave
|
||||||
|
} from './builderStore.svelte';
|
||||||
|
import { generateImageHashClient } from '$lib/imageHashClient';
|
||||||
|
import { readThumbnail } from './imageThumbnail';
|
||||||
|
import { dropFullImage, getFullImageUrl, setFullImage } from './fullImageCache.svelte';
|
||||||
|
import DateTimePicker from '$lib/components/DateTimePicker.svelte';
|
||||||
|
import MapPinned from '@lucide/svelte/icons/map-pinned';
|
||||||
|
import ImagePlus from '@lucide/svelte/icons/image-plus';
|
||||||
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
|
import Globe from '@lucide/svelte/icons/globe';
|
||||||
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onCancelPlacement?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onCancelPlacement }: Props = $props();
|
||||||
|
|
||||||
|
const NUDGE_MINUTES = [-10, -5, 5, 10];
|
||||||
|
|
||||||
|
// Drive everything off the focus signal. The full waypoint array index
|
||||||
|
// (`idx`) is used for in-place mutation; `wp` is a reactive reference into
|
||||||
|
// the same store entry so writes propagate via Svelte 5 deep reactivity.
|
||||||
|
const wpIdx = $derived(
|
||||||
|
mapView.focusId ? builder.waypoints.findIndex((w) => w.id === mapView.focusId) : -1
|
||||||
|
);
|
||||||
|
const wp = $derived(wpIdx === -1 ? null : builder.waypoints[wpIdx]);
|
||||||
|
const seq = $derived(wp ? placedSequence(wp.id) : null);
|
||||||
|
|
||||||
|
const placed = $derived(builder.waypoints.filter((w) => !w.unplaced));
|
||||||
|
|
||||||
|
const firstPlacedIdx = $derived(builder.waypoints.findIndex((w) => !w.unplaced));
|
||||||
|
const lastPlacedIdx = $derived.by(() => {
|
||||||
|
for (let i = builder.waypoints.length - 1; i >= 0; i--) {
|
||||||
|
if (!builder.waypoints[i].unplaced) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
});
|
||||||
|
const requiresTime = $derived(wpIdx !== -1 && (wpIdx === firstPlacedIdx || wpIdx === lastPlacedIdx));
|
||||||
|
|
||||||
|
function nearestTimestamp(idx: number): number | undefined {
|
||||||
|
const wps = builder.waypoints;
|
||||||
|
for (let dist = 1; dist < wps.length; dist++) {
|
||||||
|
const a = wps[idx - dist];
|
||||||
|
if (a && typeof a.timestamp === 'number') return a.timestamp;
|
||||||
|
const b = wps[idx + dist];
|
||||||
|
if (b && typeof b.timestamp === 'number') return b.timestamp;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inheritedTs = $derived.by(() => {
|
||||||
|
if (!wp || wp.timestamp != null) return null;
|
||||||
|
return nearestTimestamp(wpIdx) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateLat(raw: string) {
|
||||||
|
if (!wp) return;
|
||||||
|
const n = parseFloat(raw);
|
||||||
|
if (!isNaN(n)) {
|
||||||
|
wp.lat = n;
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLng(raw: string) {
|
||||||
|
if (!wp) return;
|
||||||
|
const n = parseFloat(raw);
|
||||||
|
if (!isNaN(n)) {
|
||||||
|
wp.lng = n;
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisibility(value: 'public' | 'private') {
|
||||||
|
if (!wp) return;
|
||||||
|
wp.imageVisibility = value;
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWaypoint() {
|
||||||
|
if (!wp || wpIdx === -1) return;
|
||||||
|
const id = wp.id;
|
||||||
|
dropFullImage(id);
|
||||||
|
builder.waypoints.splice(wpIdx, 1);
|
||||||
|
scheduleSave();
|
||||||
|
// Move focus to the next remaining placed waypoint, or clear it.
|
||||||
|
const next = placed.find((w) => w.id !== id);
|
||||||
|
focusWaypoint(next?.id ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
focusWaypoint(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachBusy = $state(false);
|
||||||
|
let dragActive = $state(false);
|
||||||
|
|
||||||
|
async function attachImage(fileList: FileList | null) {
|
||||||
|
const file = fileList?.[0];
|
||||||
|
if (!file || !wp) return;
|
||||||
|
attachBusy = true;
|
||||||
|
try {
|
||||||
|
const exifr = (await import('exifr')).default;
|
||||||
|
const exif = await exifr.parse(file, { gps: true, exif: true }).catch(() => null);
|
||||||
|
const hash = await generateImageHashClient(file);
|
||||||
|
let thumbnail: string | undefined;
|
||||||
|
try {
|
||||||
|
thumbnail = await readThumbnail(file);
|
||||||
|
} catch { /* thumbnail is optional */ }
|
||||||
|
wp.imageHash = hash;
|
||||||
|
wp.thumbnail = thumbnail;
|
||||||
|
wp.imageVisibility = 'public';
|
||||||
|
setFullImage(wp.id, file);
|
||||||
|
if (wp.timestamp == null && exif?.DateTimeOriginal instanceof Date) {
|
||||||
|
wp.timestamp = exif.DateTimeOriginal.getTime();
|
||||||
|
}
|
||||||
|
scheduleSave();
|
||||||
|
} finally {
|
||||||
|
attachBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHeroDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragActive = false;
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const imgs = [...files].filter((f) => f.type.startsWith('image/'));
|
||||||
|
if (imgs.length === 0) return;
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(imgs[0]);
|
||||||
|
attachImage(dt.files);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="detail-panel" aria-label="Wegpunkt-Details">
|
||||||
|
{#if !wp}
|
||||||
|
<div class="empty">
|
||||||
|
<MapPinned size={32} strokeWidth={1.5} />
|
||||||
|
<p class="empty-title">Kein Wegpunkt ausgewählt</p>
|
||||||
|
<p class="empty-sub">
|
||||||
|
Klicke einen Pin auf der Karte oder einen Eintrag in der Liste an, um ihn
|
||||||
|
hier zu bearbeiten. Mit ← / → kannst du die Route Wegpunkt für Wegpunkt
|
||||||
|
durchgehen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<header class="panel-head">
|
||||||
|
<span class="seq" class:unplaced={wp.unplaced}>{seq ?? '?'}</span>
|
||||||
|
<h3 class="title">
|
||||||
|
{#if wp.unplaced}
|
||||||
|
Bild ohne Position
|
||||||
|
{:else if wp.imageHash}
|
||||||
|
Bild {seq}
|
||||||
|
{:else}
|
||||||
|
Wegpunkt {seq}
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close"
|
||||||
|
onclick={closePanel}
|
||||||
|
aria-label="Panel schließen"
|
||||||
|
title="Schließen"
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="hero" class:empty={!wp.thumbnail && !getFullImageUrl(wp.id)} class:busy={attachBusy}>
|
||||||
|
{#if wp.thumbnail || getFullImageUrl(wp.id)}
|
||||||
|
<img src={getFullImageUrl(wp.id) ?? wp.thumbnail} alt="" />
|
||||||
|
{:else}
|
||||||
|
<!-- Same 4:3 box as the thumbnail variant so the rest of the panel
|
||||||
|
stays put when an image gets attached. The label fills the box,
|
||||||
|
acts as both click target and drop target. -->
|
||||||
|
<label
|
||||||
|
class="hero-upload"
|
||||||
|
class:drag={dragActive}
|
||||||
|
ondragenter={(e) => { e.preventDefault(); dragActive = true; }}
|
||||||
|
ondragover={(e) => { e.preventDefault(); }}
|
||||||
|
ondragleave={() => { dragActive = false; }}
|
||||||
|
ondrop={onHeroDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
disabled={attachBusy}
|
||||||
|
onchange={(e) => attachImage(e.currentTarget.files)}
|
||||||
|
/>
|
||||||
|
<span class="hero-upload-inner">
|
||||||
|
{#if attachBusy}
|
||||||
|
<LoaderCircle size={28} strokeWidth={1.75} class="spin" />
|
||||||
|
<span class="hero-upload-title">Bild wird gelesen…</span>
|
||||||
|
{:else}
|
||||||
|
<ImagePlus size={28} strokeWidth={1.75} />
|
||||||
|
<span class="hero-upload-title">Bild anhängen</span>
|
||||||
|
<span class="hero-upload-sub">
|
||||||
|
Klicken oder hierher ziehen
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if wp.imageHash}
|
||||||
|
<div class="vis-block" class:is-private={wp.imageVisibility === 'private'}>
|
||||||
|
<div class="vis-head">
|
||||||
|
<span class="label">Sichtbarkeit auf der Website</span>
|
||||||
|
<span class="vis-state">
|
||||||
|
{wp.imageVisibility === 'private'
|
||||||
|
? 'Nur du siehst dieses Bild im veröffentlichten GPX.'
|
||||||
|
: 'Dieses Bild wird öffentlich auf der Wandereintragsseite angezeigt.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="vis-segment" role="radiogroup" aria-label="Sichtbarkeit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vis-opt"
|
||||||
|
class:active={wp.imageVisibility !== 'private'}
|
||||||
|
aria-pressed={wp.imageVisibility !== 'private'}
|
||||||
|
onclick={() => setVisibility('public')}
|
||||||
|
>
|
||||||
|
<Globe size={18} strokeWidth={2} />
|
||||||
|
<span>Öffentlich</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vis-opt"
|
||||||
|
class:active={wp.imageVisibility === 'private'}
|
||||||
|
aria-pressed={wp.imageVisibility === 'private'}
|
||||||
|
onclick={() => setVisibility('private')}
|
||||||
|
>
|
||||||
|
<Lock size={18} strokeWidth={2} />
|
||||||
|
<span>Privat</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !wp.unplaced}
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">
|
||||||
|
{requiresTime ? 'Zeit (Pflicht)' : 'Zeit'}
|
||||||
|
</span>
|
||||||
|
<DateTimePicker
|
||||||
|
bind:value={builder.waypoints[wpIdx].timestamp}
|
||||||
|
mode={wp.imageHash || requiresTime || wp.timestamp != null ? 'datetime' : 'date'}
|
||||||
|
inheritedValue={inheritedTs}
|
||||||
|
nudgeMinutes={NUDGE_MINUTES}
|
||||||
|
required={requiresTime}
|
||||||
|
lang="de"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="placement-hint">
|
||||||
|
Diese Position fehlt noch. Wähle den Eintrag in der Wegpunktliste unten und
|
||||||
|
klicke „Auf Karte platzieren“ oder ziehe ein Bild mit GPS-EXIF in den
|
||||||
|
Bildbereich.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="ghost" onclick={() => onCancelPlacement?.()}>
|
||||||
|
Platzierung abbrechen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !wp.unplaced}
|
||||||
|
<details class="coords-details">
|
||||||
|
<summary>Koordinaten anpassen</summary>
|
||||||
|
<div class="coords-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="dp-lat">Breitengrad</label>
|
||||||
|
<input
|
||||||
|
id="dp-lat"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
value={wp.lat}
|
||||||
|
onchange={(e) => updateLat(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="dp-lng">Längengrad</label>
|
||||||
|
<input
|
||||||
|
id="dp-lng"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
value={wp.lng}
|
||||||
|
onchange={(e) => updateLng(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="button" class="danger" onclick={removeWaypoint}>
|
||||||
|
Wegpunkt entfernen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.detail-panel {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-width: 0;
|
||||||
|
/* Match the map's height so the column visually anchors next to it.
|
||||||
|
* The intrinsic content scrolls within so the panel itself stays the
|
||||||
|
* same shape regardless of waypoint state. */
|
||||||
|
max-height: 640px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.detail-panel {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 1.5rem 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seq {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 0.45em;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seq.unplaced {
|
||||||
|
background: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty-state variant occupies the SAME 4:3 box as the thumbnail
|
||||||
|
* variant — same width, same aspect-ratio, same border-radius — so
|
||||||
|
* dropping/attaching an image swaps the inner content without shifting
|
||||||
|
* any other panel section. */
|
||||||
|
.hero.empty {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(in oklab, var(--color-primary) 6%, transparent),
|
||||||
|
transparent 70%
|
||||||
|
),
|
||||||
|
var(--color-bg-tertiary);
|
||||||
|
border: 1.5px dashed color-mix(in oklab, var(--color-primary) 32%, var(--color-border));
|
||||||
|
transition: border-color var(--transition-fast), background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero.empty:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero.busy .hero-upload {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload input[type='file'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload.drag {
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload-inner {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload-inner :global(svg) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload-sub {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-upload :global(.spin) {
|
||||||
|
animation: panel-spin 0.85s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type='number'] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visibility is the highest-stakes setting in the panel — privacy choice
|
||||||
|
* for the published GPX. Treat it as a primary action: card-like block
|
||||||
|
* with a short rationale + a wide two-segment toggle, tinted green for
|
||||||
|
* public and amber for private so the current state reads at a glance. */
|
||||||
|
.vis-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.75rem 0.85rem 0.85rem;
|
||||||
|
background: color-mix(in oklab, var(--green) 8%, var(--color-bg-secondary));
|
||||||
|
border: 1px solid color-mix(in oklab, var(--green) 30%, var(--color-border));
|
||||||
|
border-left: 3px solid var(--green);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-block.is-private {
|
||||||
|
background: color-mix(in oklab, var(--orange) 8%, var(--color-bg-secondary));
|
||||||
|
border-color: color-mix(in oklab, var(--orange) 35%, var(--color-border));
|
||||||
|
border-left-color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-state {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-segment {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-opt {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||||
|
color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-opt:hover:not(.active) {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-opt.active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-block:not(.is-private) .vis-opt.active {
|
||||||
|
background: var(--green);
|
||||||
|
border-color: var(--green);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--green) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-block.is-private .vis-opt.active {
|
||||||
|
background: var(--orange);
|
||||||
|
border-color: var(--orange);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--orange) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Coords are a power-user adjustment — keep them out of the way unless
|
||||||
|
* the user explicitly opens the disclosure. Dragging the marker on the
|
||||||
|
* map is the primary editing affordance. */
|
||||||
|
.coords-details {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords-details > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
list-style: revert;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords-details[open] > summary {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 0.75rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.coords-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-hint {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
background: color-mix(in oklab, var(--orange) 10%, var(--color-bg-secondary));
|
||||||
|
border-left: 3px solid var(--orange);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panel-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--red) 35%, var(--color-border));
|
||||||
|
color: var(--red);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger:hover {
|
||||||
|
background: var(--red);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import { builder, scheduleSave } from './builderStore.svelte';
|
import {
|
||||||
|
builder,
|
||||||
|
focusWaypoint,
|
||||||
|
mapView,
|
||||||
|
placedSequence,
|
||||||
|
scheduleSave
|
||||||
|
} from './builderStore.svelte';
|
||||||
import { generateImageHashClient } from '$lib/imageHashClient';
|
import { generateImageHashClient } from '$lib/imageHashClient';
|
||||||
import { readThumbnail } from './imageThumbnail';
|
import { readThumbnail } from './imageThumbnail';
|
||||||
import { dropFullImage, getFullImageUrl, setFullImage } from './fullImageCache.svelte';
|
import { dropFullImage, getFullImageUrl, setFullImage } from './fullImageCache.svelte';
|
||||||
import DateTimePicker from '$lib/components/DateTimePicker.svelte';
|
import DateTimePicker from '$lib/components/DateTimePicker.svelte';
|
||||||
import ImagePlus from '@lucide/svelte/icons/image-plus';
|
import ImagePlus from '@lucide/svelte/icons/image-plus';
|
||||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
|
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
||||||
|
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import Crosshair from '@lucide/svelte/icons/crosshair';
|
||||||
|
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||||
|
import MapPinOff from '@lucide/svelte/icons/map-pin-off';
|
||||||
|
import Globe from '@lucide/svelte/icons/globe';
|
||||||
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
|
|
||||||
const NUDGE_MINUTES = [-10, -5, 5, 10];
|
const NUDGE_MINUTES = [-10, -5, 5, 10];
|
||||||
|
|
||||||
@@ -137,10 +151,12 @@
|
|||||||
<p class="legend">* Erster und letzter platzierter Wegpunkt brauchen einen Zeitstempel.</p>
|
<p class="legend">* Erster und letzter platzierter Wegpunkt brauchen einen Zeitstempel.</p>
|
||||||
<ol>
|
<ol>
|
||||||
{#each builder.waypoints as wp, idx (wp.id)}
|
{#each builder.waypoints as wp, idx (wp.id)}
|
||||||
|
{@const seq = placedSequence(wp.id)}
|
||||||
<li
|
<li
|
||||||
class="wp"
|
class="wp"
|
||||||
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}
|
||||||
animate:flip={{ duration: 220 }}
|
animate:flip={{ duration: 220 }}
|
||||||
>
|
>
|
||||||
{#if wp.thumbnail || getFullImageUrl(wp.id)}
|
{#if wp.thumbnail || getFullImageUrl(wp.id)}
|
||||||
@@ -151,28 +167,63 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{#if wp.unplaced}
|
{#if wp.unplaced}
|
||||||
<span class="hero-badge">📍 noch nicht platziert</span>
|
<span class="hero-badge">
|
||||||
|
<MapPinOff size={12} strokeWidth={2} />
|
||||||
|
<span>noch nicht platziert</span>
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="row title-row">
|
<div class="row title-row">
|
||||||
<span class="idx" class:unplaced-idx={wp.unplaced}>
|
<span class="idx" class:unplaced-idx={wp.unplaced}>
|
||||||
{wp.unplaced ? '?' : idx + 1}
|
{seq ?? '?'}
|
||||||
</span>
|
</span>
|
||||||
<span class="title">
|
<span class="title">
|
||||||
{#if wp.unplaced}
|
{#if wp.unplaced}
|
||||||
Bild ohne Position
|
Bild ohne Position
|
||||||
{:else if wp.imageHash}
|
{:else if wp.imageHash}
|
||||||
Bild {idx + 1}
|
Bild {seq}
|
||||||
{:else}
|
{:else}
|
||||||
Wegpunkt {idx + 1}
|
Wegpunkt {seq}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button type="button" onclick={() => move(idx, -1)} disabled={idx === 0} aria-label="Nach oben">↑</button>
|
{#if !wp.unplaced}
|
||||||
<button type="button" onclick={() => move(idx, 1)} disabled={idx === builder.waypoints.length - 1} aria-label="Nach unten">↓</button>
|
<button
|
||||||
<button type="button" class="del" onclick={() => remove(idx)} aria-label="Entfernen">✕</button>
|
type="button"
|
||||||
|
class="focus-btn"
|
||||||
|
onclick={() => focusWaypoint(wp.id)}
|
||||||
|
aria-label="Auf Karte fokussieren"
|
||||||
|
title="Auf Karte fokussieren"
|
||||||
|
>
|
||||||
|
<Crosshair size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => move(idx, -1)}
|
||||||
|
disabled={idx === 0}
|
||||||
|
aria-label="Nach oben"
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => move(idx, 1)}
|
||||||
|
disabled={idx === builder.waypoints.length - 1}
|
||||||
|
aria-label="Nach unten"
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="del"
|
||||||
|
onclick={() => remove(idx)}
|
||||||
|
aria-label="Entfernen"
|
||||||
|
>
|
||||||
|
<X size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -183,7 +234,8 @@
|
|||||||
<button type="button" class="ghost" onclick={() => onCancelPlacement?.()}>Abbrechen</button>
|
<button type="button" class="ghost" onclick={() => onCancelPlacement?.()}>Abbrechen</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button type="button" class="primary" onclick={() => onRequestPlacement?.(wp.id)}>
|
<button type="button" class="primary" onclick={() => onRequestPlacement?.(wp.id)}>
|
||||||
📍 Auf Karte platzieren
|
<MapPin size={14} strokeWidth={2} />
|
||||||
|
<span>Auf Karte platzieren</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -236,13 +288,19 @@
|
|||||||
class:active={wp.imageVisibility !== 'private'}
|
class:active={wp.imageVisibility !== 'private'}
|
||||||
aria-pressed={wp.imageVisibility !== 'private'}
|
aria-pressed={wp.imageVisibility !== 'private'}
|
||||||
onclick={() => setVisibility(idx, 'public')}
|
onclick={() => setVisibility(idx, 'public')}
|
||||||
>🌐 Öffentlich</button>
|
>
|
||||||
|
<Globe size={12} strokeWidth={2} />
|
||||||
|
<span>Öffentlich</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={wp.imageVisibility === 'private'}
|
class:active={wp.imageVisibility === 'private'}
|
||||||
aria-pressed={wp.imageVisibility === 'private'}
|
aria-pressed={wp.imageVisibility === 'private'}
|
||||||
onclick={() => setVisibility(idx, 'private')}
|
onclick={() => setVisibility(idx, 'private')}
|
||||||
>🔒 Privat</button>
|
>
|
||||||
|
<Lock size={12} strokeWidth={2} />
|
||||||
|
<span>Privat</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if !wp.unplaced}
|
{:else if !wp.unplaced}
|
||||||
@@ -278,8 +336,6 @@
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
max-height: 600px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header h2 {
|
header h2 {
|
||||||
@@ -304,9 +360,9 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 0.6rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wp {
|
.wp {
|
||||||
@@ -318,6 +374,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
scroll-margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wp.unplaced {
|
.wp.unplaced {
|
||||||
@@ -330,6 +387,12 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wp.focused {
|
||||||
|
border-color: var(--blue);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--blue) 30%, transparent),
|
||||||
|
var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -349,7 +412,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.4rem;
|
top: 0.4rem;
|
||||||
left: 0.4rem;
|
left: 0.4rem;
|
||||||
padding: 0.15rem 0.5rem;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
background: var(--orange);
|
background: var(--orange);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -461,11 +527,13 @@
|
|||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.25rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
line-height: 0;
|
||||||
line-height: 1;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-actions button:disabled {
|
.row-actions button:disabled {
|
||||||
@@ -477,6 +545,18 @@
|
|||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-actions button.focus-btn {
|
||||||
|
color: var(--blue);
|
||||||
|
border-color: color-mix(in oklab, var(--blue) 35%, var(--color-border));
|
||||||
|
background: color-mix(in oklab, var(--blue) 8%, var(--color-bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button.focus-btn:hover {
|
||||||
|
background: var(--blue);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
.placement-row .primary,
|
.placement-row .primary,
|
||||||
.placement-row .ghost {
|
.placement-row .ghost {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -485,6 +565,9 @@
|
|||||||
padding: 0.3rem 0.8rem;
|
padding: 0.3rem 0.8rem;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placement-row .primary {
|
.placement-row .primary {
|
||||||
@@ -528,8 +611,11 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
padding: 0.2rem 0.6rem;
|
padding: 0.25rem 0.65rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment button + button {
|
.segment button + button {
|
||||||
|
|||||||
@@ -108,16 +108,48 @@ 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
|
* UI-only signals shared between the edit map and the side panels.
|
||||||
* re-run `fitBounds()` on the current track. Used after batch insertions
|
*
|
||||||
* (image drops, GPX import) where the user expects the map to reframe to
|
* - `fitTick`: bump to re-run `fitBounds()` on the current track. Used
|
||||||
* show every newly-added waypoint. Not persisted.
|
* after batch insertions (image drops, GPX import) where the user
|
||||||
|
* expects the map to reframe to show every newly-added waypoint.
|
||||||
|
* - `focusId` + `focusTick`: bump to pan/zoom the map onto a specific
|
||||||
|
* waypoint AND mark it as the "current" one (drives prev/next nav and
|
||||||
|
* the highlight on the corresponding table row + marker).
|
||||||
|
*
|
||||||
|
* Not persisted — pure session UI.
|
||||||
*/
|
*/
|
||||||
export const mapView = $state({ fitTick: 0 });
|
export const mapView = $state<{
|
||||||
|
fitTick: number;
|
||||||
|
focusId: string | null;
|
||||||
|
focusTick: number;
|
||||||
|
}>({ fitTick: 0, focusId: null, focusTick: 0 });
|
||||||
|
|
||||||
export function requestFitBounds(): void {
|
export function requestFitBounds(): void {
|
||||||
mapView.fitTick++;
|
mapView.fitTick++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function focusWaypoint(id: string | null): void {
|
||||||
|
mapView.focusId = id;
|
||||||
|
mapView.focusTick++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sequence number (1-based) of `wp` among placed waypoints only. Unplaced
|
||||||
|
* image entries return `null`. Single source of truth so the table badge,
|
||||||
|
* the map marker number, and the GPX export all agree on what "Wegpunkt 3"
|
||||||
|
* means.
|
||||||
|
*/
|
||||||
|
export function placedSequence(wpId: string): number | null {
|
||||||
|
let n = 0;
|
||||||
|
for (const w of builder.waypoints) {
|
||||||
|
if (w.unplaced) continue;
|
||||||
|
n++;
|
||||||
|
if (w.id === wpId) return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Shared elevation analytics for the hikes pipeline.
|
||||||
|
*
|
||||||
|
* The build script and the route-builder both need to derive
|
||||||
|
* gain / loss / range from a track's `<ele>` values. Keeping a single
|
||||||
|
* implementation here guarantees the live preview in the builder reports
|
||||||
|
* the same numbers the published detail page will show after a deploy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HasAltitude {
|
||||||
|
altitude?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moving-average window for altitude denoising (in trkpts). */
|
||||||
|
export const ELEV_SMOOTH_WINDOW = 5;
|
||||||
|
|
||||||
|
/** Discard altitude deltas below this many metres — eliminates GPS
|
||||||
|
* jitter from being counted as real gain/loss. */
|
||||||
|
export const ELEV_MIN_STEP_M = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the smoothed altitude per trkpt. Entries are `null` for indices
|
||||||
|
* where no defined altitude exists in the ±half window — the previous
|
||||||
|
* behaviour (defaulting to 0) silently turned missing `<ele>` tags into
|
||||||
|
* huge synthetic gain spikes against the next real altitude.
|
||||||
|
*/
|
||||||
|
export function smoothAltitudes(track: HasAltitude[]): (number | null)[] {
|
||||||
|
const n = track.length;
|
||||||
|
const out = new Array<number | null>(n);
|
||||||
|
const half = Math.floor(ELEV_SMOOTH_WINDOW / 2);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
|
||||||
|
const a = track[j].altitude;
|
||||||
|
if (typeof a === 'number') {
|
||||||
|
sum += a;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[i] = count > 0 ? sum / count : null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeElevationStats(track: HasAltitude[]): { gain: number; loss: number } {
|
||||||
|
if (track.length < 2) return { gain: 0, loss: 0 };
|
||||||
|
const altitudes = smoothAltitudes(track);
|
||||||
|
let gain = 0;
|
||||||
|
let loss = 0;
|
||||||
|
let prev: number | null = null;
|
||||||
|
for (const a of altitudes) {
|
||||||
|
if (a === null) continue;
|
||||||
|
if (prev === null) {
|
||||||
|
prev = a;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const diff = a - prev;
|
||||||
|
if (diff >= ELEV_MIN_STEP_M) {
|
||||||
|
gain += diff;
|
||||||
|
prev = a;
|
||||||
|
} else if (diff <= -ELEV_MIN_STEP_M) {
|
||||||
|
loss += -diff;
|
||||||
|
prev = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { gain: Math.round(gain), loss: Math.round(loss) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeElevationRange(track: HasAltitude[]): {
|
||||||
|
min: number | null;
|
||||||
|
max: number | null;
|
||||||
|
} {
|
||||||
|
let min = Infinity;
|
||||||
|
let max = -Infinity;
|
||||||
|
for (const p of track) {
|
||||||
|
if (typeof p.altitude !== 'number') continue;
|
||||||
|
if (p.altitude < min) min = p.altitude;
|
||||||
|
if (p.altitude > max) max = p.altitude;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return { min: null, max: null };
|
||||||
|
return { min: Math.round(min), max: Math.round(max) };
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@
|
|||||||
import Seo from '$lib/components/Seo.svelte';
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
import EditMap from '$lib/components/hikes/route-builder/EditMap.svelte';
|
import EditMap from '$lib/components/hikes/route-builder/EditMap.svelte';
|
||||||
import WaypointTable from '$lib/components/hikes/route-builder/WaypointTable.svelte';
|
import WaypointTable from '$lib/components/hikes/route-builder/WaypointTable.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 { assembleTrackPoints, buildGpx, type GpxImageWaypoint } from '$lib/gpx';
|
import { assembleTrackPoints, buildGpx, type GpxImageWaypoint } from '$lib/gpx';
|
||||||
import {
|
import {
|
||||||
builder,
|
builder,
|
||||||
|
focusWaypoint,
|
||||||
|
mapView,
|
||||||
setRoutedSegments,
|
setRoutedSegments,
|
||||||
setElevations,
|
setElevations,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
@@ -14,6 +18,14 @@
|
|||||||
densifyLinearSegments,
|
densifyLinearSegments,
|
||||||
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 ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
|
import Crosshair from '@lucide/svelte/icons/crosshair';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
||||||
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
|
import CheckCircle2 from '@lucide/svelte/icons/circle-check-big';
|
||||||
|
import Toggle from '$lib/components/Toggle.svelte';
|
||||||
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
@@ -202,12 +214,10 @@
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPX import: file input is hidden; the visible "GPX laden" button
|
// GPX import: handed off to ImageDropzone's FAB picker, which forwards
|
||||||
// proxies its click. Imported route REPLACES the current draft, so
|
// any `.gpx` file in the picked set here. Imported route REPLACES the
|
||||||
// confirm first when there's existing work to avoid silent data loss.
|
// current draft, so confirm first when there's existing work.
|
||||||
let gpxFileInput: HTMLInputElement | undefined = $state();
|
async function importGpxFile(file: File) {
|
||||||
|
|
||||||
function openGpxPicker() {
|
|
||||||
if (
|
if (
|
||||||
builder.waypoints.length > 0 &&
|
builder.waypoints.length > 0 &&
|
||||||
!confirm(
|
!confirm(
|
||||||
@@ -216,13 +226,6 @@
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
gpxFileInput?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onGpxSelected(e: Event) {
|
|
||||||
const input = e.currentTarget as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
try {
|
try {
|
||||||
const xml = await file.text();
|
const xml = await file.text();
|
||||||
const result = importGpx(xml);
|
const result = importGpx(xml);
|
||||||
@@ -236,9 +239,6 @@
|
|||||||
routeRequestId++;
|
routeRequestId++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = `GPX-Import fehlgeschlagen: ${(err as Error).message}`;
|
error = `GPX-Import fehlgeschlagen: ${(err as Error).message}`;
|
||||||
} finally {
|
|
||||||
// Reset so the same file can be re-selected later.
|
|
||||||
input.value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,8 +253,73 @@
|
|||||||
function cancelPlacement() {
|
function cancelPlacement() {
|
||||||
pendingPlacementId = null;
|
pendingPlacementId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Prev / next waypoint navigation -------------------------------------
|
||||||
|
// Derived list of placed-only waypoints (the only ones that can sit on the
|
||||||
|
// map). The nav bar walks this list in display order so jumping with
|
||||||
|
// chevrons follows the table's numbering.
|
||||||
|
const placedWaypoints = $derived(builder.waypoints.filter((w) => !w.unplaced));
|
||||||
|
|
||||||
|
const focusedIdx = $derived.by(() => {
|
||||||
|
if (!mapView.focusId) return -1;
|
||||||
|
return placedWaypoints.findIndex((w) => w.id === mapView.focusId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function focusByIdx(idx: number) {
|
||||||
|
const wp = placedWaypoints[idx];
|
||||||
|
if (!wp) return;
|
||||||
|
focusWaypoint(wp.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusPrev() {
|
||||||
|
if (placedWaypoints.length === 0) return;
|
||||||
|
if (focusedIdx <= 0) {
|
||||||
|
// No focus yet, or already at first → land on the last waypoint
|
||||||
|
// (typical "step backwards through the route" intent).
|
||||||
|
focusByIdx(focusedIdx === -1 ? placedWaypoints.length - 1 : 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focusByIdx(focusedIdx - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNext() {
|
||||||
|
if (placedWaypoints.length === 0) return;
|
||||||
|
if (focusedIdx === -1) {
|
||||||
|
focusByIdx(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (focusedIdx >= placedWaypoints.length - 1) {
|
||||||
|
focusByIdx(placedWaypoints.length - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focusByIdx(focusedIdx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refocus() {
|
||||||
|
// Re-center the map on the currently focused waypoint (handy after
|
||||||
|
// the user pans away).
|
||||||
|
if (focusedIdx === -1) focusByIdx(0);
|
||||||
|
else focusByIdx(focusedIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts for power users: ← / → step through waypoints
|
||||||
|
// (when not focused on an input).
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
|
||||||
|
if (target?.isContentEditable) return;
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
focusPrev();
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
focusNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
<Seo title="Routen-Builder · Wanderungen" description="Eigene Wanderrouten erstellen, exportieren und teilen." lang="de" />
|
<Seo title="Routen-Builder · Wanderungen" description="Eigene Wanderrouten erstellen, exportieren und teilen." lang="de" />
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -264,241 +329,496 @@
|
|||||||
|
|
||||||
<section class="builder">
|
<section class="builder">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
|
<div class="header-primary">
|
||||||
<input
|
<input
|
||||||
class="name-input"
|
class="name-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Name der Tour…"
|
placeholder="Name der Tour…"
|
||||||
bind:value={builder.name}
|
bind:value={builder.name}
|
||||||
/>
|
/>
|
||||||
<div class="actions">
|
<button
|
||||||
<select bind:value={builder.profile} disabled={!builder.autoSnap}>
|
type="button"
|
||||||
|
class="download-cta"
|
||||||
|
onclick={downloadGpx}
|
||||||
|
disabled={busy}
|
||||||
|
title={busy ? 'Warten bis Route + Höhenprofil berechnet sind' : 'GPX herunterladen'}
|
||||||
|
>
|
||||||
|
<Download size={16} strokeWidth={2.2} />
|
||||||
|
<span>GPX herunterladen</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="reset-btn"
|
||||||
|
onclick={clearDraft}
|
||||||
|
aria-label="Entwurf zurücksetzen"
|
||||||
|
title="Entwurf zurücksetzen"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="header-settings">
|
||||||
|
<div class="setting">
|
||||||
|
<span class="setting-label">Routing</span>
|
||||||
|
<select
|
||||||
|
class="profile-select"
|
||||||
|
bind:value={builder.profile}
|
||||||
|
disabled={!builder.autoSnap}
|
||||||
|
>
|
||||||
<option value="hiking-mountain">Wandern (Berg)</option>
|
<option value="hiking-mountain">Wandern (Berg)</option>
|
||||||
<option value="trekking">Trekking</option>
|
<option value="trekking">Trekking</option>
|
||||||
<option value="road">Strasse</option>
|
<option value="road">Strasse</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="snap-toggle" class:active={builder.autoSnap}>
|
</div>
|
||||||
<input type="checkbox" bind:checked={builder.autoSnap} />
|
<div class="setting setting-toggle">
|
||||||
<span>Auf Wege snappen</span>
|
<Toggle bind:checked={builder.autoSnap} label="Auf Wege snappen" />
|
||||||
</label>
|
</div>
|
||||||
<!-- Mode-agnostic busy chip — fires for both the snap-to-route
|
<!-- Mode-agnostic snap status. Spinner while the route or
|
||||||
path and the densify+elevate path so the user always knows
|
elevation profile is still resolving; subtle check when idle. -->
|
||||||
when the GPX is still incomplete. -->
|
<span class="snap-status" class:busy aria-live="polite" aria-atomic="true">
|
||||||
<span class="status" class:busy aria-live="polite" aria-atomic="true">
|
{#if busy}
|
||||||
<span class="status-dot" aria-hidden="true"></span>
|
<LoaderCircle size={14} strokeWidth={2.4} class="spin" />
|
||||||
{busy ? 'Berechne Route + Höhenprofil…' : 'Bereit'}
|
<span>Berechne Route + Höhenprofil…</span>
|
||||||
|
{:else}
|
||||||
|
<CheckCircle2 size={14} strokeWidth={2} />
|
||||||
|
<span>Bereit</span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="primary"
|
|
||||||
onclick={downloadGpx}
|
|
||||||
disabled={busy}
|
|
||||||
title={busy ? 'Warten bis Route + Höhenprofil berechnet sind' : ''}
|
|
||||||
>
|
|
||||||
GPX herunterladen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="link"
|
|
||||||
onclick={openGpxPicker}
|
|
||||||
title="Eine zuvor exportierte GPX-Datei in den Editor laden"
|
|
||||||
>
|
|
||||||
GPX laden
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
bind:this={gpxFileInput}
|
|
||||||
type="file"
|
|
||||||
accept=".gpx,application/gpx+xml,application/xml,text/xml"
|
|
||||||
onchange={onGpxSelected}
|
|
||||||
hidden
|
|
||||||
/>
|
|
||||||
<button type="button" class="link" onclick={clearDraft}>Zurücksetzen</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<RouteStatsBar {busy} />
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="err">{error}</p>
|
<p class="err">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid">
|
<div class="map-row">
|
||||||
<div class="map-col">
|
<div class="map-stage">
|
||||||
<EditMap
|
<EditMap
|
||||||
{pendingPlacementId}
|
{pendingPlacementId}
|
||||||
onPlacementCancel={cancelPlacement}
|
onPlacementCancel={cancelPlacement}
|
||||||
onPlacementComplete={cancelPlacement}
|
onPlacementComplete={cancelPlacement}
|
||||||
/>
|
/>
|
||||||
|
{#if placedWaypoints.length > 0}
|
||||||
|
<div class="map-nav" role="group" aria-label="Wegpunkt-Navigation">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={focusPrev}
|
||||||
|
aria-label="Vorheriger Wegpunkt"
|
||||||
|
title="Vorheriger Wegpunkt (←)"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
<span class="map-nav-label" aria-live="polite">
|
||||||
|
{focusedIdx === -1 ? '–' : focusedIdx + 1}
|
||||||
|
<span class="sep">/</span>
|
||||||
|
{placedWaypoints.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="recenter"
|
||||||
|
onclick={refocus}
|
||||||
|
aria-label="Auf aktuellen Wegpunkt zentrieren"
|
||||||
|
title="Zentrieren"
|
||||||
|
>
|
||||||
|
<Crosshair size={16} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={focusNext}
|
||||||
|
aria-label="Nächster Wegpunkt"
|
||||||
|
title="Nächster Wegpunkt (→)"
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="side">
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="side-col">
|
||||||
|
{#if placedWaypoints.length > 0}
|
||||||
|
<nav class="step-nav" aria-label="Zwischen Wegpunkten wechseln">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={focusPrev}
|
||||||
|
disabled={focusedIdx === 0}
|
||||||
|
aria-label="Vorheriger Wegpunkt"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} strokeWidth={2} />
|
||||||
|
<span>Zurück</span>
|
||||||
|
</button>
|
||||||
|
<span class="step-pos">
|
||||||
|
{focusedIdx === -1 ? '–' : focusedIdx + 1}
|
||||||
|
<span class="sep">/</span>
|
||||||
|
{placedWaypoints.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={focusNext}
|
||||||
|
disabled={focusedIdx === placedWaypoints.length - 1}
|
||||||
|
aria-label="Nächster Wegpunkt"
|
||||||
|
>
|
||||||
|
<span>Weiter</span>
|
||||||
|
<ChevronRight size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
<WaypointDetailPanel onCancelPlacement={cancelPlacement} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<WaypointTable
|
<WaypointTable
|
||||||
{pendingPlacementId}
|
{pendingPlacementId}
|
||||||
onRequestPlacement={startPlacement}
|
onRequestPlacement={startPlacement}
|
||||||
onCancelPlacement={cancelPlacement}
|
onCancelPlacement={cancelPlacement}
|
||||||
/>
|
/>
|
||||||
<ImageDropzone />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Tipp: Klicke auf die Karte, um Wegpunkte hinzuzufügen. Bilder mit GPS-EXIF werden
|
Tipp: Klicke auf die Karte, um Wegpunkte hinzuzufügen. Bilder mit GPS-EXIF werden
|
||||||
automatisch als Wegpunkte verwendet. Der GPX-Export bleibt lokal — eine Veröffentlichung
|
automatisch als Wegpunkte verwendet. Mit den Pfeiltasten ← → kannst du die Wegpunkte
|
||||||
als Wandereintrag erfordert einen Commit der Dateien unter
|
nacheinander auf der Karte anspringen. Der GPX-Export bleibt lokal — eine
|
||||||
|
Veröffentlichung als Wandereintrag erfordert einen Commit der Dateien unter
|
||||||
<code>src/content/hikes/<slug>/</code>.
|
<code>src/content/hikes/<slug>/</code>.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- FAB lives outside .builder so its fixed-positioning doesn't add a
|
||||||
|
phantom flex item / gap to the page's vertical flow. -->
|
||||||
|
<ImageDropzone onGpxImport={importGpxFile} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.builder {
|
.builder {
|
||||||
max-width: 1300px;
|
max-width: 1400px;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
padding: 1rem 1rem 3rem;
|
padding: 1rem 1rem 3rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.6rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary row: name input is the dominant element; download is the
|
||||||
|
* single primary CTA; reset is an icon-only escape hatch. */
|
||||||
|
.header-primary {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.name-input {
|
.name-input {
|
||||||
flex: 1 1 240px;
|
flex: 1 1 auto;
|
||||||
font-size: 1.25rem;
|
min-width: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.55rem 0.85rem;
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.name-input:focus {
|
||||||
display: flex;
|
outline: none;
|
||||||
gap: 0.5rem;
|
border-color: var(--color-primary);
|
||||||
flex-wrap: wrap;
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions select,
|
.download-cta {
|
||||||
.actions button {
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.45rem 0.9rem;
|
font-weight: 600;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), transform var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-cta:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover, var(--color-primary));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0.4em 1em -0.4em color-mix(in oklab, var(--color-primary) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-cta:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
background: var(--color-bg-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color var(--transition-fast), border-color var(--transition-fast),
|
||||||
|
background var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn:hover {
|
||||||
|
color: var(--red);
|
||||||
|
border-color: color-mix(in oklab, var(--red) 35%, var(--color-border));
|
||||||
|
background: color-mix(in oklab, var(--red) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary row: routing settings grouped on the left, live snap
|
||||||
|
* status on the right. Quieter than the primary row by design. */
|
||||||
|
.header-settings {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.25rem 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-select {
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.35rem 1.85rem 0.35rem 0.7rem;
|
||||||
|
background: var(--color-bg-tertiary)
|
||||||
|
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2381a1c1' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>")
|
||||||
|
no-repeat right 0.55rem center;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions button.primary {
|
.profile-select:disabled {
|
||||||
background: var(--color-primary);
|
opacity: 0.55;
|
||||||
color: var(--color-text-on-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions button.link {
|
|
||||||
background: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions button:disabled,
|
|
||||||
.actions select:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snap-toggle {
|
.setting-toggle :global(.toggle-wrapper label) {
|
||||||
display: inline-flex;
|
font-size: 0.85rem;
|
||||||
align-items: center;
|
gap: 0.55rem;
|
||||||
gap: 0.4rem;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.45rem 0.9rem;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.snap-toggle.active {
|
.snap-status {
|
||||||
background: var(--color-primary);
|
margin-left: auto;
|
||||||
color: var(--color-text-on-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.snap-toggle input {
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Live busy chip — sits between the snap toggle and the download
|
|
||||||
* button so the user can't miss it when GPX export would land
|
|
||||||
* without elevations yet. Quiet green dot when idle, pulsing
|
|
||||||
* amber dot when fetching. */
|
|
||||||
.status {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-tertiary);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.snap-status :global(svg) {
|
||||||
width: 0.55rem;
|
color: var(--green);
|
||||||
height: 0.55rem;
|
flex-shrink: 0;
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--green);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--green) 40%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.busy {
|
.snap-status.busy {
|
||||||
color: var(--orange);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.busy .status-dot {
|
.snap-status.busy :global(svg) {
|
||||||
background: var(--orange);
|
color: var(--color-primary);
|
||||||
animation: status-pulse 1.1s ease-out infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes status-pulse {
|
.snap-status :global(.spin) {
|
||||||
0% {
|
animation: snap-spin 0.85s linear infinite;
|
||||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--orange) 70%, transparent);
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
box-shadow: 0 0 0 0.55rem color-mix(in oklab, var(--orange) 0%, transparent);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--orange) 0%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes snap-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.status.busy .status-dot {
|
.snap-status :global(.spin) {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.map-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
|
grid-template-columns: minmax(0, 2.2fr) minmax(280px, 1fr);
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1024px) {
|
||||||
.grid {
|
.map-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-stage {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stand-alone Zurück/Weiter slider sitting above the detail-panel card,
|
||||||
|
* not inside it — gives the user the same step controls as the floating
|
||||||
|
* map-nav, but with full labels in the side column. */
|
||||||
|
.step-nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.3rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-nav button {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-nav button:first-child {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-nav button:last-child {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-nav button:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-nav button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-pos {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 3.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-pos .sep {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0 0.2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating prev/next navigator anchored to the top-right of the map.
|
||||||
|
* Sits above Leaflet's own zoom controls (z-index 1000); we use 600 to
|
||||||
|
* stay above markers/polylines (400) but below modal-ish UI. */
|
||||||
|
.map-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
z-index: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: color-mix(in oklab, var(--color-surface) 92%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-nav button {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-nav button:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-nav button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-nav button.recenter {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-nav-label {
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
min-width: 3.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-nav-label .sep {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0 0.15rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.err {
|
.err {
|
||||||
margin: 0 0 1rem;
|
margin: 0;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: color-mix(in oklab, var(--red) 12%, transparent);
|
background: color-mix(in oklab, var(--red) 12%, transparent);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
@@ -506,7 +826,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin-top: 1.5rem;
|
margin-top: 0.25rem;
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user