Files
Crussell/frontend/src/lib/components/ui/map/Map.svelte
T
popertots 4146f8e09a fix: pre-launch review — security, money safety, privacy, legal, code quality
Security (P0):
- IsJTIRevoked fails closed on DB error (previously accepted revoked tokens)
- Remove dead consume parameter from SCA gate (prevented token replay)
- Rate limiter map TTL-based eviction (prevented memory exhaustion)
- 2FA attempt map already had LRU eviction (verified)

Money Safety (P1):
- Gift card transfer refuses expired destination cards
- Gift card balance deduction has WHERE balance >= amount guard
- Webhook clawback acquires till-sale advisory lock
- Sweep/retry lock keys aligned

Privacy/Cookies (P2):
- Self-host Google Fonts (Playfair Display woff2)
- Replace CARTO map tiles with OpenStreetMap raster tiles
- Replace Wikimedia/icon-icons external images with local SVGs
- Remove external image URLs from CSP

Legal (P3):
- Privacy policy: add 6 missing data categories (gift cards, 2FA, GDPR, notifications, technical, cookies)
- Terms: add Tips section (optionality, non-refundable, same processing as bookings)

Code Quality (P4):
- twofa.Check accepts db.Querier for testability
- depositPromotionMinPct uses literal 0.20 (not misleading alias)
- HolidayHours.svelte uses proper type (not as any[])
- Remove stale TODO comments from main.go

Testing (P5):
- 94 new float64 money validity tests across 3 test files
- Cover VAT, splits, refunds, gift cards, rounding, precision boundaries
- All 27 backend test packages pass
2026-08-22 00:34:51 +01:00

