Files
Crussell/frontend/src/lib/components/ui/map/MapMarker.svelte
T
popertotsandSisyphus 4c26db81db 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>
2026-06-04 01:07:25 +01:00

186 lines
4.8 KiB
Svelte

<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?.()}