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>
186 lines
4.8 KiB
Svelte
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?.()}
|