360 lines
9.8 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, setContext, untrack } from 'svelte';
import { writable } from 'svelte/store';
import MapLibreGL from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { browser } from '$app/environment';
import { resolveMapTheme } from './theme';
const theme = writable<'light' | 'dark'>('light');
// Check document class for theme (works with next-themes, etc.)
function getDocumentTheme(): 'light' | 'dark' | null {
if (typeof document === 'undefined') return null;
if (document.documentElement.classList.contains('dark')) return 'dark';
if (document.documentElement.classList.contains('light')) return 'light';
return null;
}
// Get system preference
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
let tailwindTheme: 'light' | 'dark' = $state('light');
type MapStyleOption = string | MapLibreGL.StyleSpecification;
/** Map viewport state */
export type MapViewport = {
/** Center coordinates [longitude, latitude] */
center: [number, number];
/** Zoom level */
zoom: number;
/** Bearing (rotation) in degrees */
bearing: number;
/** Pitch (tilt) in degrees */
pitch: number;
};
interface Props {
children?: import('svelte').Snippet;
styles?: {
light?: MapStyleOption;
dark?: MapStyleOption;
};
theme?: 'light' | 'dark';
/** Map projection type. Use `{ type: "globe" }` for 3D globe view. */
projection?: MapLibreGL.ProjectionSpecification;
center?: [number, number];
zoom?: number;
options?: Omit<MapLibreGL.MapOptions, 'container' | 'style'>;
/**
* Bindable reference to the underlying MapLibre map instance.
* Useful for calling map methods imperatively from the parent.
*/
map?: MapLibreGL.Map | null;
/**
* Controlled viewport. When provided with onViewportChange,
* the map becomes controlled and viewport is driven by this prop.
*/
viewport?: Partial<MapViewport>;
/**
* Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch).
* Can be used standalone to observe changes, or with `viewport` prop
* to enable controlled mode where the map viewport is driven by your state.
*/
onviewportchange?: (viewport: MapViewport) => void;
/**
* Callback fired after each style load completes (initial load and subsequent style changes).
* Runs after any viewport restoration, so it's safe to call map methods here.
*/
onstyleloaded?: () => void;
}
const osmRasterStyle: MapLibreGL.StyleSpecification = {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }]
};
const defaultStyles = {
dark: osmRasterStyle,
light: osmRasterStyle
};
let {
children,
styles,
theme: explicitTheme,
projection,
center = [13.405, 52.52],
zoom = 0,
options = {},
map = $bindable(null),
viewport,
onviewportchange,
onstyleloaded
}: Props = $props();
let mapContainer: HTMLDivElement;
let isMounted = $state(false);
let isLoaded = $state(false);
let isStyleLoaded = $state(false);
let isInteracting = $state(false);
let hasInitiallyLoaded = $state(false);
let initialStyleApplied = false;
let initialCenterZoomApplied = false;
let styleTimeoutId: ReturnType<typeof setTimeout> | null = null;
let internalUpdate = false;
const isControlled = $derived(viewport !== undefined && onviewportchange !== undefined);
function getViewport(mapInstance: MapLibreGL.Map): MapViewport {
const c = mapInstance.getCenter();
return {
center: [c.lng, c.lat],
zoom: mapInstance.getZoom(),
bearing: mapInstance.getBearing(),
pitch: mapInstance.getPitch()
};
}
const mapStyles = $derived({
dark: styles?.dark ?? defaultStyles.dark,
light: styles?.light ?? defaultStyles.light
});
const resolvedTheme = $derived(resolveMapTheme({ explicitTheme, ambientTheme: tailwindTheme }));
const currentStyle = $derived(resolvedTheme === 'light' ? mapStyles.light : mapStyles.dark);
const isReady = $derived(isMounted && isLoaded && isStyleLoaded);
setContext('map', {
getMap: () => map,
isLoaded: () => hasInitiallyLoaded,
isStyleReady: () => isReady
});
function clearStyleTimeout() {
if (styleTimeoutId) {
clearTimeout(styleTimeoutId);
styleTimeoutId = null;
}
}
onMount(() => {
isMounted = true;
// Subscribe to theme store for instant updates
const themeUnsubscribe = theme.subscribe((value) => {
tailwindTheme = value;
});
// Clean up theme subscription
onDestroy(() => {
themeUnsubscribe();
});
if (browser) {
// Also watch for document class changes (e.g., external theme togglers)
const updateTheme = () => {
const docTheme = getDocumentTheme();
// Only use document theme if set, otherwise fall back to system preference
tailwindTheme = docTheme ?? getSystemTheme();
};
updateTheme();
const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
// Also watch for system preference changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemChange = (e: MediaQueryListEvent) => {
// Only use system preference if no document class is set
if (!getDocumentTheme()) {
tailwindTheme = e.matches ? 'dark' : 'light';
}
};
mediaQuery.addEventListener('change', handleSystemChange);
onDestroy(() => {
observer.disconnect();
mediaQuery.removeEventListener('change', handleSystemChange);
});
}
const mapInstance = new MapLibreGL.Map({
container: mapContainer,
style: currentStyle,
fadeDuration: 0,
renderWorldCopies: false,
attributionControl: {
compact: true
},
center: viewport?.center ?? center,
zoom: viewport?.zoom ?? zoom,
bearing: viewport?.bearing ?? 0,
pitch: viewport?.pitch ?? 0,
...options
});
const styleDataHandler = () => {
clearStyleTimeout();
// Delay to ensure style is fully processed before allowing layer operations
// This is a workaround to avoid race conditions with the style loading
// else we have to force update every layer on setStyle change
styleTimeoutId = setTimeout(() => {
isStyleLoaded = true;
if (!initialStyleApplied) {
initialStyleApplied = true;
}
if (!hasInitiallyLoaded) {
hasInitiallyLoaded = true;
}
if (projection) {
mapInstance.setProjection(projection);
}
onstyleloaded?.();
}, 100);
};
const loadHandler = () => {
isLoaded = true;
};
// Viewport change handler - skip if triggered by internal update
const handleMove = () => {
if (internalUpdate) return;
onviewportchange?.(getViewport(mapInstance));
};
mapInstance.on('load', loadHandler);
mapInstance.on('styledata', styleDataHandler);
mapInstance.on('move', handleMove);
mapInstance.on('dragstart', () => (isInteracting = true));
mapInstance.on('dragend', () => (isInteracting = false));
mapInstance.on('zoomstart', () => (isInteracting = true));
mapInstance.on('zoomend', () => (isInteracting = false));
mapInstance.on('rotatestart', () => (isInteracting = true));
mapInstance.on('rotateend', () => (isInteracting = false));
mapInstance.on('pitchstart', () => (isInteracting = true));
mapInstance.on('pitchend', () => (isInteracting = false));
map = mapInstance;
});
// Sync controlled viewport to map
$effect(() => {
if (!map || !isControlled || !viewport) return;
if (map.isMoving()) return;
const current = getViewport(map);
const next = {
center: viewport.center ?? current.center,
zoom: viewport.zoom ?? current.zoom,
bearing: viewport.bearing ?? current.bearing,
pitch: viewport.pitch ?? current.pitch
};
if (
next.center[0] === current.center[0] &&
next.center[1] === current.center[1] &&
next.zoom === current.zoom &&
next.bearing === current.bearing &&
next.pitch === current.pitch
) {
return;
}
internalUpdate = true;
map!.once('moveend', () => {
internalUpdate = false;
});
map.jumpTo(next);
});
$effect(() => {
const style = currentStyle;
if (!map || !initialStyleApplied) {
return;
}
untrack(() => {
const currCenter = map!.getCenter();
const currZoom = map!.getZoom();
const currBearing = map!.getBearing();
const currPitch = map!.getPitch();
isStyleLoaded = false;
map!.setStyle(style, { diff: true });
map!.once('styledata', () => {
map!.jumpTo({
center: currCenter,
zoom: currZoom,
bearing: currBearing,
pitch: currPitch
});
});
});
});
$effect(() => {
if (!map || !isReady || isInteracting || initialCenterZoomApplied || isControlled) {
return;
}
// Only apply initial center/zoom once, then let user move freely
// Skip if controlled mode is enabled
initialCenterZoomApplied = true;
const [lng, lat] = center;
untrack(() => {
map!.easeTo({ center: [lng, lat], zoom });
});
});
onDestroy(() => {
map?.remove();
map = null;
isLoaded = false;
isStyleLoaded = false;
});
</script>
<div bind:this={mapContainer} class="relative h-full w-full">
{#if !isReady}
<div class="absolute inset-0 flex items-center justify-center">
<div class="flex gap-1">
<span class="size-1.5 animate-pulse rounded-full bg-muted-foreground/60"></span>
<span
class="size-1.5 animate-pulse rounded-full bg-muted-foreground/60 [animation-delay:150ms]"
></span>
<span
class="size-1.5 animate-pulse rounded-full bg-muted-foreground/60 [animation-delay:300ms]"
></span>
</div>
</div>
{/if}
{#if hasInitiallyLoaded}
{@render children?.()}
{/if}
</div>