- Hero: Sub-Headline und CTA-Text niedrigschwelliger formuliert - Services: Konkretere, ergebnisorientierte Beschreibungen - Neue About-Section mit persönlichem Pull-Quote - Neue Process-Section als 4-Spalten-Grid (statt Services-Klon) - Packages: Feature-Listen, Profi-Paket hervorgehoben, neue Texte - Contact: Infos links, interaktive mapcn-Karte rechts (Crimmitschau) - Karte rot eingefärbt via sepia/hue-rotate CSS-Filter - Nav um "Ablauf"-Link ergänzt, fehlende IDs gesetzt - Kleinere Copy-Fixes (Opacity, leerer Span, Region konkretisiert) Co-authored-by: Cursor <cursoragent@cursor.com>
1845 lines
51 KiB
TypeScript
1845 lines
51 KiB
TypeScript
"use client";
|
|
|
|
import MapLibreGL, { type PopupOptions, type MarkerOptions } from "maplibre-gl";
|
|
import "maplibre-gl/dist/maplibre-gl.css";
|
|
import {
|
|
createContext,
|
|
forwardRef,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useId,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { X, Minus, Plus, Locate, Maximize, Loader2 } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
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",
|
|
};
|
|
|
|
type Theme = "light" | "dark";
|
|
|
|
// Check document class for theme (works with next-themes, etc.)
|
|
function getDocumentTheme(): Theme | 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(): Theme {
|
|
if (typeof window === "undefined") return "light";
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
? "dark"
|
|
: "light";
|
|
}
|
|
|
|
function useResolvedTheme(themeProp?: "light" | "dark"): Theme {
|
|
const [detectedTheme, setDetectedTheme] = useState<Theme>(
|
|
() => getDocumentTheme() ?? getSystemTheme(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (themeProp) return; // Skip detection if theme is provided via prop
|
|
|
|
// Watch for document class changes (e.g., next-themes toggling dark class)
|
|
const observer = new MutationObserver(() => {
|
|
const docTheme = getDocumentTheme();
|
|
if (docTheme) {
|
|
setDetectedTheme(docTheme);
|
|
}
|
|
});
|
|
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()) {
|
|
setDetectedTheme(e.matches ? "dark" : "light");
|
|
}
|
|
};
|
|
mediaQuery.addEventListener("change", handleSystemChange);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
mediaQuery.removeEventListener("change", handleSystemChange);
|
|
};
|
|
}, [themeProp]);
|
|
|
|
return themeProp ?? detectedTheme;
|
|
}
|
|
|
|
type MapContextValue = {
|
|
map: MapLibreGL.Map | null;
|
|
isLoaded: boolean;
|
|
};
|
|
|
|
const MapContext = createContext<MapContextValue | null>(null);
|
|
|
|
function useMap() {
|
|
const context = useContext(MapContext);
|
|
if (!context) {
|
|
throw new Error("useMap must be used within a Map component");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
/** Map viewport state */
|
|
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;
|
|
};
|
|
|
|
type MapStyleOption = string | MapLibreGL.StyleSpecification;
|
|
|
|
type MapRef = MapLibreGL.Map;
|
|
|
|
type MapProps = {
|
|
children?: ReactNode;
|
|
/** Additional CSS classes for the map container */
|
|
className?: string;
|
|
/**
|
|
* Theme for the map. If not provided, automatically detects system preference.
|
|
* Pass your theme value here.
|
|
*/
|
|
theme?: Theme;
|
|
/** Custom map styles for light and dark themes. Overrides the default Carto styles. */
|
|
styles?: {
|
|
light?: MapStyleOption;
|
|
dark?: MapStyleOption;
|
|
};
|
|
/** Map projection type. Use `{ type: "globe" }` for 3D globe view. */
|
|
projection?: MapLibreGL.ProjectionSpecification;
|
|
/**
|
|
* 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;
|
|
/** Show a loading indicator on the map */
|
|
loading?: boolean;
|
|
} & Omit<MapLibreGL.MapOptions, "container" | "style">;
|
|
|
|
function DefaultLoader() {
|
|
return (
|
|
<div className="bg-background/50 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-xs">
|
|
<div className="flex gap-1">
|
|
<span className="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full" />
|
|
<span className="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:150ms]" />
|
|
<span className="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:300ms]" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getViewport(map: MapLibreGL.Map): MapViewport {
|
|
const center = map.getCenter();
|
|
return {
|
|
center: [center.lng, center.lat],
|
|
zoom: map.getZoom(),
|
|
bearing: map.getBearing(),
|
|
pitch: map.getPitch(),
|
|
};
|
|
}
|
|
|
|
const Map = forwardRef<MapRef, MapProps>(function Map(
|
|
{
|
|
children,
|
|
className,
|
|
theme: themeProp,
|
|
styles,
|
|
projection,
|
|
viewport,
|
|
onViewportChange,
|
|
loading = false,
|
|
...props
|
|
},
|
|
ref,
|
|
) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [mapInstance, setMapInstance] = useState<MapLibreGL.Map | null>(null);
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
const [isStyleLoaded, setIsStyleLoaded] = useState(false);
|
|
const currentStyleRef = useRef<MapStyleOption | null>(null);
|
|
const styleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const internalUpdateRef = useRef(false);
|
|
const resolvedTheme = useResolvedTheme(themeProp);
|
|
|
|
const isControlled = viewport !== undefined && onViewportChange !== undefined;
|
|
|
|
const onViewportChangeRef = useRef(onViewportChange);
|
|
onViewportChangeRef.current = onViewportChange;
|
|
|
|
const mapStyles = useMemo(
|
|
() => ({
|
|
dark: styles?.dark ?? defaultStyles.dark,
|
|
light: styles?.light ?? defaultStyles.light,
|
|
}),
|
|
[styles],
|
|
);
|
|
|
|
// Expose the map instance to the parent component
|
|
useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);
|
|
|
|
const clearStyleTimeout = useCallback(() => {
|
|
if (styleTimeoutRef.current) {
|
|
clearTimeout(styleTimeoutRef.current);
|
|
styleTimeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
// Initialize the map
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const initialStyle =
|
|
resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
|
|
currentStyleRef.current = initialStyle;
|
|
|
|
const map = new MapLibreGL.Map({
|
|
container: containerRef.current,
|
|
style: initialStyle,
|
|
renderWorldCopies: false,
|
|
attributionControl: {
|
|
compact: true,
|
|
},
|
|
...props,
|
|
...viewport,
|
|
});
|
|
|
|
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
|
|
styleTimeoutRef.current = setTimeout(() => {
|
|
setIsStyleLoaded(true);
|
|
if (projection) {
|
|
map.setProjection(projection);
|
|
}
|
|
}, 100);
|
|
};
|
|
const loadHandler = () => setIsLoaded(true);
|
|
|
|
// Viewport change handler - skip if triggered by internal update
|
|
const handleMove = () => {
|
|
if (internalUpdateRef.current) return;
|
|
onViewportChangeRef.current?.(getViewport(map));
|
|
};
|
|
|
|
map.on("load", loadHandler);
|
|
map.on("styledata", styleDataHandler);
|
|
map.on("move", handleMove);
|
|
setMapInstance(map);
|
|
|
|
return () => {
|
|
clearStyleTimeout();
|
|
map.off("load", loadHandler);
|
|
map.off("styledata", styleDataHandler);
|
|
map.off("move", handleMove);
|
|
map.remove();
|
|
setIsLoaded(false);
|
|
setIsStyleLoaded(false);
|
|
setMapInstance(null);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Sync controlled viewport to map
|
|
useEffect(() => {
|
|
if (!mapInstance || !isControlled || !viewport) return;
|
|
if (mapInstance.isMoving()) return;
|
|
|
|
const current = getViewport(mapInstance);
|
|
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;
|
|
}
|
|
|
|
internalUpdateRef.current = true;
|
|
mapInstance.jumpTo(next);
|
|
internalUpdateRef.current = false;
|
|
}, [mapInstance, isControlled, viewport]);
|
|
|
|
// Handle style change
|
|
useEffect(() => {
|
|
if (!mapInstance || !resolvedTheme) return;
|
|
|
|
const newStyle =
|
|
resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
|
|
|
|
if (currentStyleRef.current === newStyle) return;
|
|
|
|
clearStyleTimeout();
|
|
currentStyleRef.current = newStyle;
|
|
setIsStyleLoaded(false);
|
|
|
|
mapInstance.setStyle(newStyle, { diff: true });
|
|
}, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]);
|
|
|
|
const contextValue = useMemo(
|
|
() => ({
|
|
map: mapInstance,
|
|
isLoaded: isLoaded && isStyleLoaded,
|
|
}),
|
|
[mapInstance, isLoaded, isStyleLoaded],
|
|
);
|
|
|
|
return (
|
|
<MapContext.Provider value={contextValue}>
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("relative h-full w-full", className)}
|
|
>
|
|
{(!isLoaded || loading) && <DefaultLoader />}
|
|
{/* SSR-safe: children render only when map is loaded on client */}
|
|
{mapInstance && children}
|
|
</div>
|
|
</MapContext.Provider>
|
|
);
|
|
});
|
|
|
|
type MarkerContextValue = {
|
|
marker: MapLibreGL.Marker;
|
|
map: MapLibreGL.Map | null;
|
|
};
|
|
|
|
const MarkerContext = createContext<MarkerContextValue | null>(null);
|
|
|
|
function useMarkerContext() {
|
|
const context = useContext(MarkerContext);
|
|
if (!context) {
|
|
throw new Error("Marker components must be used within MapMarker");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
type MapMarkerProps = {
|
|
/** Longitude coordinate for marker position */
|
|
longitude: number;
|
|
/** Latitude coordinate for marker position */
|
|
latitude: number;
|
|
/** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */
|
|
children: ReactNode;
|
|
/** Callback when marker is clicked */
|
|
onClick?: (e: MouseEvent) => void;
|
|
/** Callback when mouse enters marker */
|
|
onMouseEnter?: (e: MouseEvent) => void;
|
|
/** Callback when mouse leaves marker */
|
|
onMouseLeave?: (e: MouseEvent) => void;
|
|
/** Callback when marker drag starts (requires draggable: true) */
|
|
onDragStart?: (lngLat: { lng: number; lat: number }) => void;
|
|
/** Callback during marker drag (requires draggable: true) */
|
|
onDrag?: (lngLat: { lng: number; lat: number }) => void;
|
|
/** Callback when marker drag ends (requires draggable: true) */
|
|
onDragEnd?: (lngLat: { lng: number; lat: number }) => void;
|
|
} & Omit<MarkerOptions, "element">;
|
|
|
|
function MapMarker({
|
|
longitude,
|
|
latitude,
|
|
children,
|
|
onClick,
|
|
onMouseEnter,
|
|
onMouseLeave,
|
|
onDragStart,
|
|
onDrag,
|
|
onDragEnd,
|
|
draggable = false,
|
|
...markerOptions
|
|
}: MapMarkerProps) {
|
|
const { map } = useMap();
|
|
|
|
const callbacksRef = useRef({
|
|
onClick,
|
|
onMouseEnter,
|
|
onMouseLeave,
|
|
onDragStart,
|
|
onDrag,
|
|
onDragEnd,
|
|
});
|
|
callbacksRef.current = {
|
|
onClick,
|
|
onMouseEnter,
|
|
onMouseLeave,
|
|
onDragStart,
|
|
onDrag,
|
|
onDragEnd,
|
|
};
|
|
|
|
const marker = useMemo(() => {
|
|
const markerInstance = new MapLibreGL.Marker({
|
|
...markerOptions,
|
|
element: document.createElement("div"),
|
|
draggable,
|
|
}).setLngLat([longitude, latitude]);
|
|
|
|
const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);
|
|
const handleMouseEnter = (e: MouseEvent) =>
|
|
callbacksRef.current.onMouseEnter?.(e);
|
|
const handleMouseLeave = (e: MouseEvent) =>
|
|
callbacksRef.current.onMouseLeave?.(e);
|
|
|
|
markerInstance.getElement()?.addEventListener("click", handleClick);
|
|
markerInstance
|
|
.getElement()
|
|
?.addEventListener("mouseenter", handleMouseEnter);
|
|
markerInstance
|
|
.getElement()
|
|
?.addEventListener("mouseleave", handleMouseLeave);
|
|
|
|
const handleDragStart = () => {
|
|
const lngLat = markerInstance.getLngLat();
|
|
callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });
|
|
};
|
|
const handleDrag = () => {
|
|
const lngLat = markerInstance.getLngLat();
|
|
callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });
|
|
};
|
|
const handleDragEnd = () => {
|
|
const lngLat = markerInstance.getLngLat();
|
|
callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });
|
|
};
|
|
|
|
markerInstance.on("dragstart", handleDragStart);
|
|
markerInstance.on("drag", handleDrag);
|
|
markerInstance.on("dragend", handleDragEnd);
|
|
|
|
return markerInstance;
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
marker.addTo(map);
|
|
|
|
return () => {
|
|
marker.remove();
|
|
};
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [map]);
|
|
|
|
if (
|
|
marker.getLngLat().lng !== longitude ||
|
|
marker.getLngLat().lat !== latitude
|
|
) {
|
|
marker.setLngLat([longitude, latitude]);
|
|
}
|
|
if (marker.isDraggable() !== draggable) {
|
|
marker.setDraggable(draggable);
|
|
}
|
|
|
|
const currentOffset = marker.getOffset();
|
|
const newOffset = markerOptions.offset ?? [0, 0];
|
|
const [newOffsetX, newOffsetY] = Array.isArray(newOffset)
|
|
? newOffset
|
|
: [newOffset.x, newOffset.y];
|
|
if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {
|
|
marker.setOffset(newOffset);
|
|
}
|
|
|
|
if (marker.getRotation() !== markerOptions.rotation) {
|
|
marker.setRotation(markerOptions.rotation ?? 0);
|
|
}
|
|
if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) {
|
|
marker.setRotationAlignment(markerOptions.rotationAlignment ?? "auto");
|
|
}
|
|
if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) {
|
|
marker.setPitchAlignment(markerOptions.pitchAlignment ?? "auto");
|
|
}
|
|
|
|
return (
|
|
<MarkerContext.Provider value={{ marker, map }}>
|
|
{children}
|
|
</MarkerContext.Provider>
|
|
);
|
|
}
|
|
|
|
type MarkerContentProps = {
|
|
/** Custom marker content. Defaults to a blue dot if not provided */
|
|
children?: ReactNode;
|
|
/** Additional CSS classes for the marker container */
|
|
className?: string;
|
|
};
|
|
|
|
function MarkerContent({ children, className }: MarkerContentProps) {
|
|
const { marker } = useMarkerContext();
|
|
|
|
return createPortal(
|
|
<div className={cn("relative cursor-pointer", className)}>
|
|
{children || <DefaultMarkerIcon />}
|
|
</div>,
|
|
marker.getElement(),
|
|
);
|
|
}
|
|
|
|
function DefaultMarkerIcon() {
|
|
return (
|
|
<div className="relative h-4 w-4 rounded-full border-2 border-white bg-blue-500 shadow-lg" />
|
|
);
|
|
}
|
|
|
|
function PopupCloseButton({ onClick }: { onClick: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label="Close popup"
|
|
className="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 className="size-3.5" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
type MarkerPopupProps = {
|
|
/** Popup content */
|
|
children: ReactNode;
|
|
/** Additional CSS classes for the popup container */
|
|
className?: string;
|
|
/** Show a close button in the popup (default: false) */
|
|
closeButton?: boolean;
|
|
} & Omit<PopupOptions, "className" | "closeButton">;
|
|
|
|
function MarkerPopup({
|
|
children,
|
|
className,
|
|
closeButton = false,
|
|
...popupOptions
|
|
}: MarkerPopupProps) {
|
|
const { marker, map } = useMarkerContext();
|
|
const container = useMemo(() => document.createElement("div"), []);
|
|
const prevPopupOptions = useRef(popupOptions);
|
|
|
|
const popup = useMemo(() => {
|
|
const popupInstance = new MapLibreGL.Popup({
|
|
offset: 16,
|
|
...popupOptions,
|
|
closeButton: false,
|
|
})
|
|
.setMaxWidth("none")
|
|
.setDOMContent(container);
|
|
|
|
return popupInstance;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
popup.setDOMContent(container);
|
|
marker.setPopup(popup);
|
|
|
|
return () => {
|
|
marker.setPopup(null);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [map]);
|
|
|
|
if (popup.isOpen()) {
|
|
const prev = prevPopupOptions.current;
|
|
|
|
if (prev.offset !== popupOptions.offset) {
|
|
popup.setOffset(popupOptions.offset ?? 16);
|
|
}
|
|
if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {
|
|
popup.setMaxWidth(popupOptions.maxWidth ?? "none");
|
|
}
|
|
|
|
prevPopupOptions.current = popupOptions;
|
|
}
|
|
|
|
const handleClose = () => popup.remove();
|
|
|
|
return createPortal(
|
|
<div
|
|
className={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,
|
|
)}
|
|
>
|
|
{closeButton && <PopupCloseButton onClick={handleClose} />}
|
|
{children}
|
|
</div>,
|
|
container,
|
|
);
|
|
}
|
|
|
|
type MarkerTooltipProps = {
|
|
/** Tooltip content */
|
|
children: ReactNode;
|
|
/** Additional CSS classes for the tooltip container */
|
|
className?: string;
|
|
} & Omit<PopupOptions, "className" | "closeButton" | "closeOnClick">;
|
|
|
|
function MarkerTooltip({
|
|
children,
|
|
className,
|
|
...popupOptions
|
|
}: MarkerTooltipProps) {
|
|
const { marker, map } = useMarkerContext();
|
|
const container = useMemo(() => document.createElement("div"), []);
|
|
const prevTooltipOptions = useRef(popupOptions);
|
|
|
|
const tooltip = useMemo(() => {
|
|
const tooltipInstance = new MapLibreGL.Popup({
|
|
offset: 16,
|
|
...popupOptions,
|
|
closeOnClick: true,
|
|
closeButton: false,
|
|
}).setMaxWidth("none");
|
|
|
|
return tooltipInstance;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
tooltip.setDOMContent(container);
|
|
|
|
const handleMouseEnter = () => {
|
|
tooltip.setLngLat(marker.getLngLat()).addTo(map);
|
|
};
|
|
const handleMouseLeave = () => tooltip.remove();
|
|
|
|
marker.getElement()?.addEventListener("mouseenter", handleMouseEnter);
|
|
marker.getElement()?.addEventListener("mouseleave", handleMouseLeave);
|
|
|
|
return () => {
|
|
marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter);
|
|
marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave);
|
|
tooltip.remove();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [map]);
|
|
|
|
if (tooltip.isOpen()) {
|
|
const prev = prevTooltipOptions.current;
|
|
|
|
if (prev.offset !== popupOptions.offset) {
|
|
tooltip.setOffset(popupOptions.offset ?? 16);
|
|
}
|
|
if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {
|
|
tooltip.setMaxWidth(popupOptions.maxWidth ?? "none");
|
|
}
|
|
|
|
prevTooltipOptions.current = popupOptions;
|
|
}
|
|
|
|
return createPortal(
|
|
<div
|
|
className={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,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>,
|
|
container,
|
|
);
|
|
}
|
|
|
|
type MarkerLabelProps = {
|
|
/** Label text content */
|
|
children: ReactNode;
|
|
/** Additional CSS classes for the label */
|
|
className?: string;
|
|
/** Position of the label relative to the marker (default: "top") */
|
|
position?: "top" | "bottom";
|
|
};
|
|
|
|
function MarkerLabel({
|
|
children,
|
|
className,
|
|
position = "top",
|
|
}: MarkerLabelProps) {
|
|
const positionClasses = {
|
|
top: "bottom-full mb-1",
|
|
bottom: "top-full mt-1",
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
|
|
"text-foreground text-[10px] font-medium",
|
|
positionClasses[position],
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type MapControlsProps = {
|
|
/** Position of the controls on the map (default: "bottom-right") */
|
|
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
/** Show zoom in/out buttons (default: true) */
|
|
showZoom?: boolean;
|
|
/** Show compass button to reset bearing (default: false) */
|
|
showCompass?: boolean;
|
|
/** Show locate button to find user's location (default: false) */
|
|
showLocate?: boolean;
|
|
/** Show fullscreen toggle button (default: false) */
|
|
showFullscreen?: boolean;
|
|
/** Additional CSS classes for the controls container */
|
|
className?: string;
|
|
/** Callback with user coordinates when located */
|
|
onLocate?: (coords: { longitude: number; latitude: number }) => void;
|
|
};
|
|
|
|
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",
|
|
};
|
|
|
|
function ControlGroup({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="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">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ControlButton({
|
|
onClick,
|
|
label,
|
|
children,
|
|
disabled = false,
|
|
}: {
|
|
onClick: () => void;
|
|
label: string;
|
|
children: React.ReactNode;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
type="button"
|
|
className={cn(
|
|
"flex size-8 items-center justify-center transition-all",
|
|
"first:rounded-t-md last:rounded-b-md",
|
|
"hover:bg-accent dark:hover:bg-accent/40",
|
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
|
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
)}
|
|
disabled={disabled}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function MapControls({
|
|
position = "bottom-right",
|
|
showZoom = true,
|
|
showCompass = false,
|
|
showLocate = false,
|
|
showFullscreen = false,
|
|
className,
|
|
onLocate,
|
|
}: MapControlsProps) {
|
|
const { map } = useMap();
|
|
const [waitingForLocation, setWaitingForLocation] = useState(false);
|
|
|
|
const handleZoomIn = useCallback(() => {
|
|
map?.zoomTo(map.getZoom() + 1, { duration: 300 });
|
|
}, [map]);
|
|
|
|
const handleZoomOut = useCallback(() => {
|
|
map?.zoomTo(map.getZoom() - 1, { duration: 300 });
|
|
}, [map]);
|
|
|
|
const handleResetBearing = useCallback(() => {
|
|
map?.resetNorthPitch({ duration: 300 });
|
|
}, [map]);
|
|
|
|
const handleLocate = useCallback(() => {
|
|
setWaitingForLocation(true);
|
|
if ("geolocation" in navigator) {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
const coords = {
|
|
longitude: pos.coords.longitude,
|
|
latitude: pos.coords.latitude,
|
|
};
|
|
map?.flyTo({
|
|
center: [coords.longitude, coords.latitude],
|
|
zoom: 14,
|
|
duration: 1500,
|
|
});
|
|
onLocate?.(coords);
|
|
setWaitingForLocation(false);
|
|
},
|
|
(error) => {
|
|
console.error("Error getting location:", error);
|
|
setWaitingForLocation(false);
|
|
},
|
|
);
|
|
}
|
|
}, [map, onLocate]);
|
|
|
|
const handleFullscreen = useCallback(() => {
|
|
const container = map?.getContainer();
|
|
if (!container) return;
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
} else {
|
|
container.requestFullscreen();
|
|
}
|
|
}, [map]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"absolute z-10 flex flex-col gap-1.5",
|
|
positionClasses[position],
|
|
className,
|
|
)}
|
|
>
|
|
{showZoom && (
|
|
<ControlGroup>
|
|
<ControlButton onClick={handleZoomIn} label="Zoom in">
|
|
<Plus className="size-4" />
|
|
</ControlButton>
|
|
<ControlButton onClick={handleZoomOut} label="Zoom out">
|
|
<Minus className="size-4" />
|
|
</ControlButton>
|
|
</ControlGroup>
|
|
)}
|
|
{showCompass && (
|
|
<ControlGroup>
|
|
<CompassButton onClick={handleResetBearing} />
|
|
</ControlGroup>
|
|
)}
|
|
{showLocate && (
|
|
<ControlGroup>
|
|
<ControlButton
|
|
onClick={handleLocate}
|
|
label="Find my location"
|
|
disabled={waitingForLocation}
|
|
>
|
|
{waitingForLocation ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : (
|
|
<Locate className="size-4" />
|
|
)}
|
|
</ControlButton>
|
|
</ControlGroup>
|
|
)}
|
|
{showFullscreen && (
|
|
<ControlGroup>
|
|
<ControlButton onClick={handleFullscreen} label="Toggle fullscreen">
|
|
<Maximize className="size-4" />
|
|
</ControlButton>
|
|
</ControlGroup>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CompassButton({ onClick }: { onClick: () => void }) {
|
|
const { map } = useMap();
|
|
const compassRef = useRef<SVGSVGElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!map || !compassRef.current) return;
|
|
|
|
const compass = compassRef.current;
|
|
|
|
const updateRotation = () => {
|
|
const bearing = map.getBearing();
|
|
const pitch = map.getPitch();
|
|
compass.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);
|
|
};
|
|
}, [map]);
|
|
|
|
return (
|
|
<ControlButton onClick={onClick} label="Reset bearing to north">
|
|
<svg
|
|
ref={compassRef}
|
|
viewBox="0 0 24 24"
|
|
className="size-5 transition-transform duration-200"
|
|
style={{ transformStyle: "preserve-3d" }}
|
|
>
|
|
<path d="M12 2L16 12H12V2Z" className="fill-red-500" />
|
|
<path d="M12 2L8 12H12V2Z" className="fill-red-300" />
|
|
<path d="M12 22L16 12H12V22Z" className="fill-muted-foreground/60" />
|
|
<path d="M12 22L8 12H12V22Z" className="fill-muted-foreground/30" />
|
|
</svg>
|
|
</ControlButton>
|
|
);
|
|
}
|
|
|
|
type MapPopupProps = {
|
|
/** Longitude coordinate for popup position */
|
|
longitude: number;
|
|
/** Latitude coordinate for popup position */
|
|
latitude: number;
|
|
/** Callback when popup is closed */
|
|
onClose?: () => void;
|
|
/** Popup content */
|
|
children: ReactNode;
|
|
/** Additional CSS classes for the popup container */
|
|
className?: string;
|
|
/** Show a close button in the popup (default: false) */
|
|
closeButton?: boolean;
|
|
} & Omit<PopupOptions, "className" | "closeButton">;
|
|
|
|
function MapPopup({
|
|
longitude,
|
|
latitude,
|
|
onClose,
|
|
children,
|
|
className,
|
|
closeButton = false,
|
|
...popupOptions
|
|
}: MapPopupProps) {
|
|
const { map } = useMap();
|
|
const popupOptionsRef = useRef(popupOptions);
|
|
const onCloseRef = useRef(onClose);
|
|
onCloseRef.current = onClose;
|
|
const container = useMemo(() => document.createElement("div"), []);
|
|
|
|
const popup = useMemo(() => {
|
|
const popupInstance = new MapLibreGL.Popup({
|
|
offset: 16,
|
|
...popupOptions,
|
|
closeButton: false,
|
|
})
|
|
.setMaxWidth("none")
|
|
.setLngLat([longitude, latitude]);
|
|
|
|
return popupInstance;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
|
|
const onCloseProp = () => onCloseRef.current?.();
|
|
|
|
popup.on("close", onCloseProp);
|
|
|
|
popup.setDOMContent(container);
|
|
popup.addTo(map);
|
|
|
|
return () => {
|
|
popup.off("close", onCloseProp);
|
|
if (popup.isOpen()) {
|
|
popup.remove();
|
|
}
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [map]);
|
|
|
|
if (popup.isOpen()) {
|
|
const prev = popupOptionsRef.current;
|
|
|
|
if (
|
|
popup.getLngLat().lng !== longitude ||
|
|
popup.getLngLat().lat !== latitude
|
|
) {
|
|
popup.setLngLat([longitude, latitude]);
|
|
}
|
|
|
|
if (prev.offset !== popupOptions.offset) {
|
|
popup.setOffset(popupOptions.offset ?? 16);
|
|
}
|
|
if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {
|
|
popup.setMaxWidth(popupOptions.maxWidth ?? "none");
|
|
}
|
|
popupOptionsRef.current = popupOptions;
|
|
}
|
|
|
|
const handleClose = () => {
|
|
popup.remove();
|
|
};
|
|
|
|
return createPortal(
|
|
<div
|
|
className={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,
|
|
)}
|
|
>
|
|
{closeButton && <PopupCloseButton onClick={handleClose} />}
|
|
{children}
|
|
</div>,
|
|
container,
|
|
);
|
|
}
|
|
|
|
type MapRouteProps = {
|
|
/** 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;
|
|
};
|
|
|
|
function MapRoute({
|
|
id: propId,
|
|
coordinates,
|
|
color = "#4285F4",
|
|
width = 3,
|
|
opacity = 0.8,
|
|
dashArray,
|
|
onClick,
|
|
onMouseEnter,
|
|
onMouseLeave,
|
|
interactive = true,
|
|
}: MapRouteProps) {
|
|
const { map, isLoaded } = useMap();
|
|
const autoId = useId();
|
|
const id = propId ?? autoId;
|
|
const sourceId = `route-source-${id}`;
|
|
const layerId = `route-layer-${id}`;
|
|
|
|
// Add source and layer on mount
|
|
useEffect(() => {
|
|
if (!isLoaded || !map) return;
|
|
|
|
map.addSource(sourceId, {
|
|
type: "geojson",
|
|
data: {
|
|
type: "Feature",
|
|
properties: {},
|
|
geometry: { type: "LineString", coordinates: [] },
|
|
},
|
|
});
|
|
|
|
map.addLayer({
|
|
id: layerId,
|
|
type: "line",
|
|
source: sourceId,
|
|
layout: { "line-join": "round", "line-cap": "round" },
|
|
paint: {
|
|
"line-color": color,
|
|
"line-width": width,
|
|
"line-opacity": opacity,
|
|
...(dashArray && { "line-dasharray": dashArray }),
|
|
},
|
|
});
|
|
|
|
return () => {
|
|
try {
|
|
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
|
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isLoaded, map]);
|
|
|
|
// When coordinates change, update the source data
|
|
useEffect(() => {
|
|
if (!isLoaded || !map || coordinates.length < 2) return;
|
|
|
|
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
|
|
if (source) {
|
|
source.setData({
|
|
type: "Feature",
|
|
properties: {},
|
|
geometry: { type: "LineString", coordinates },
|
|
});
|
|
}
|
|
}, [isLoaded, map, coordinates, sourceId]);
|
|
|
|
useEffect(() => {
|
|
if (!isLoaded || !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);
|
|
}
|
|
}, [isLoaded, map, layerId, color, width, opacity, dashArray]);
|
|
|
|
// Handle click and hover events
|
|
useEffect(() => {
|
|
if (!isLoaded || !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);
|
|
};
|
|
}, [
|
|
isLoaded,
|
|
map,
|
|
layerId,
|
|
onClick,
|
|
onMouseEnter,
|
|
onMouseLeave,
|
|
interactive,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/** A single arc to render inside <MapArc data={...}>. */
|
|
type MapArcDatum = {
|
|
/** Unique identifier for this arc. Required for hover state tracking and event payloads. */
|
|
id: string | number;
|
|
/** Start coordinate as [longitude, latitude]. */
|
|
from: [number, number];
|
|
/** End coordinate as [longitude, latitude]. */
|
|
to: [number, number];
|
|
};
|
|
|
|
/** Event payload passed to MapArc interaction callbacks. */
|
|
type MapArcEvent<T extends MapArcDatum = MapArcDatum> = {
|
|
/** The arc datum that was hovered or clicked. */
|
|
arc: T;
|
|
/** Longitude of the cursor at the time of the event. */
|
|
longitude: number;
|
|
/** Latitude of the cursor at the time of the event. */
|
|
latitude: number;
|
|
/** The underlying MapLibre mouse event for advanced use cases. */
|
|
originalEvent: MapLibreGL.MapMouseEvent;
|
|
};
|
|
|
|
type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
|
|
type MapArcLineLayout = NonNullable<
|
|
MapLibreGL.LineLayerSpecification["layout"]
|
|
>;
|
|
|
|
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. Auto-generated if not provided. */
|
|
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. Arcs are computed as a quadratic Bézier in lng/lat space and do not
|
|
* account for the antimeridian. (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. Merged on top of sensible
|
|
* defaults (`line-color: #4285F4`, `line-width: 2`, `line-opacity: 0.85`).
|
|
* Any value can be a MapLibre expression for per-feature styling, every
|
|
* field on each arc datum (besides `from`/`to`) is exposed via `["get", ...]`.
|
|
*/
|
|
paint?: MapArcLinePaint;
|
|
/** MapLibre layout properties for the arc layer. Defaults to rounded joins/caps. */
|
|
layout?: MapArcLineLayout;
|
|
/**
|
|
* Paint properties applied to the arc currently under the cursor. Each key
|
|
* is merged into `paint` as a `case` expression keyed on per-feature hover
|
|
* state, so only the hovered arc changes appearance.
|
|
*/
|
|
hoverPaint?: MapArcLinePaint;
|
|
/** Callback when an arc is clicked. */
|
|
onClick?: (e: MapArcEvent<T>) => void;
|
|
/**
|
|
* Callback fired when the hovered arc changes. Receives the cursor's
|
|
* lng/lat at the moment of entry, and `null` when the cursor leaves the
|
|
* last hovered arc.
|
|
*/
|
|
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 (z-order control). */
|
|
beforeId?: string;
|
|
};
|
|
|
|
const DEFAULT_ARC_CURVATURE = 0.2;
|
|
const DEFAULT_ARC_SAMPLES = 64;
|
|
const ARC_HIT_MIN_WIDTH = 12;
|
|
const ARC_HIT_PADDING = 6;
|
|
|
|
const DEFAULT_ARC_PAINT: MapArcLinePaint = {
|
|
"line-color": "#4285F4",
|
|
"line-width": 2,
|
|
"line-opacity": 0.85,
|
|
};
|
|
|
|
const DEFAULT_ARC_LAYOUT: MapArcLineLayout = {
|
|
"line-join": "round",
|
|
"line-cap": "round",
|
|
};
|
|
|
|
function mergeArcPaint(
|
|
paint: MapArcLinePaint,
|
|
hoverPaint: MapArcLinePaint | undefined,
|
|
): MapArcLinePaint {
|
|
if (!hoverPaint) return paint;
|
|
const merged: Record<string, unknown> = { ...paint };
|
|
for (const [key, hoverValue] of Object.entries(hoverPaint)) {
|
|
if (hoverValue === undefined) continue;
|
|
const baseValue = merged[key];
|
|
merged[key] =
|
|
baseValue === undefined
|
|
? hoverValue
|
|
: [
|
|
"case",
|
|
["boolean", ["feature-state", "hover"], false],
|
|
hoverValue,
|
|
baseValue,
|
|
];
|
|
}
|
|
return merged as MapArcLinePaint;
|
|
}
|
|
|
|
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 += 1) {
|
|
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 MapArc<T extends MapArcDatum = MapArcDatum>({
|
|
data,
|
|
id: propId,
|
|
curvature = DEFAULT_ARC_CURVATURE,
|
|
samples = DEFAULT_ARC_SAMPLES,
|
|
paint,
|
|
layout,
|
|
hoverPaint,
|
|
onClick,
|
|
onHover,
|
|
interactive = true,
|
|
beforeId,
|
|
}: MapArcProps<T>) {
|
|
const { map, isLoaded } = useMap();
|
|
const autoId = useId();
|
|
const id = propId ?? autoId;
|
|
const sourceId = `arc-source-${id}`;
|
|
const layerId = `arc-layer-${id}`;
|
|
const hitLayerId = `arc-hit-layer-${id}`;
|
|
|
|
const mergedPaint = useMemo(
|
|
() => mergeArcPaint({ ...DEFAULT_ARC_PAINT, ...paint }, hoverPaint),
|
|
[paint, hoverPaint],
|
|
);
|
|
const mergedLayout = useMemo(
|
|
() => ({ ...DEFAULT_ARC_LAYOUT, ...layout }),
|
|
[layout],
|
|
);
|
|
|
|
const hitWidth = useMemo(() => {
|
|
const w = paint?.["line-width"] ?? DEFAULT_ARC_PAINT["line-width"];
|
|
const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH;
|
|
return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
|
|
}, [paint]);
|
|
|
|
const geoJSON = useMemo<GeoJSON.FeatureCollection<GeoJSON.LineString>>(
|
|
() => ({
|
|
type: "FeatureCollection",
|
|
features: data.map((arc) => {
|
|
const { from, to, ...properties } = arc;
|
|
return {
|
|
type: "Feature",
|
|
properties,
|
|
geometry: {
|
|
type: "LineString",
|
|
coordinates: buildArcCoordinates(from, to, curvature, samples),
|
|
},
|
|
};
|
|
}),
|
|
}),
|
|
[data, curvature, samples],
|
|
);
|
|
|
|
const latestRef = useRef({ data, onClick, onHover });
|
|
latestRef.current = { data, onClick, onHover };
|
|
|
|
// Add source and layers on mount.
|
|
useEffect(() => {
|
|
if (!isLoaded || !map) return;
|
|
|
|
map.addSource(sourceId, {
|
|
type: "geojson",
|
|
data: geoJSON,
|
|
promoteId: "id",
|
|
});
|
|
|
|
map.addLayer(
|
|
{
|
|
id: hitLayerId,
|
|
type: "line",
|
|
source: sourceId,
|
|
layout: DEFAULT_ARC_LAYOUT,
|
|
paint: {
|
|
"line-color": "rgba(0, 0, 0, 0)",
|
|
"line-width": hitWidth,
|
|
"line-opacity": 1,
|
|
},
|
|
},
|
|
beforeId,
|
|
);
|
|
|
|
map.addLayer(
|
|
{
|
|
id: layerId,
|
|
type: "line",
|
|
source: sourceId,
|
|
layout: mergedLayout,
|
|
paint: mergedPaint,
|
|
},
|
|
beforeId,
|
|
);
|
|
|
|
return () => {
|
|
try {
|
|
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
|
if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId);
|
|
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isLoaded, map]);
|
|
|
|
// Sync features when data / curvature / samples change.
|
|
useEffect(() => {
|
|
if (!isLoaded || !map) return;
|
|
const source = map.getSource(sourceId) as
|
|
| MapLibreGL.GeoJSONSource
|
|
| undefined;
|
|
source?.setData(geoJSON);
|
|
}, [isLoaded, map, geoJSON, sourceId]);
|
|
|
|
// Sync paint/layout when they change.
|
|
useEffect(() => {
|
|
if (!isLoaded || !map || !map.getLayer(layerId)) return;
|
|
for (const [key, value] of Object.entries(mergedPaint)) {
|
|
map.setPaintProperty(
|
|
layerId,
|
|
key as keyof MapArcLinePaint,
|
|
value as never,
|
|
);
|
|
}
|
|
for (const [key, value] of Object.entries(mergedLayout)) {
|
|
map.setLayoutProperty(
|
|
layerId,
|
|
key as keyof MapArcLineLayout,
|
|
value as never,
|
|
);
|
|
}
|
|
if (map.getLayer(hitLayerId)) {
|
|
map.setPaintProperty(hitLayerId, "line-width", hitWidth);
|
|
}
|
|
}, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth]);
|
|
|
|
// Interaction handlers
|
|
useEffect(() => {
|
|
if (!isLoaded || !map || !interactive) return;
|
|
|
|
let hoveredId: string | number | null = null;
|
|
|
|
const setHover = (next: string | number | null) => {
|
|
if (next === hoveredId) return;
|
|
const sourceExists = !!map.getSource(sourceId);
|
|
if (hoveredId != null && sourceExists) {
|
|
map.setFeatureState(
|
|
{ source: sourceId, id: hoveredId },
|
|
{ hover: false },
|
|
);
|
|
}
|
|
hoveredId = next;
|
|
if (next != null && sourceExists) {
|
|
map.setFeatureState({ source: sourceId, id: next }, { hover: true });
|
|
}
|
|
};
|
|
|
|
const findArc = (featureId: string | number | undefined) =>
|
|
featureId == null
|
|
? undefined
|
|
: latestRef.current.data.find(
|
|
(arc) => String(arc.id) === String(featureId),
|
|
);
|
|
|
|
const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
const featureId = e.features?.[0]?.id as string | number | undefined;
|
|
if (featureId == null || featureId === hoveredId) return;
|
|
|
|
setHover(featureId);
|
|
map.getCanvas().style.cursor = "pointer";
|
|
|
|
const arc = findArc(featureId);
|
|
if (arc) {
|
|
latestRef.current.onHover?.({
|
|
arc: arc as T,
|
|
longitude: e.lngLat.lng,
|
|
latitude: e.lngLat.lat,
|
|
originalEvent: e,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
setHover(null);
|
|
map.getCanvas().style.cursor = "";
|
|
latestRef.current.onHover?.(null);
|
|
};
|
|
|
|
const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
const arc = findArc(e.features?.[0]?.id as string | number | undefined);
|
|
if (!arc) return;
|
|
latestRef.current.onClick?.({
|
|
arc: arc as T,
|
|
longitude: e.lngLat.lng,
|
|
latitude: e.lngLat.lat,
|
|
originalEvent: e,
|
|
});
|
|
};
|
|
|
|
map.on("mousemove", hitLayerId, handleMouseMove);
|
|
map.on("mouseleave", hitLayerId, handleMouseLeave);
|
|
map.on("click", hitLayerId, handleClick);
|
|
|
|
return () => {
|
|
map.off("mousemove", hitLayerId, handleMouseMove);
|
|
map.off("mouseleave", hitLayerId, handleMouseLeave);
|
|
map.off("click", hitLayerId, handleClick);
|
|
setHover(null);
|
|
map.getCanvas().style.cursor = "";
|
|
};
|
|
}, [isLoaded, map, hitLayerId, sourceId, interactive]);
|
|
|
|
return null;
|
|
}
|
|
|
|
type MapClusterLayerProps<
|
|
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
|
> = {
|
|
/** 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;
|
|
};
|
|
|
|
function MapClusterLayer<
|
|
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
|
>({
|
|
data,
|
|
clusterMaxZoom = 14,
|
|
clusterRadius = 50,
|
|
clusterColors = ["#22c55e", "#eab308", "#ef4444"],
|
|
clusterThresholds = [100, 750],
|
|
pointColor = "#3b82f6",
|
|
onPointClick,
|
|
onClusterClick,
|
|
}: MapClusterLayerProps<P>) {
|
|
const { map, isLoaded } = useMap();
|
|
const id = useId();
|
|
const sourceId = `cluster-source-${id}`;
|
|
const clusterLayerId = `clusters-${id}`;
|
|
const clusterCountLayerId = `cluster-count-${id}`;
|
|
const unclusteredLayerId = `unclustered-point-${id}`;
|
|
|
|
const stylePropsRef = useRef({
|
|
clusterColors,
|
|
clusterThresholds,
|
|
pointColor,
|
|
});
|
|
|
|
// Add source and layers on mount
|
|
useEffect(() => {
|
|
if (!isLoaded || !map) return;
|
|
|
|
// 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
|
|
}
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isLoaded, map, sourceId]);
|
|
|
|
// Update source data when data prop changes (only for non-URL data)
|
|
useEffect(() => {
|
|
if (!isLoaded || !map || typeof data === "string") return;
|
|
|
|
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
|
|
if (source) {
|
|
source.setData(data);
|
|
}
|
|
}, [isLoaded, map, data, sourceId]);
|
|
|
|
// Update layer styles when props change
|
|
useEffect(() => {
|
|
if (!isLoaded || !map) return;
|
|
|
|
const prev = stylePropsRef.current;
|
|
const colorsChanged =
|
|
prev.clusterColors !== clusterColors ||
|
|
prev.clusterThresholds !== clusterThresholds;
|
|
|
|
// Update cluster layer colors and sizes
|
|
if (map.getLayer(clusterLayerId) && colorsChanged) {
|
|
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) && prev.pointColor !== pointColor) {
|
|
map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor);
|
|
}
|
|
|
|
stylePropsRef.current = { clusterColors, clusterThresholds, pointColor };
|
|
}, [
|
|
isLoaded,
|
|
map,
|
|
clusterLayerId,
|
|
unclusteredLayerId,
|
|
clusterColors,
|
|
clusterThresholds,
|
|
pointColor,
|
|
]);
|
|
|
|
// Handle click events
|
|
useEffect(() => {
|
|
if (!isLoaded || !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);
|
|
};
|
|
}, [
|
|
isLoaded,
|
|
map,
|
|
clusterLayerId,
|
|
unclusteredLayerId,
|
|
sourceId,
|
|
onClusterClick,
|
|
onPointClick,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
export {
|
|
Map,
|
|
useMap,
|
|
MapMarker,
|
|
MarkerContent,
|
|
MarkerPopup,
|
|
MarkerTooltip,
|
|
MarkerLabel,
|
|
MapPopup,
|
|
MapControls,
|
|
MapRoute,
|
|
MapArc,
|
|
MapClusterLayer,
|
|
};
|
|
|
|
export type { MapRef, MapViewport, MapArcDatum, MapArcEvent };
|