feat(frontend): add MapLibre GL map components

Reusable Svelte map component library wrapping MapLibre GL JS: Map, MapMarker, MapControls, MapPopup, MapRoute, MapClusterLayer, MapArc, and supporting components. Features theme auto-detection, controlled/uncontrolled viewport, drag support, and context API. useMap hook provides reactive access. Integrated on contact page for salon location display.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-04 01:07:25 +01:00
co-authored by Sisyphus
parent 584c02ac6c
commit 4c26db81db
15 changed files with 2152 additions and 31 deletions
@@ -0,0 +1,346 @@
<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 defaultStyles = {
dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
};
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="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full"></span>
<span
class="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:150ms]"
></span>
<span
class="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:300ms]"
></span>
</div>
</div>
{/if}
{#if hasInitiallyLoaded}
{@render children?.()}
{/if}
</div>