8a67f5fba8
Three related improvements to the pre-rendered hero map system: * New medium viewport variant (561–899 CSS px) for the per-hike detail hero and the /hikes overview. Tablet/split-pane viewports were getting the wide pose (chosen for ~1920 CSS px), which landed too zoomed in. Each variant is rendered at a pose matching its container, so the static→Leaflet handover aligns at every band. Manifest fields are optional — pages fall back to the wide variant on tablets until build-hikes regenerates the images. * Overview frames on Switzerland (fixed center [46.82, 8.23]) with explicit per-variant zooms (wide=8, medium=8, narrow=7) rather than auto-fitting the union of hike bboxes. The previous behavior zoomed in on whichever corner the catalogue clustered in; this reads as "hikes across CH". Bumps OVERVIEW_RENDER_VERSION so cached overview images get invalidated on the next build. * Removed the post-tile-load flyToBounds in both HikeMap.svelte and HikesOverviewMap.svelte. The map already opens at the static pose via setView; the second auto-fit was adding a visible wobble on routes whose bbox sits at an integer-zoom boundary (e.g. the Einsiedeln–Unteriberg detail), where the build-time fit and Leaflet's runtime fit disagree by one zoom step at the user's actual container size.
600 lines
18 KiB
Svelte
600 lines
18 KiB
Svelte
<script lang="ts">
|
|
import type { Attachment } from 'svelte/attachments';
|
|
import { goto } from '$app/navigation';
|
|
import { resolve } from '$app/paths';
|
|
import { sacTrailColor } from '$lib/data/sacColors';
|
|
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
|
|
import { isSwissRegion } from '$lib/hikes/hikeArea';
|
|
import type { HikeManifestEntry } from '$types/hikes';
|
|
import Map from '@lucide/svelte/icons/map';
|
|
import Satellite from '@lucide/svelte/icons/satellite';
|
|
import Landmark from '@lucide/svelte/icons/landmark';
|
|
import Layers from '@lucide/svelte/icons/layers';
|
|
import Locate from '@lucide/svelte/icons/locate';
|
|
import LocateOff from '@lucide/svelte/icons/locate-off';
|
|
import Maximize2 from '@lucide/svelte/icons/maximize-2';
|
|
|
|
interface Props {
|
|
hikes: HikeManifestEntry[];
|
|
/** Initial map centre `[lat, lng]`. When provided alongside
|
|
* `initialZoom`, the map opens with `setView(center, zoom)` instead
|
|
* of `fitBounds(union)` — used by the index page to align Leaflet's
|
|
* first paint with the SSR-rendered static overview hero. */
|
|
initialCenter?: [number, number];
|
|
initialZoom?: number;
|
|
/** Fires once the schematic tile layer's first batch of tiles has
|
|
* finished loading — i.e. the map is visually complete. The page
|
|
* uses this to fade out the SSR-rendered static hero. */
|
|
onReady?: () => void;
|
|
}
|
|
|
|
const { hikes, initialCenter, initialZoom, onReady }: Props = $props();
|
|
|
|
// When every displayed hike is in a swisstopo region (CH/LI), the schematic
|
|
// can use swisstopo's z19; with a hike abroad the global fallback is shallower
|
|
// so we cap a touch lower (still generous — the overview is a finder).
|
|
const allSwiss = $derived(hikes.every((h) => isSwissRegion(h.canton, h.country)));
|
|
|
|
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
|
|
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = $derived({
|
|
schematic: { label: 'Karte', icon: Map, maxZoom: allSwiss ? 19 : 18 },
|
|
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
|
|
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
|
|
});
|
|
|
|
const GPS_STORAGE_KEY = 'hikes:gpsEnabled';
|
|
|
|
let baseLayer = $state<BaseLayer>('schematic');
|
|
let layerMenuOpen = $state(false);
|
|
let enableUserLocation = $state(false);
|
|
let locationError = $state<string | null>(null);
|
|
// Re-fit callback wired up once Leaflet + bounds are alive inside the
|
|
// attachment. Null hides the button.
|
|
let recenterMap = $state<(() => void) | null>(null);
|
|
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
if (window.localStorage.getItem(GPS_STORAGE_KEY) === '1') enableUserLocation = true;
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
window.localStorage.setItem(GPS_STORAGE_KEY, enableUserLocation ? '1' : '0');
|
|
});
|
|
|
|
// Close the layer popover on outside click. The opening click on the
|
|
// button calls stopPropagation so this never sees the click that opened it.
|
|
$effect(() => {
|
|
if (!layerMenuOpen) return;
|
|
function onAway(e: MouseEvent) {
|
|
const target = e.target as HTMLElement | null;
|
|
if (target && !target.closest('.layer-menu')) layerMenuOpen = false;
|
|
}
|
|
window.addEventListener('click', onAway);
|
|
return () => window.removeEventListener('click', onAway);
|
|
});
|
|
|
|
function toggleLocation() {
|
|
if (enableUserLocation) {
|
|
enableUserLocation = false;
|
|
locationError = null;
|
|
return;
|
|
}
|
|
if (typeof window === 'undefined') return;
|
|
const hasTauri = '__TAURI_INTERNALS__' in window;
|
|
const hasWebGeo = 'geolocation' in navigator;
|
|
if (!hasTauri && !hasWebGeo) {
|
|
locationError = 'Geolocation steht in diesem Browser nicht zur Verfügung.';
|
|
return;
|
|
}
|
|
locationError = null;
|
|
enableUserLocation = true;
|
|
}
|
|
|
|
const mapAttachment: Attachment<HTMLElement> = (node) => {
|
|
let cancelled = false;
|
|
let cleanup: (() => void) | undefined;
|
|
|
|
(async () => {
|
|
const L = await import('leaflet');
|
|
if (cancelled || !node.isConnected) return;
|
|
|
|
// `tolerance` widens the canvas renderer's hit-test radius around
|
|
// every polyline (hit = weight/2 + tolerance), so a route can be
|
|
// hovered/clicked from a comfortable margin instead of demanding a
|
|
// pixel-perfect click on the 4 px line.
|
|
const map = L.map(node, {
|
|
// On-map attribution control removed for a cleaner frame.
|
|
// NOTE: swisstopo's tile licence requires their credit to appear;
|
|
// the /hikes page currently shows no other swisstopo attribution.
|
|
attributionControl: false,
|
|
zoomControl: true,
|
|
preferCanvas: true,
|
|
renderer: L.canvas({ tolerance: 12 })
|
|
});
|
|
// Sensible default centre (mid-Switzerland) while the polyline
|
|
// layer is built up; `fitBounds` below overrides it once the
|
|
// union bounds are known. If the caller passed a pre-rendered
|
|
// hero pose, use that instead so Leaflet lands aligned with the
|
|
// static image on first paint.
|
|
if (initialCenter && typeof initialZoom === 'number') {
|
|
map.setView(initialCenter, initialZoom, { animate: false });
|
|
} else {
|
|
map.setView([46.8, 8.3], 8);
|
|
}
|
|
|
|
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
|
|
schematic: L.tileLayer(TILE_URL.karte, {
|
|
maxZoom: LAYER_DEFS.schematic.maxZoom,
|
|
minZoom: 7,
|
|
attribution: TILE_ATTRIBUTION,
|
|
updateWhenZooming: false
|
|
}),
|
|
aerial: L.tileLayer(TILE_URL.luftbild, {
|
|
maxZoom: LAYER_DEFS.aerial.maxZoom,
|
|
minZoom: 7,
|
|
attribution: TILE_ATTRIBUTION,
|
|
updateWhenZooming: false
|
|
}),
|
|
dufour: L.tileLayer(TILE_URL.dufour, {
|
|
maxZoom: LAYER_DEFS.dufour.maxZoom,
|
|
minZoom: 7,
|
|
attribution: TILE_ATTRIBUTION,
|
|
updateWhenZooming: false
|
|
})
|
|
};
|
|
tileLayers.schematic.addTo(map);
|
|
let currentBase: BaseLayer = 'schematic';
|
|
|
|
// Forward-declared so the tile-load handover handler below can
|
|
// close over it; populated once the polyline loop has built the
|
|
// union bounds.
|
|
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
|
|
|
|
// First-paint handover: fire `onReady` once the schematic tile
|
|
// layer's initial batch loads so the static hero can fade out.
|
|
// The map already opened at the static pose via setView (see
|
|
// the initialCenter branch below), so no extra animation is
|
|
// needed — and `flyToBounds(union)` here used to cause a
|
|
// visible wobble on hikes whose union bbox sits at an integer-
|
|
// zoom boundary, where the static's fit and Leaflet's runtime
|
|
// fit disagree by one zoom step. Mirrors the same fix in
|
|
// `HikeMap.svelte`.
|
|
tileLayers.schematic.once('load', () => {
|
|
onReady?.();
|
|
});
|
|
|
|
// One polyline per hike, sourced from the manifest's already-
|
|
// simplified previewPolyline (≤150 points each). The layer is
|
|
// re-populated on every `hikes` prop change (see the $effect
|
|
// below) so toggling filters updates the visible routes — and
|
|
// re-fits the camera to the new union bounds.
|
|
const layer = L.layerGroup().addTo(map);
|
|
|
|
function renderPolylines(): boolean {
|
|
layer.clearLayers();
|
|
const b = L.latLngBounds([]);
|
|
for (const hike of hikes) {
|
|
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
|
|
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
|
|
const color = sacTrailColor(hike.difficulty);
|
|
// Multi-day hikes with a big inter-stage gap ship `previewBreaks`
|
|
// (indices where a new run starts); split there so Leaflet draws
|
|
// disconnected segments instead of a line across the transfer.
|
|
const breaks = hike.previewBreaks;
|
|
let coords: [number, number][] | [number, number][][] = latLngs;
|
|
if (breaks && breaks.length > 0) {
|
|
const segs: [number, number][][] = [];
|
|
let start = 0;
|
|
for (const brk of breaks) {
|
|
if (brk > start) segs.push(latLngs.slice(start, brk));
|
|
start = brk;
|
|
}
|
|
segs.push(latLngs.slice(start));
|
|
coords = segs.filter((s) => s.length >= 2);
|
|
}
|
|
const poly = L.polyline(coords, {
|
|
color,
|
|
weight: 4,
|
|
opacity: 0.9,
|
|
interactive: true
|
|
}).addTo(layer);
|
|
|
|
poly.bindTooltip(
|
|
`<strong>${hike.title}</strong><br>` +
|
|
`${hike.distanceKm.toFixed(1)} km · ↑${hike.elevationGainM} m · SAC ${hike.difficulty}`,
|
|
{ sticky: true, direction: 'top', opacity: 0.95, className: 'hike-overview-tooltip' }
|
|
);
|
|
poly.on('mouseover', () => {
|
|
poly.setStyle({ weight: 7, opacity: 1 });
|
|
poly.bringToFront();
|
|
});
|
|
poly.on('mouseout', () => {
|
|
poly.setStyle({ weight: 4, opacity: 0.9 });
|
|
});
|
|
poly.on('click', () => {
|
|
goto(resolve('/hikes/[slug]', { slug: hike.slug }));
|
|
});
|
|
|
|
for (const [lat, lng] of latLngs) {
|
|
b.extend([lat, lng]);
|
|
}
|
|
}
|
|
if (b.isValid()) {
|
|
initialBounds = b;
|
|
recenterMap = () => {
|
|
if (!initialBounds) return;
|
|
map.flyToBounds(initialBounds, {
|
|
padding: [32, 32],
|
|
maxZoom: 13,
|
|
duration: 0.6,
|
|
easeLinearity: 0.25
|
|
});
|
|
};
|
|
return true;
|
|
}
|
|
initialBounds = null;
|
|
recenterMap = null;
|
|
return false;
|
|
}
|
|
|
|
// Initial paint — no animated fit when the caller handed us a
|
|
// pre-rendered hero pose (the tile-load handover handles the
|
|
// fly-to), otherwise fit straight to the union bounds.
|
|
if (renderPolylines() && (!initialCenter || typeof initialZoom !== 'number') && initialBounds) {
|
|
map.fitBounds(initialBounds, { padding: [32, 32], maxZoom: 13 });
|
|
}
|
|
|
|
// User location (opt-in). Same Tauri-first / Web-Geolocation-fallback
|
|
// pattern as HikeMap so the toggle behaves identically across the app.
|
|
let userMarker: ReturnType<typeof L.circleMarker> | null = null;
|
|
let userAccuracyCircle: ReturnType<typeof L.circle> | null = null;
|
|
let userCleanup: (() => void) | undefined;
|
|
|
|
async function attachUserLocation() {
|
|
if (!enableUserLocation) return;
|
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
|
const handlePos = (lat: number, lng: number, accuracy: number) => {
|
|
if (!userMarker) {
|
|
userMarker = L.circleMarker([lat, lng], {
|
|
radius: 7,
|
|
fillColor: '#5e81ac',
|
|
fillOpacity: 1,
|
|
color: '#fff',
|
|
weight: 2
|
|
}).addTo(map);
|
|
userAccuracyCircle = L.circle([lat, lng], {
|
|
radius: accuracy,
|
|
color: '#5e81ac',
|
|
fillColor: '#5e81ac',
|
|
fillOpacity: 0.1,
|
|
weight: 1
|
|
}).addTo(map);
|
|
} else {
|
|
userMarker.setLatLng([lat, lng]);
|
|
userAccuracyCircle?.setLatLng([lat, lng]);
|
|
userAccuracyCircle?.setRadius(accuracy);
|
|
}
|
|
};
|
|
if (isTauri) {
|
|
try {
|
|
const geo = await import('@tauri-apps/plugin-geolocation');
|
|
const watchId = await geo.watchPosition(
|
|
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 },
|
|
(pos) => {
|
|
if (pos?.coords)
|
|
handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy ?? 30);
|
|
}
|
|
);
|
|
userCleanup = () => geo.clearWatch(watchId).catch(() => {});
|
|
} catch {
|
|
/* Tauri plugin unavailable — fall through to web API */
|
|
}
|
|
}
|
|
if (!userCleanup && 'geolocation' in navigator) {
|
|
const id = navigator.geolocation.watchPosition(
|
|
(pos) => handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy),
|
|
() => {},
|
|
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 }
|
|
);
|
|
userCleanup = () => navigator.geolocation.clearWatch(id);
|
|
}
|
|
}
|
|
attachUserLocation();
|
|
|
|
// React to control toggles outside the attachment.
|
|
const stopReactRoot = $effect.root(() => {
|
|
// Re-render polylines whenever the `hikes` prop changes
|
|
// (filter bar toggles, tag deep-link). The first $effect
|
|
// run fires immediately and would re-do the initial paint
|
|
// for no UX gain — skip it via a tick counter.
|
|
let rerunTick = 0;
|
|
$effect(() => {
|
|
void hikes;
|
|
if (rerunTick++ === 0) return;
|
|
if (renderPolylines() && initialBounds) {
|
|
// Smooth re-fit so the user sees the camera glide
|
|
// toward whichever subset is now on display.
|
|
map.flyToBounds(initialBounds, {
|
|
padding: [32, 32],
|
|
maxZoom: 13,
|
|
duration: 0.6,
|
|
easeLinearity: 0.25
|
|
});
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (baseLayer === currentBase) return;
|
|
tileLayers[currentBase].remove();
|
|
tileLayers[baseLayer].addTo(map);
|
|
const newMax = LAYER_DEFS[baseLayer].maxZoom;
|
|
map.setMaxZoom(newMax);
|
|
if (map.getZoom() > newMax) map.setZoom(newMax);
|
|
currentBase = baseLayer;
|
|
});
|
|
$effect(() => {
|
|
if (!enableUserLocation && userCleanup) {
|
|
userCleanup();
|
|
userCleanup = undefined;
|
|
if (userMarker) userMarker.remove();
|
|
if (userAccuracyCircle) userAccuracyCircle.remove();
|
|
userMarker = null;
|
|
userAccuracyCircle = null;
|
|
} else if (enableUserLocation && !userCleanup) {
|
|
attachUserLocation();
|
|
}
|
|
});
|
|
});
|
|
|
|
cleanup = () => {
|
|
userCleanup?.();
|
|
stopReactRoot();
|
|
recenterMap = null;
|
|
map.remove();
|
|
};
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
cleanup?.();
|
|
};
|
|
};
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<link rel="preconnect" href="https://maps.bocken.org" crossorigin="anonymous" />
|
|
</svelte:head>
|
|
|
|
<div class="map-wrap">
|
|
<div class="overview-map" {@attach mapAttachment} aria-label="Übersichtskarte aller Wanderungen"></div>
|
|
|
|
<div class="map-controls">
|
|
<div class="layer-menu" class:open={layerMenuOpen}>
|
|
<button
|
|
type="button"
|
|
class="round-btn"
|
|
aria-label="Kartenebene wählen"
|
|
aria-haspopup="menu"
|
|
aria-expanded={layerMenuOpen}
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
layerMenuOpen = !layerMenuOpen;
|
|
}}
|
|
>
|
|
<Layers size={20} strokeWidth={2} aria-hidden="true" />
|
|
</button>
|
|
{#if layerMenuOpen}
|
|
<div class="layer-popover" role="menu">
|
|
{#each Object.entries(LAYER_DEFS) as [key, def] (key)}
|
|
{@const Icon = def.icon}
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={baseLayer === key}
|
|
class:active={baseLayer === key}
|
|
onclick={() => {
|
|
baseLayer = key as BaseLayer;
|
|
layerMenuOpen = false;
|
|
}}
|
|
>
|
|
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
|
|
{def.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if recenterMap}
|
|
<button
|
|
type="button"
|
|
class="round-btn"
|
|
aria-label="Auf alle Touren zurückzentrieren"
|
|
title="Karte auf alle Touren zurückzentrieren"
|
|
onclick={() => recenterMap?.()}
|
|
>
|
|
<Maximize2 size={18} strokeWidth={2} aria-hidden="true" />
|
|
</button>
|
|
{/if}
|
|
|
|
<button
|
|
type="button"
|
|
class="round-btn"
|
|
class:active={enableUserLocation}
|
|
aria-pressed={enableUserLocation}
|
|
title={enableUserLocation
|
|
? 'Eigenen Standort verbergen'
|
|
: 'Eigenen Standort anzeigen — wird lokal berechnet, nicht an Dritte gesendet'}
|
|
aria-label={enableUserLocation ? 'Eigenen Standort verbergen' : 'Eigenen Standort anzeigen'}
|
|
onclick={toggleLocation}
|
|
>
|
|
{#if enableUserLocation}
|
|
<Locate size={20} strokeWidth={2} aria-hidden="true" />
|
|
{:else}
|
|
<LocateOff size={20} strokeWidth={2} aria-hidden="true" />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
{#if locationError}
|
|
<p class="gps-error" role="status">{locationError}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.map-wrap {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.overview-map {
|
|
width: 100%;
|
|
height: clamp(320px, 50vh, 520px);
|
|
background: var(--color-bg-elevated);
|
|
}
|
|
|
|
/* Tooltip lives at body level, so it has to be global. */
|
|
:global(.hike-overview-tooltip) {
|
|
font: inherit;
|
|
font-size: 0.8rem;
|
|
line-height: 1.35;
|
|
padding: 0.4rem 0.6rem;
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-surface);
|
|
color: var(--color-text-primary);
|
|
box-shadow: var(--shadow-md);
|
|
}
|
|
|
|
:global(.hike-overview-tooltip strong) {
|
|
display: block;
|
|
margin-bottom: 0.1rem;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
:global(.leaflet-interactive) {
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Bottom-right stack of round controls. Mirrors HikeMap.svelte exactly so
|
|
* users get the same controls and visual language as the detail page. */
|
|
.map-controls {
|
|
position: absolute;
|
|
bottom: 1rem;
|
|
right: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
align-items: flex-end;
|
|
z-index: 500;
|
|
}
|
|
|
|
.round-btn {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 44px;
|
|
height: 44px;
|
|
background: var(--color-surface);
|
|
color: var(--color-text-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 50%;
|
|
box-shadow: var(--shadow-md);
|
|
cursor: pointer;
|
|
transition:
|
|
color var(--transition-fast),
|
|
background var(--transition-fast),
|
|
transform var(--transition-fast),
|
|
box-shadow var(--transition-fast);
|
|
}
|
|
|
|
.round-btn:hover {
|
|
color: var(--color-primary);
|
|
transform: scale(1.05);
|
|
box-shadow: var(--shadow-hover);
|
|
}
|
|
|
|
.round-btn.active {
|
|
background: var(--color-primary);
|
|
color: var(--color-text-on-primary);
|
|
border-color: var(--color-primary);
|
|
}
|
|
|
|
.round-btn.active:hover {
|
|
color: var(--color-text-on-primary);
|
|
}
|
|
|
|
.layer-menu {
|
|
position: relative;
|
|
}
|
|
|
|
.layer-popover {
|
|
position: absolute;
|
|
right: calc(100% + 0.5rem);
|
|
bottom: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
padding: 0.3rem;
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: var(--shadow-lg);
|
|
min-width: 9.5rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.layer-popover button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
padding: 0.45rem 0.7rem;
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--color-text-primary);
|
|
font: inherit;
|
|
font-size: 0.85rem;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background var(--transition-fast), color var(--transition-fast);
|
|
}
|
|
|
|
.layer-popover button :global(svg) {
|
|
color: var(--color-text-tertiary);
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.layer-popover button:hover {
|
|
background: var(--color-bg-elevated);
|
|
}
|
|
|
|
.layer-popover button.active {
|
|
background: var(--color-primary);
|
|
color: var(--color-text-on-primary);
|
|
}
|
|
|
|
.layer-popover button.active :global(svg) {
|
|
color: var(--color-text-on-primary);
|
|
}
|
|
|
|
/* GPS-permission error toast. Three 44 px buttons + two 0.5 rem gaps =
|
|
* ~148 px stack plus 1 rem inset; anchor the toast above that. */
|
|
.gps-error {
|
|
position: absolute;
|
|
bottom: 11rem;
|
|
right: 1rem;
|
|
max-width: 18rem;
|
|
margin: 0;
|
|
padding: 0.5rem 0.75rem;
|
|
background: var(--color-surface);
|
|
color: var(--red);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: var(--shadow-md);
|
|
font-size: 0.78rem;
|
|
z-index: 500;
|
|
}
|
|
</style>
|