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>
@@ -0,0 +1,304 @@
<script lang="ts" module>
import type MapLibreGL from "maplibre-gl";
export type MapArcDatum = {
/** Unique identifier for this arc. Required for hover state tracking. */
id: string | number;
/** Start coordinate as [longitude, latitude]. */
from: [number, number];
/** End coordinate as [longitude, latitude]. */
to: [number, number];
};
export type MapArcEvent<T extends MapArcDatum = MapArcDatum> = {
arc: T;
longitude: number;
latitude: number;
originalEvent: MapLibreGL.MapMouseEvent;
};
type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
type MapArcLineLayout = NonNullable<MapLibreGL.LineLayerSpecification["layout"]>;
export type MapArcProps<T extends MapArcDatum = MapArcDatum> = {
/** Array of arcs to render. Each arc must have a unique `id`. */
data: T[];
/** Optional unique identifier prefix for the arc source/layers. */
id?: string;
/**
* How far each arc bows away from a straight line. `0` renders straight lines;
* higher values bend further. Negative values bend to the opposite side. (default: 0.2)
*/
curvature?: number;
/** Number of samples used to render each curve. Higher = smoother. (default: 64) */
samples?: number;
/** MapLibre paint properties for the arc layer. */
paint?: MapArcLinePaint;
/** MapLibre layout properties for the arc layer. */
layout?: MapArcLineLayout;
/** Paint properties applied to the arc currently under the cursor. */
hoverPaint?: MapArcLinePaint;
/** Callback when an arc is clicked. */
onclick?: (e: MapArcEvent<T>) => void;
/** Callback fired when the hovered arc changes. */
onhover?: (e: MapArcEvent<T> | null) => void;
/** Whether arcs respond to mouse events. (default: true) */
interactive?: boolean;
/** Optional MapLibre layer id to insert the arc layers before. */
beforeId?: string;
};
</script>
<script lang="ts" generics="T extends MapArcDatum = MapArcDatum">
import { useMap } from "$lib/hooks/use-map.svelte.js";
let {
data,
id: propId,
curvature = 0.2,
samples = 64,
paint,
layout,
hoverPaint,
onclick,
onhover,
interactive = true,
beforeId,
}: MapArcProps<T> = $props();
const DEFAULT_PAINT: NonNullable<MapLibreGL.LineLayerSpecification["paint"]> = {
"line-color": "#4285F4",
"line-width": 2,
"line-opacity": 0.85,
};
const DEFAULT_LAYOUT: NonNullable<MapLibreGL.LineLayerSpecification["layout"]> = {
"line-join": "round",
"line-cap": "round",
};
const ARC_HIT_MIN_WIDTH = 12;
const ARC_HIT_PADDING = 6;
let autoId = $state(Math.random().toString(36).slice(2));
const id = $derived(propId ?? autoId);
const sourceId = $derived(`arc-source-${id}`);
const layerId = $derived(`arc-layer-${id}`);
const hitLayerId = $derived(`arc-hit-layer-${id}`);
const { map, isLoaded } = useMap();
function buildArcCoordinates(
from: [number, number],
to: [number, number],
curvature: number,
samples: number
): [number, number][] {
const [x0, y0] = from;
const [x2, y2] = to;
const dx = x2 - x0;
const dy = y2 - y0;
const distance = Math.hypot(dx, dy);
if (distance === 0 || curvature === 0) return [from, to];
const mx = (x0 + x2) / 2;
const my = (y0 + y2) / 2;
const nx = -dy / distance;
const ny = dx / distance;
const offset = distance * curvature;
const cx = mx + nx * offset;
const cy = my + ny * offset;
const points: [number, number][] = [];
const segments = Math.max(2, Math.floor(samples));
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const inv = 1 - t;
const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2;
const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2;
points.push([x, y]);
}
return points;
}
function mergeArcPaint(
base: NonNullable<MapLibreGL.LineLayerSpecification["paint"]>,
hover: NonNullable<MapLibreGL.LineLayerSpecification["paint"]> | undefined
): NonNullable<MapLibreGL.LineLayerSpecification["paint"]> {
if (!hover) return base;
const merged: Record<string, unknown> = { ...base };
for (const [key, hoverValue] of Object.entries(hover)) {
if (hoverValue === undefined) continue;
const baseValue = merged[key];
merged[key] =
baseValue === undefined
? hoverValue
: ["case", ["boolean", ["feature-state", "hover"], false], hoverValue, baseValue];
}
return merged as NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
}
const geoJSON = $derived.by<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => ({
type: "FeatureCollection",
features: data.map((arc) => {
const { from, to, id: arcId, ...properties } = arc;
return {
id: typeof arcId === "number" ? arcId : undefined,
type: "Feature" as const,
properties: { ...properties, _arc_id: String(arcId) },
geometry: {
type: "LineString" as const,
coordinates: buildArcCoordinates(from, to, curvature, samples),
},
};
}),
}));
const mergedPaint = $derived(mergeArcPaint({ ...DEFAULT_PAINT, ...paint }, hoverPaint));
const mergedLayout = $derived({ ...DEFAULT_LAYOUT, ...layout });
const hitWidth = $derived(() => {
const w = paint?.["line-width"] ?? DEFAULT_PAINT["line-width"];
const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH;
return Math.max((base as number) + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
});
let hoveredArcId: string | null = null;
function getArcById(arcId: string): T | undefined {
return data.find((a) => String(a.id) === arcId);
}
$effect(() => {
if (!map || !isLoaded) return;
const currentSourceId = sourceId;
const currentLayerId = layerId;
const currentHitLayerId = hitLayerId;
if (!map.getSource(currentSourceId)) {
map.addSource(currentSourceId, {
type: "geojson",
data: geoJSON,
promoteId: "_arc_id",
});
map.addLayer(
{
id: currentLayerId,
type: "line",
source: currentSourceId,
layout: mergedLayout,
paint: mergedPaint,
},
beforeId
);
if (interactive) {
map.addLayer(
{
id: currentHitLayerId,
type: "line",
source: currentSourceId,
layout: mergedLayout,
paint: { "line-color": "transparent", "line-width": hitWidth() },
},
beforeId
);
}
}
return () => {
try {
if (map.getLayer(currentHitLayerId)) map.removeLayer(currentHitLayerId);
if (map.getLayer(currentLayerId)) map.removeLayer(currentLayerId);
if (map.getSource(currentSourceId)) map.removeSource(currentSourceId);
} catch {
// ignore
}
};
});
// Update GeoJSON data reactively
$effect(() => {
if (!map || !isLoaded) return;
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
if (source) source.setData(geoJSON);
});
// Update paint reactively
$effect(() => {
if (!map || !isLoaded) return;
if (map.getLayer(layerId)) {
for (const [key, value] of Object.entries(mergedPaint)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.setPaintProperty(layerId, key as any, value);
}
}
});
// Wire up interaction events
$effect(() => {
if (!map || !isLoaded || !interactive) return;
const targetLayer = hitLayerId;
const handleClick = (e: MapLibreGL.MapMouseEvent) => {
if (!onclick) return;
const features = map.queryRenderedFeatures(e.point, { layers: [targetLayer] });
if (!features.length) return;
const arcId = features[0].properties?._arc_id;
if (!arcId) return;
const arc = getArcById(arcId);
if (!arc) return;
onclick({ arc, longitude: e.lngLat.lng, latitude: e.lngLat.lat, originalEvent: e });
};
const handleMouseMove = (e: MapLibreGL.MapMouseEvent) => {
const features = map.queryRenderedFeatures(e.point, { layers: [targetLayer] });
const arcId = features.length ? features[0].properties?._arc_id : null;
if (arcId === hoveredArcId) return;
if (hoveredArcId !== null) {
map.setFeatureState({ source: sourceId, id: hoveredArcId }, { hover: false });
hoveredArcId = null;
}
if (arcId) {
map.setFeatureState({ source: sourceId, id: arcId }, { hover: true });
hoveredArcId = arcId;
map.getCanvas().style.cursor = "pointer";
if (onhover) {
const arc = getArcById(arcId);
if (arc) {
onhover({ arc, longitude: e.lngLat.lng, latitude: e.lngLat.lat, originalEvent: e });
}
}
} else {
map.getCanvas().style.cursor = "";
if (onhover) onhover(null);
}
};
const handleMouseLeave = () => {
if (hoveredArcId !== null) {
map.setFeatureState({ source: sourceId, id: hoveredArcId }, { hover: false });
hoveredArcId = null;
}
map.getCanvas().style.cursor = "";
if (onhover) onhover(null);
};
map.on("click", targetLayer, handleClick);
map.on("mousemove", targetLayer, handleMouseMove);
map.on("mouseleave", targetLayer, handleMouseLeave);
return () => {
map.off("click", targetLayer, handleClick);
map.off("mousemove", targetLayer, handleMouseMove);
map.off("mouseleave", targetLayer, handleMouseLeave);
};
});
</script>
@@ -0,0 +1,286 @@
<script lang="ts" generics="P extends GeoJSON.GeoJsonProperties">
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
interface Props {
/** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */
data: string | GeoJSON.FeatureCollection<GeoJSON.Point, P>;
/** Maximum zoom level to cluster points on (default: 14) */
clusterMaxZoom?: number;
/** Radius of each cluster when clustering points in pixels (default: 50) */
clusterRadius?: number;
/** Colors for cluster circles: [small, medium, large] based on point count (default: ["#22c55e", "#eab308", "#ef4444"]) */
clusterColors?: [string, string, string];
/** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */
clusterThresholds?: [number, number];
/** Color for unclustered individual points (default: "#3b82f6") */
pointColor?: string;
/** Callback when an unclustered point is clicked */
onpointclick?: (
feature: GeoJSON.Feature<GeoJSON.Point, P>,
coordinates: [number, number]
) => void;
/** Callback when a cluster is clicked. If not provided, zooms into the cluster */
onclusterclick?: (clusterId: number, coordinates: [number, number], pointCount: number) => void;
}
let {
data,
clusterMaxZoom = 14,
clusterRadius = 50,
clusterColors = ["#22c55e", "#eab308", "#ef4444"],
clusterThresholds = [100, 750],
pointColor = "#3b82f6",
onpointclick,
onclusterclick,
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isStyleReady: () => boolean;
}>("map");
const id = crypto.randomUUID();
const sourceId = $derived(`cluster-source-${id}`);
const clusterLayerId = $derived(`clusters-${id}`);
const clusterCountLayerId = $derived(`cluster-count-${id}`);
const unclusteredLayerId = $derived(`unclustered-point-${id}`);
// Add source and layers when map is ready
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map) return;
// Remove existing layers and source if they exist
try {
if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId);
if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId);
if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
} catch {
// ignore
}
// Add clustered GeoJSON source
map.addSource(sourceId, {
type: "geojson",
data,
cluster: true,
clusterMaxZoom,
clusterRadius,
});
// Add cluster circles layer
map.addLayer({
id: clusterLayerId,
type: "circle",
source: sourceId,
filter: ["has", "point_count"],
paint: {
"circle-color": [
"step",
["get", "point_count"],
clusterColors[0],
clusterThresholds[0],
clusterColors[1],
clusterThresholds[1],
clusterColors[2],
],
"circle-radius": [
"step",
["get", "point_count"],
20,
clusterThresholds[0],
30,
clusterThresholds[1],
40,
],
"circle-stroke-width": 1,
"circle-stroke-color": "#fff",
"circle-opacity": 0.85,
},
});
// Add cluster count text layer
map.addLayer({
id: clusterCountLayerId,
type: "symbol",
source: sourceId,
filter: ["has", "point_count"],
layout: {
"text-field": "{point_count_abbreviated}",
"text-font": ["Open Sans"],
"text-size": 12,
},
paint: {
"text-color": "#fff",
},
});
// Add unclustered point layer
map.addLayer({
id: unclusteredLayerId,
type: "circle",
source: sourceId,
filter: ["!", ["has", "point_count"]],
paint: {
"circle-color": pointColor,
"circle-radius": 5,
"circle-stroke-width": 2,
"circle-stroke-color": "#fff",
},
});
return () => {
try {
if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId);
if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId);
if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
} catch {
// ignore
}
};
});
// Update source data when data prop changes (only for non-URL data)
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || typeof data === "string") return;
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
if (source) {
source.setData(data);
}
});
// Update layer styles when props change
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map) return;
// Update cluster layer colors and sizes
if (map.getLayer(clusterLayerId)) {
map.setPaintProperty(clusterLayerId, "circle-color", [
"step",
["get", "point_count"],
clusterColors[0],
clusterThresholds[0],
clusterColors[1],
clusterThresholds[1],
clusterColors[2],
]);
map.setPaintProperty(clusterLayerId, "circle-radius", [
"step",
["get", "point_count"],
20,
clusterThresholds[0],
30,
clusterThresholds[1],
40,
]);
}
// Update unclustered point layer color
if (map.getLayer(unclusteredLayerId)) {
map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor);
}
});
// Handle click events
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map) return;
// Cluster click handler - zoom into cluster
const handleClusterClick = async (
e: MapLibreGL.MapMouseEvent & {
features?: MapLibreGL.MapGeoJSONFeature[];
}
) => {
const features = map.queryRenderedFeatures(e.point, {
layers: [clusterLayerId],
});
if (!features.length) return;
const feature = features[0];
const clusterId = feature.properties?.cluster_id as number;
const pointCount = feature.properties?.point_count as number;
const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [number, number];
if (onclusterclick) {
onclusterclick(clusterId, coordinates, pointCount);
} else {
// Default behavior: zoom to cluster expansion zoom
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
const zoom = await source.getClusterExpansionZoom(clusterId);
map.easeTo({
center: coordinates,
zoom,
});
}
};
// Unclustered point click handler
const handlePointClick = (
e: MapLibreGL.MapMouseEvent & {
features?: MapLibreGL.MapGeoJSONFeature[];
}
) => {
if (!onpointclick || !e.features?.length) return;
const feature = e.features[0];
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [
number,
number,
];
// Handle world copies
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
onpointclick(feature as unknown as GeoJSON.Feature<GeoJSON.Point, P>, coordinates);
};
// Cursor style handlers
const handleMouseEnterCluster = () => {
map.getCanvas().style.cursor = "pointer";
};
const handleMouseLeaveCluster = () => {
map.getCanvas().style.cursor = "";
};
const handleMouseEnterPoint = () => {
if (onpointclick) {
map.getCanvas().style.cursor = "pointer";
}
};
const handleMouseLeavePoint = () => {
map.getCanvas().style.cursor = "";
};
map.on("click", clusterLayerId, handleClusterClick);
map.on("click", unclusteredLayerId, handlePointClick);
map.on("mouseenter", clusterLayerId, handleMouseEnterCluster);
map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster);
map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
return () => {
map.off("click", clusterLayerId, handleClusterClick);
map.off("click", unclusteredLayerId, handlePointClick);
map.off("mouseenter", clusterLayerId, handleMouseEnterCluster);
map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster);
map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
};
});
</script>
@@ -0,0 +1,212 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
import { cn } from "$lib/utils.js";
import Plus from "@lucide/svelte/icons/plus";
import Minus from "@lucide/svelte/icons/minus";
import Locate from "@lucide/svelte/icons/locate";
import Maximize from "@lucide/svelte/icons/maximize";
import Loader2 from "@lucide/svelte/icons/loader-2";
interface Props {
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
showZoom?: boolean;
showCompass?: boolean;
showLocate?: boolean;
showFullscreen?: boolean;
class?: string;
onlocate?: (coords: { longitude: number; latitude: number }) => void;
}
let {
position = "bottom-right",
showZoom = true,
showCompass = false,
showLocate = false,
showFullscreen = false,
class: className,
onlocate,
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isLoaded: () => boolean;
}>("map");
let waitingForLocation = $state(false);
let compassElement: SVGSVGElement | null = $state(null);
const loaded = $derived(mapCtx.isLoaded());
const positionClasses = {
"top-left": "top-2 left-2",
"top-right": "top-2 right-2",
"bottom-left": "bottom-2 left-2",
"bottom-right": "bottom-10 right-2",
};
// Update compass rotation
$effect(() => {
const map = mapCtx.getMap();
if (!loaded || !map || !compassElement) return;
const updateRotation = () => {
if (!compassElement) return;
const bearing = map.getBearing();
const pitch = map.getPitch();
compassElement.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;
};
map.on("rotate", updateRotation);
map.on("pitch", updateRotation);
updateRotation();
return () => {
map.off("rotate", updateRotation);
map.off("pitch", updateRotation);
};
});
function handleZoomIn() {
const map = mapCtx.getMap();
map?.zoomTo(map.getZoom() + 1, { duration: 300 });
}
function handleZoomOut() {
const map = mapCtx.getMap();
map?.zoomTo(map.getZoom() - 1, { duration: 300 });
}
function handleResetBearing() {
const map = mapCtx.getMap();
map?.resetNorthPitch({ duration: 300 });
}
function handleLocate() {
const map = mapCtx.getMap();
if (!map) return;
waitingForLocation = true;
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
const coords = {
longitude: position.coords.longitude,
latitude: position.coords.latitude,
};
map.flyTo({
center: [coords.longitude, coords.latitude],
zoom: 14,
duration: 1500,
});
onlocate?.(coords);
waitingForLocation = false;
},
(error) => {
console.error("Error getting location:", error);
waitingForLocation = false;
}
);
}
}
function handleFullscreen() {
const map = mapCtx.getMap();
const container = map?.getContainer();
if (!container) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
container.requestFullscreen();
}
}
</script>
{#if loaded}
<div class={cn("absolute z-10 flex flex-col gap-1.5", positionClasses[position], className)}>
{#if showZoom}
<div
class="border-border bg-background [&>button:not(:last-child)]:border-border flex flex-col overflow-hidden rounded-md border shadow-sm [&>button:not(:last-child)]:border-b"
>
<button
onclick={handleZoomIn}
aria-label="Zoom in"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
>
<Plus class="size-4" />
</button>
<button
onclick={handleZoomOut}
aria-label="Zoom out"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
>
<Minus class="size-4" />
</button>
</div>
{/if}
{#if showCompass}
<div
class="border-border bg-background flex flex-col overflow-hidden rounded-md border shadow-sm"
>
<button
onclick={handleResetBearing}
aria-label="Reset bearing to north"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
>
<svg
bind:this={compassElement}
viewBox="0 0 24 24"
class="size-5 transition-transform duration-200"
style="transform-style: preserve-3d;"
>
<path d="M12 2L16 12H12V2Z" class="fill-red-500" />
<path d="M12 2L8 12H12V2Z" class="fill-red-300" />
<path d="M12 22L16 12H12V22Z" class="fill-muted-foreground/60" />
<path d="M12 22L8 12H12V22Z" class="fill-muted-foreground/30" />
</svg>
</button>
</div>
{/if}
{#if showLocate}
<div
class="border-border bg-background flex flex-col overflow-hidden rounded-md border shadow-sm"
>
<button
onclick={handleLocate}
aria-label="Find my location"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
disabled={waitingForLocation}
>
{#if waitingForLocation}
<Loader2 class="size-4 animate-spin" />
{:else}
<Locate class="size-4" />
{/if}
</button>
</div>
{/if}
{#if showFullscreen}
<div
class="border-border bg-background flex flex-col overflow-hidden rounded-md border shadow-sm"
>
<button
onclick={handleFullscreen}
aria-label="Toggle fullscreen"
type="button"
class="hover:bg-accent dark:hover:bg-accent/40 focus-visible:ring-ring flex size-8 items-center justify-center transition-all first:rounded-t-md last:rounded-b-md focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50"
>
<Maximize class="size-4" />
</button>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,185 @@
<script lang="ts">
import { getContext, setContext, untrack } from "svelte";
import MapLibreGL, { type MarkerOptions } from "maplibre-gl";
type Anchor =
| "center"
| "top"
| "bottom"
| "left"
| "right"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
interface Props {
longitude: number;
latitude: number;
children?: import("svelte").Snippet;
onclick?: (e: MouseEvent) => void;
onmouseenter?: (e: MouseEvent) => void;
onmouseleave?: (e: MouseEvent) => void;
ondragstart?: (lngLat: { lng: number; lat: number }) => void;
ondrag?: (lngLat: { lng: number; lat: number }) => void;
ondragend?: (lngLat: { lng: number; lat: number }) => void;
draggable?: boolean;
anchor?: Anchor;
offset?: MarkerOptions["offset"];
rotation?: number;
pitchAlignment?: MarkerOptions["pitchAlignment"];
rotationAlignment?: MarkerOptions["rotationAlignment"];
}
let {
longitude,
latitude,
children,
onclick,
onmouseenter,
onmouseleave,
ondragstart,
ondrag,
ondragend,
draggable = false,
anchor = "center",
offset,
rotation,
pitchAlignment,
rotationAlignment,
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isLoaded: () => boolean;
}>("map");
let marker: MapLibreGL.Marker | null = $state(null);
let markerElement: HTMLDivElement | null = $state(null);
let isReady = $state(false);
let isDragging = $state(false);
// Provide marker context for child components
setContext("marker", {
getMarker: () => marker,
getElement: () => markerElement,
getMap: () => mapCtx.getMap(),
isReady: () => isReady,
isDraggable: () => draggable,
isDragging: () => isDragging,
});
// Create marker when map is ready
$effect(() => {
const map = mapCtx.getMap();
const mapLoaded = mapCtx.isLoaded();
if (!map || !mapLoaded) return;
// Validate coordinates (untracked — position updates are handled by a separate effect)
const lng = untrack(() => longitude);
const lat = untrack(() => latitude);
if (
typeof lng !== "number" ||
typeof lat !== "number" ||
Number.isNaN(lng) ||
Number.isNaN(lat)
) {
return;
}
// Create container element programmatically
const container = document.createElement("div");
container.className = "cursor-pointer";
markerElement = container;
// Build marker options
const markerOptions: MarkerOptions = {
element: container,
draggable,
anchor,
};
if (offset !== undefined) markerOptions.offset = offset;
if (rotation !== undefined) markerOptions.rotation = rotation;
if (pitchAlignment !== undefined) markerOptions.pitchAlignment = pitchAlignment;
if (rotationAlignment !== undefined) markerOptions.rotationAlignment = rotationAlignment;
// Create and add marker
const markerInstance = new MapLibreGL.Marker(markerOptions).setLngLat([lng, lat]).addTo(map);
marker = markerInstance;
// Mouse event listeners on the container
if (onclick) container.addEventListener("click", onclick);
if (onmouseenter) container.addEventListener("mouseenter", onmouseenter);
if (onmouseleave) {
container.addEventListener("mouseleave", (e) => {
if (!isDragging) onmouseleave(e);
});
}
// Drag event handlers
const handleDragStart = () => {
isDragging = true;
const lngLat = markerInstance.getLngLat();
ondragstart?.({ lng: lngLat.lng, lat: lngLat.lat });
};
const handleDrag = () => {
const lngLat = markerInstance.getLngLat();
ondrag?.({ lng: lngLat.lng, lat: lngLat.lat });
};
const handleDragEnd = () => {
isDragging = false;
const lngLat = markerInstance.getLngLat();
ondragend?.({ lng: lngLat.lng, lat: lngLat.lat });
};
if (draggable) {
markerInstance.on("dragstart", handleDragStart);
markerInstance.on("drag", handleDrag);
markerInstance.on("dragend", handleDragEnd);
}
isReady = true;
// Cleanup
return () => {
if (onclick) container.removeEventListener("click", onclick);
if (onmouseenter) container.removeEventListener("mouseenter", onmouseenter);
if (onmouseleave) container.removeEventListener("mouseleave", onmouseleave);
if (draggable) {
markerInstance.off("dragstart", handleDragStart);
markerInstance.off("drag", handleDrag);
markerInstance.off("dragend", handleDragEnd);
}
markerInstance.remove();
marker = null;
markerElement = null;
isReady = false;
};
});
// Update position when coordinates change
$effect(() => {
if (
marker &&
typeof longitude === "number" &&
typeof latitude === "number" &&
!Number.isNaN(longitude) &&
!Number.isNaN(latitude)
) {
marker.setLngLat([longitude, latitude]);
}
});
// Update draggable when prop changes
$effect(() => {
marker?.setDraggable(draggable);
});
</script>
<!-- Children are MarkerContent, MarkerPopup, MarkerTooltip -->
{@render children?.()}
@@ -0,0 +1,176 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL, { type PopupOptions } from "maplibre-gl";
import { cn } from "$lib/utils.js";
import X from "@lucide/svelte/icons/x";
interface Props {
longitude: number;
latitude: number;
children?: import("svelte").Snippet;
class?: string;
closeButton?: boolean;
onclose?: () => void;
offset?: PopupOptions["offset"];
anchor?: PopupOptions["anchor"];
closeOnClick?: boolean;
closeOnMove?: boolean;
focusAfterOpen?: boolean;
maxWidth?: string;
}
let {
longitude,
latitude,
children,
class: className,
closeButton = false,
onclose,
offset = 16,
anchor,
closeOnClick,
closeOnMove,
focusAfterOpen,
maxWidth,
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isLoaded: () => boolean;
}>("map");
const markerCtx =
getContext<{
isDraggable?: () => boolean;
}>("marker") || {};
let popup: MapLibreGL.Popup | null = null;
let wrapperElement: HTMLDivElement | null = $state(null);
// Create popup when map is ready
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isLoaded();
if (!loaded || !map || !wrapperElement) return;
// Validate coordinates
if (
typeof longitude !== "number" ||
typeof latitude !== "number" ||
Number.isNaN(longitude) ||
Number.isNaN(latitude)
) {
return;
}
// Create popup container
const container = document.createElement("div");
// Build popup options
const popupOptions: PopupOptions = {
offset,
closeButton: false,
className: "maplibre-popup-transparent",
};
// If marker is draggable, preserve popup state during movement
if (markerCtx.isDraggable?.()) {
popupOptions.closeOnMove = false;
}
if (anchor !== undefined) popupOptions.anchor = anchor;
if (closeOnClick !== undefined) popupOptions.closeOnClick = closeOnClick;
if (closeOnMove !== undefined) popupOptions.closeOnMove = closeOnMove;
if (focusAfterOpen !== undefined) popupOptions.focusAfterOpen = focusAfterOpen;
// Create popup
const popupInstance = new MapLibreGL.Popup(popupOptions)
.setDOMContent(container)
.setLngLat([longitude, latitude])
.addTo(map);
if (maxWidth) {
popupInstance.setMaxWidth(maxWidth);
} else {
popupInstance.setMaxWidth("none");
}
popup = popupInstance;
// Handle close event
const handleClose = () => onclose?.();
popupInstance.on("close", handleClose);
// Move content to popup container
while (wrapperElement.firstChild) {
container.appendChild(wrapperElement.firstChild);
}
return () => {
popupInstance.off("close", handleClose);
// Move content back
while (container.firstChild) {
wrapperElement?.appendChild(container.firstChild);
}
if (popupInstance.isOpen()) {
popupInstance.remove();
}
popup = null;
};
});
// Update position when coordinates change
$effect(() => {
if (
popup &&
typeof longitude === "number" &&
typeof latitude === "number" &&
!Number.isNaN(longitude) &&
!Number.isNaN(latitude)
) {
popup.setLngLat([longitude, latitude]);
}
});
function handleClose() {
popup?.remove();
onclose?.();
}
</script>
<div bind:this={wrapperElement} style="display: contents;">
<div
class={cn(
"bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md",
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
className
)}
>
{#if closeButton}
<button
type="button"
onclick={handleClose}
aria-label="Close popup"
class="focus-visible:ring-ring hover:bg-muted text-foreground absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm transition-colors focus:outline-none focus-visible:ring-2"
>
<X class="size-3.5" />
</button>
{/if}
{@render children?.()}
</div>
</div>
<style>
:global(.maplibre-popup-transparent .maplibregl-popup-content) {
background: transparent;
box-shadow: none;
padding: 0;
}
:global(.maplibre-popup-transparent .maplibregl-popup-tip) {
display: none;
}
</style>
@@ -0,0 +1,184 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
interface Props {
/** Optional unique identifier for the route layer */
id?: string;
/** Array of [longitude, latitude] coordinate pairs defining the route */
coordinates: [number, number][];
/** Line color as CSS color value (default: "#4285F4") */
color?: string;
/** Line width in pixels (default: 3) */
width?: number;
/** Line opacity from 0 to 1 (default: 0.8) */
opacity?: number;
/** Dash pattern [dash length, gap length] for dashed lines */
dashArray?: [number, number];
/** Callback when the route line is clicked */
onclick?: () => void;
/** Callback when mouse enters the route line */
onmouseenter?: () => void;
/** Callback when mouse leaves the route line */
onmouseleave?: () => void;
/** Whether the route is interactive - shows pointer cursor on hover (default: true) */
interactive?: boolean;
}
let {
coordinates,
color = "#4285F4",
width = 3,
opacity = 0.8,
dashArray,
onclick,
onmouseenter,
onmouseleave,
interactive = true,
id = crypto.randomUUID(),
}: Props = $props();
const mapCtx = getContext<{
getMap: () => MapLibreGL.Map | null;
isStyleReady: () => boolean;
}>("map");
const sourceId = $derived(`route-source-${id}`);
const layerId = $derived(`route-layer-${id}`);
// Add route when map is ready
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || coordinates.length < 2) return;
// Remove existing layer and source if they exist
if (map.getLayer(layerId)) map.removeLayer(layerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
// Add source
map.addSource(sourceId, {
type: "geojson",
data: {
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates,
},
},
});
// Build paint options with transition definitions
// Use default values here - they'll be updated by the paint property effect
const paint: MapLibreGL.LineLayerSpecification['paint'] = {
"line-color": "#94a3b8", // Start with gray (unselected color)
"line-width": 5, // Start with unselected width
"line-opacity": 0.6, // Start with unselected opacity
"line-color-transition": { duration: 300, delay: 0 },
"line-width-transition": { duration: 300, delay: 0 },
"line-opacity-transition": { duration: 300, delay: 0 },
};
if (dashArray) {
paint["line-dasharray"] = dashArray;
}
// Add layer
map.addLayer({
id: layerId,
type: "line",
source: sourceId,
layout: {
"line-join": "round",
"line-cap": "round",
},
paint,
});
return () => {
try {
if (map.getLayer(layerId)) map.removeLayer(layerId);
if (map.getSource(sourceId)) map.removeSource(sourceId);
} catch {
// Ignore errors during cleanup
}
};
});
// Update route data when coordinates change
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || coordinates.length < 2) return;
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
if (source) {
source.setData({
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates,
},
});
}
});
// Update paint properties when they change
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || !map.getLayer(layerId)) return;
map.setPaintProperty(layerId, "line-color", color);
map.setPaintProperty(layerId, "line-width", width);
map.setPaintProperty(layerId, "line-opacity", opacity);
if (dashArray) {
map.setPaintProperty(layerId, "line-dasharray", dashArray);
}
// Move selected routes to top (when opacity is 1, it's selected)
if (opacity === 1) {
try {
map.moveLayer(layerId);
} catch {
// Layer might not exist yet
}
}
});
// Handle click and hover events
$effect(() => {
const map = mapCtx.getMap();
const loaded = mapCtx.isStyleReady();
if (!loaded || !map || !interactive) return;
const handleClick = () => {
onclick?.();
};
const handleMouseEnter = () => {
map.getCanvas().style.cursor = "pointer";
onmouseenter?.();
};
const handleMouseLeave = () => {
map.getCanvas().style.cursor = "";
onmouseleave?.();
};
map.on("click", layerId, handleClick);
map.on("mouseenter", layerId, handleMouseEnter);
map.on("mouseleave", layerId, handleMouseLeave);
return () => {
map.off("click", layerId, handleClick);
map.off("mouseenter", layerId, handleMouseEnter);
map.off("mouseleave", layerId, handleMouseLeave);
};
});
</script>
@@ -0,0 +1,56 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
import { cn } from "$lib/utils.js";
interface Props {
children?: import("svelte").Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const markerCtx = getContext<{
getMarker: () => MapLibreGL.Marker | null;
getElement: () => HTMLDivElement | null;
getMap: () => MapLibreGL.Map | null;
isReady: () => boolean;
}>("marker");
let wrapperElement: HTMLDivElement | null = $state(null);
let movedContent: Node[] = [];
// Move content to marker element when ready
$effect(() => {
const element = markerCtx.getElement();
const ready = markerCtx.isReady();
if (!ready || !element || !wrapperElement) return;
// Store and move children to marker element
movedContent = Array.from(wrapperElement.childNodes);
movedContent.forEach((child) => element.appendChild(child));
return () => {
// Move content back on cleanup
movedContent.forEach((child) => {
if (wrapperElement && child.parentNode === element) {
wrapperElement.appendChild(child);
}
});
movedContent = [];
};
});
</script>
<!-- Hidden wrapper that holds content until marker is ready -->
<div bind:this={wrapperElement} style="display: contents;">
<div class={cn("relative cursor-pointer", className)}>
{#if children}
{@render children()}
{:else}
<!-- Default marker icon -->
<div class="relative h-4 w-4 rounded-full border-2 border-white bg-blue-500 shadow-lg"></div>
{/if}
</div>
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
interface Props {
children?: import("svelte").Snippet;
class?: string;
position?: "top" | "bottom";
}
let { children, class: className, position = "top" }: Props = $props();
const positionClasses = {
top: "bottom-full mb-1",
bottom: "top-full mt-1",
};
</script>
<div
class={cn(
"absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
"text-foreground text-[10px] font-medium",
positionClasses[position],
className
)}
>
{@render children?.()}
</div>
@@ -0,0 +1,159 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL, { type PopupOptions } from "maplibre-gl";
import { cn } from "$lib/utils.js";
import X from "@lucide/svelte/icons/x";
interface Props {
children?: import("svelte").Snippet;
class?: string;
closeButton?: boolean;
offset?: PopupOptions["offset"];
anchor?: PopupOptions["anchor"];
closeOnClick?: boolean;
closeOnMove?: boolean;
focusAfterOpen?: boolean;
maxWidth?: string;
}
let {
children,
class: className,
closeButton = false,
offset = 16,
anchor,
closeOnClick,
closeOnMove,
focusAfterOpen,
maxWidth,
}: Props = $props();
const markerCtx = getContext<{
getMarker: () => MapLibreGL.Marker | null;
getElement: () => HTMLDivElement | null;
getMap: () => MapLibreGL.Map | null;
isReady: () => boolean;
isDraggable?: () => boolean;
isDragging?: () => boolean;
}>("marker");
let popup: MapLibreGL.Popup | null = null;
let wrapperElement: HTMLDivElement | null = $state(null);
let shouldStayOpen = $state(false);
// Create popup when marker is ready
$effect(() => {
const marker = markerCtx.getMarker();
const ready = markerCtx.isReady();
if (!ready || !marker || !wrapperElement) return;
// Create popup container
const container = document.createElement("div");
// Build popup options
const popupOptions: PopupOptions = {
offset,
closeButton: false,
className: "maplibre-popup-transparent",
};
if (anchor !== undefined) popupOptions.anchor = anchor;
if (closeOnClick !== undefined) popupOptions.closeOnClick = closeOnClick;
if (closeOnMove !== undefined) popupOptions.closeOnMove = closeOnMove;
if (focusAfterOpen !== undefined) popupOptions.focusAfterOpen = focusAfterOpen;
// If marker is draggable, preserve popup state during movement
if (markerCtx.isDraggable?.()) {
popupOptions.closeOnMove = false;
}
// Create popup
const popupInstance = new MapLibreGL.Popup(popupOptions).setDOMContent(container);
if (maxWidth) {
popupInstance.setMaxWidth(maxWidth);
} else {
popupInstance.setMaxWidth("none");
}
// Attach popup to marker
marker.setPopup(popupInstance);
popup = popupInstance;
// Prevent popup from closing during drag
$effect(() => {
const isDragging = markerCtx.isDragging?.();
if (isDragging && popupInstance.isOpen()) {
shouldStayOpen = true;
}
});
// Reopen popup after drag if it was open
$effect(() => {
const isDragging = markerCtx.isDragging?.();
if (!isDragging && shouldStayOpen && !popupInstance.isOpen()) {
// Small delay to ensure popup has finished closing
setTimeout(() => {
if (!popupInstance.isOpen()) {
marker.togglePopup();
}
shouldStayOpen = false;
}, 10);
}
});
// Move content to popup container
while (wrapperElement.firstChild) {
container.appendChild(wrapperElement.firstChild);
}
return () => {
// Move content back
while (container.firstChild) {
wrapperElement?.appendChild(container.firstChild);
}
popupInstance.remove();
popup = null;
};
});
function handleClose() {
popup?.remove();
}
</script>
<div bind:this={wrapperElement} style="display: contents;">
<div
class={cn(
"bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md",
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
className
)}
>
{#if closeButton}
<button
type="button"
onclick={handleClose}
aria-label="Close popup"
class="focus-visible:ring-ring hover:bg-muted text-foreground absolute top-0.5 right-0.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm transition-colors focus:outline-none focus-visible:ring-2"
>
<X class="size-3.5" />
</button>
{/if}
{@render children?.()}
</div>
</div>
<style>
:global(.maplibre-popup-transparent .maplibregl-popup-content) {
background: transparent;
box-shadow: none;
padding: 0;
}
:global(.maplibre-popup-transparent .maplibregl-popup-tip) {
display: none;
}
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import { getContext } from "svelte";
import MapLibreGL, { type PopupOptions } from "maplibre-gl";
import { cn } from "$lib/utils.js";
interface Props {
children?: import("svelte").Snippet;
class?: string;
offset?: PopupOptions["offset"];
anchor?: PopupOptions["anchor"];
}
let { children, class: className, offset = 16, anchor }: Props = $props();
const markerCtx = getContext<{
getMarker: () => MapLibreGL.Marker | null;
getElement: () => HTMLDivElement | null;
getMap: () => MapLibreGL.Map | null;
isReady: () => boolean;
}>("marker");
let wrapperElement: HTMLDivElement | null = $state(null);
// Create tooltip popup when marker is ready
$effect(() => {
const marker = markerCtx.getMarker();
const markerElement = markerCtx.getElement();
const map = markerCtx.getMap();
const ready = markerCtx.isReady();
if (!ready || !marker || !markerElement || !map || !wrapperElement) return;
// Create popup container
const container = document.createElement("div");
// Build popup options
const popupOptions: PopupOptions = {
offset,
closeOnClick: true,
closeButton: false,
className: "maplibre-popup-transparent",
};
if (anchor !== undefined) popupOptions.anchor = anchor;
// Create popup
const popupInstance = new MapLibreGL.Popup(popupOptions)
.setMaxWidth("none")
.setDOMContent(container);
// Move content to popup container
while (wrapperElement.firstChild) {
container.appendChild(wrapperElement.firstChild);
}
// Show on hover
const handleMouseEnter = () => {
popupInstance.setLngLat(marker.getLngLat()).addTo(map);
};
const handleMouseLeave = () => {
popupInstance.remove();
};
markerElement.addEventListener("mouseenter", handleMouseEnter);
markerElement.addEventListener("mouseleave", handleMouseLeave);
return () => {
markerElement.removeEventListener("mouseenter", handleMouseEnter);
markerElement.removeEventListener("mouseleave", handleMouseLeave);
// Move content back
while (container.firstChild) {
wrapperElement?.appendChild(container.firstChild);
}
popupInstance.remove();
};
});
</script>
<div bind:this={wrapperElement} style="display: contents;">
<div
class={cn(
"bg-foreground text-background pointer-events-none rounded-md px-2 py-1 text-xs text-balance shadow-md",
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
className
)}
>
{@render children?.()}
</div>
</div>
<style>
:global(.maplibre-popup-transparent .maplibregl-popup-content) {
background: transparent;
box-shadow: none;
padding: 0;
}
:global(.maplibre-popup-transparent .maplibregl-popup-tip) {
display: none;
}
</style>
@@ -0,0 +1,13 @@
export { default as Map } from "./Map.svelte";
export type { MapViewport } from "./Map.svelte";
export { default as MapMarker } from "./MapMarker.svelte";
export { default as MarkerContent } from "./MarkerContent.svelte";
export { default as MarkerPopup } from "./MarkerPopup.svelte";
export { default as MarkerTooltip } from "./MarkerTooltip.svelte";
export { default as MarkerLabel } from "./MarkerLabel.svelte";
export { default as MapControls } from "./MapControls.svelte";
export { default as MapPopup } from "./MapPopup.svelte";
export { default as MapRoute } from "./MapRoute.svelte";
export { default as MapClusterLayer } from "./MapClusterLayer.svelte";
export { default as MapArc } from "./MapArc.svelte";
export type { MapArcDatum, MapArcEvent, MapArcProps } from "./MapArc.svelte";
@@ -0,0 +1,11 @@
export type MapTheme = "light" | "dark";
export function resolveMapTheme({
explicitTheme,
ambientTheme,
}: {
explicitTheme?: MapTheme;
ambientTheme: MapTheme;
}): MapTheme {
return explicitTheme ?? ambientTheme;
}
+28
View File
@@ -0,0 +1,28 @@
import { getContext } from "svelte";
import MapLibreGL from "maplibre-gl";
type MapContext = {
getMap: () => MapLibreGL.Map | null;
isLoaded: () => boolean;
isStyleReady: () => boolean;
};
export function useMap() {
const mapCtx = getContext<MapContext>("map");
const map = $derived.by(() => mapCtx?.getMap() ?? null);
const isLoaded = $derived.by(() => mapCtx?.isLoaded() ?? false);
const isStyleReady = $derived.by(() => mapCtx?.isStyleReady() ?? false);
return {
get map() {
return map;
},
get isLoaded() {
return isLoaded;
},
get isStyleReady() {
return isStyleReady;
},
};
}
+61 -31
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import ContactCard from '$lib/components/layout/ContactCard.svelte';
import { onMount } from 'svelte';
import { Map, MapMarker, MapControls, MarkerContent, MarkerPopup } from '$lib/components/ui/map';
import { onMount, tick } from 'svelte';
type ContactInfo = {
name: string;
@@ -24,40 +25,69 @@
} finally {
loading = false;
}
await tick();
window.dispatchEvent(new Event('resize'));
});
</script>
<section class="py-12">
<h1 class="mb-8 text-center text-2xl font-semibold">Contact Me</h1>
{#if loading}
<div class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6">
<div class="mb-4 flex justify-center">
<div class="h-24 w-24 rounded-full bg-gray-200"></div>
</div>
<div class="mb-4 text-center">
<div class="mx-auto mb-2 h-6 w-40 rounded bg-gray-200"></div>
<div class="mx-auto h-4 w-32 rounded bg-gray-200"></div>
</div>
<div class="mx-auto grid max-w-[744px] gap-6 px-4 lg:grid-cols-2">
<div class="h-[376px]">
{#if loading}
<div class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6">
<div class="mb-4 flex justify-center">
<div class="h-24 w-24 rounded-full bg-gray-200"></div>
</div>
<div class="mb-4 text-center">
<div class="mx-auto mb-2 h-6 w-40 rounded bg-gray-200"></div>
<div class="mx-auto h-4 w-32 rounded bg-gray-200"></div>
</div>
</div>
{:else if contact}
<ContactCard
name={contact.name}
role={contact.role}
phone={contact.phone}
email={contact.email}
instagram="crussell"
address="41 Pollock Walk, Dunfermline KY12 9DA"
profileImage={contact.profilePicUrl ||
'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png'}
/>
{:else}
<ContactCard
name="Chelsea Russell"
role="Owner / Beauty Specialist"
phone="+44 8008135"
email="chelsea@emailaddress.com"
instagram="crussell"
address="41 Pollock Walk, Dunfermline KY12 9DA"
/>
{/if}
</div>
{:else if contact}
<ContactCard
name={contact.name}
role={contact.role}
phone={contact.phone}
email={contact.email}
instagram="crussell"
address="Business Centre, Office Street, Work"
profileImage={contact.profilePicUrl ||
'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png'}
/>
{:else}
<ContactCard
name="Chelsea Russell"
role="Owner / Beauty Specialist"
phone="+44 8008135"
email="chelsea@emailaddress.com"
instagram="crussell"
address="Business Centre, Office Street, Work"
/>
{/if}
<div class="map-card mx-auto h-[376px] w-full max-w-sm rounded-lg border-2 border-gray-200 bg-white overflow-hidden lg:mx-0 lg:max-w-none">
<Map theme="light" center={[-3.476464162450991, 56.0781854944036]} zoom={15}>
<MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}>
<MarkerContent>
<div class="flex items-center justify-center rounded-full bg-primary p-2 text-primary-foreground shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</div>
</MarkerContent>
<MarkerPopup>
<div class="p-2 text-sm">
<p class="font-semibold">41 Pollock Walk</p>
<p class="text-muted-foreground">Dunfermline KY12 9DA</p>
</div>
</MarkerPopup>
</MapMarker>
<MapControls position="bottom-right" showZoom />
</Map>
</div>
</div>
</section>