,
+ 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) {
+ 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,
+ 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 };
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 6ac2efe..3ebfc9b 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,6 +1,7 @@
---
import { LandingHeroSection } from "@/components/landing-hero-section";
import { LandingPageSections } from "@/components/landing-page-sections";
+import { ContactSection } from "@/components/landing/contact-section";
import { Footer27 } from "@/components/footer27";
import "@/styles/global.css";
---
@@ -22,6 +23,7 @@ import "@/styles/global.css";
+