"use client"; import { cn } from "@/lib/utils"; import { createElementObject, createLayerComponent, updateGridLayer, type LayerProps, type LeafletContextInterface, } from "@react-leaflet/core"; import { useTambo, useTamboCurrentMessage } from "@tambo-ai/react"; import { cva, type VariantProps } from "class-variance-authority"; import L, { type HeatLatLngTuple, type LatLng, type MarkerClusterGroupOptions, } from "leaflet"; import "leaflet.heat"; import "leaflet.markercluster"; import "leaflet.markercluster/dist/MarkerCluster.css"; import "leaflet.markercluster/dist/MarkerCluster.Default.css"; import * as React from "react"; import { MapContainer, Marker, TileLayer, Tooltip, useMapEvents, } from "react-leaflet"; import { z } from "zod"; // --- MarkerClusterGroup --- interface MarkerClusterGroupProps extends MarkerClusterGroupOptions { children?: React.ReactNode; iconCreateFunction?: (cluster: L.MarkerCluster) => L.DivIcon; } const ClusterGroup: React.FC = ({ children, iconCreateFunction, ...options }) => { const map = useMapEvents({}); const clusterGroupRef = React.useRef(null); const optionsString = React.useMemo(() => JSON.stringify(options), [options]); React.useEffect(() => { if (!map) return; const clusterGroup = L.markerClusterGroup({ ...options, iconCreateFunction, }); clusterGroupRef.current = clusterGroup; map.addLayer(clusterGroup); React.Children.forEach(children, (child) => { if (React.isValidElement(child) && child.props.position) { const marker = L.marker(child.props.position, child.props); const tooltipChild = React.Children.toArray(child.props.children).find( (tooltipChild) => React.isValidElement(tooltipChild) && tooltipChild.type === Tooltip, ); if (React.isValidElement(tooltipChild)) { marker.bindTooltip(tooltipChild.props.children, { direction: tooltipChild.props.direction ?? "auto", permanent: tooltipChild.props.permanent ?? false, sticky: tooltipChild.props.sticky ?? false, opacity: tooltipChild.props.opacity ?? 0.9, }); } clusterGroup.addLayer(marker); } }); return () => { map.removeLayer(clusterGroup); }; }, [map, children, optionsString, iconCreateFunction, options]); return null; }; // --- HeatLayer --- interface HeatLayerProps extends LayerProps, L.HeatMapOptions { latlngs: (LatLng | HeatLatLngTuple)[]; } const createHeatLayer = ( { latlngs, ...options }: HeatLayerProps, context: LeafletContextInterface, ) => { const layer = L.heatLayer(latlngs, options); return createElementObject(layer, context); }; const updateHeatLayer = ( layer: L.HeatLayer, { latlngs, ...options }: HeatLayerProps, prevProps: HeatLayerProps, ) => { layer.setLatLngs(latlngs); layer.setOptions(options); updateGridLayer(layer, options, prevProps); }; const HeatLayer = createLayerComponent( createHeatLayer, updateHeatLayer, ); // --- Leaflet marker icon fix (only SSR/Next.js) --- if (typeof window !== "undefined") { import("leaflet").then((L) => { delete (L.Icon.Default.prototype as { _getIconUrl?: () => string }) ._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png", iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png", shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", }); import("leaflet.heat"); }); } // --- Map Variants --- export const mapVariants = cva("w-full transition-all duration-200", { variants: { size: { sm: "h-[200px]", md: "h-[300px]", lg: "h-[500px]", full: "h-full w-full", }, theme: { default: "bg-background border border-border rounded-lg", dark: "bg-zinc-900 border border-zinc-800 rounded-lg", light: "bg-white border border-zinc-200 rounded-lg", satellite: "bg-black border border-zinc-900 rounded-lg", bordered: "border-2 border-primary", shadow: "shadow-lg", }, rounded: { none: "rounded-none", sm: "rounded-md", md: "rounded-lg", full: "rounded-full", }, }, defaultVariants: { size: "md", theme: "default", rounded: "md", }, }); /** * MapProps - Interface for the Map component * @property center - Center coordinates of the map (required) * @property zoom - Initial zoom level (default: 10) * @property markers - Array of marker objects (lat, lng, label, id?) * @property heatData - Optional array of heatmap points (lat, lng, intensity) * @property zoomControl - Show zoom controls (default: true) * @property className - Optional className for container * @property size - Variant de tamanho (sm, md, lg, full) * @property theme - Variant de tema (default, dark, light, satellite, bordered, shadow) * @property rounded - Variant de borda (none, sm, md, full) */ // --- Zod Schemas --- export const markerSchema = z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180), label: z.string(), id: z.string().optional(), }); export const heatDataSchema = z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180), intensity: z.number().min(0).max(1), }); export const mapSchema = z.object({ center: z.object({ lat: z.number(), lng: z.number() }), zoom: z.number().min(1).max(20).default(10), markers: z.array(markerSchema).default([]), heatData: z.array(heatDataSchema).optional().nullable(), zoomControl: z.boolean().optional().default(true), className: z .string() .optional() .describe("Optional tailwind className for the map container"), size: z.enum(["sm", "md", "lg", "full"]).optional(), theme: z .enum(["default", "dark", "light", "satellite", "bordered", "shadow"]) .optional(), rounded: z.enum(["none", "sm", "md", "full"]).optional(), }); export type MarkerData = z.infer; export type HeatData = z.infer; export type MapProps = z.infer & VariantProps; // --- Hooks --- function useValidMarkers(markers: MarkerData[] = []) { return React.useMemo( () => markers.filter( (m) => typeof m.lat === "number" && m.lat >= -90 && m.lat <= 90 && typeof m.lng === "number" && m.lng >= -180 && m.lng <= 180 && typeof m.label === "string" && m.label.length > 0, ), [markers], ); } function useValidHeatData(heatData?: HeatData[] | null) { return React.useMemo(() => { if (!Array.isArray(heatData)) return []; return heatData .filter( (d) => typeof d.lat === "number" && d.lat >= -90 && d.lat <= 90 && typeof d.lng === "number" && d.lng >= -180 && d.lng <= 180 && typeof d.intensity === "number" && d.intensity >= 0 && d.intensity <= 1, ) .map((d) => [d.lat, d.lng, d.intensity] as HeatLatLngTuple); }, [heatData]); } // --- Loading Spinner --- function LoadingSpinner() { return (
Loading map...
); } // --- Handlers --- function MapClickHandler() { const animateRef = React.useRef(true); useMapEvents({ click: (e: { latlng: L.LatLng; target: L.Map }) => { const map: L.Map = e.target; map.setView(e.latlng, map.getZoom(), { animate: animateRef.current }); }, }); return null; } // --- Map Component --- export const Map = React.forwardRef( ( { center, zoom = 10, markers = [], heatData, zoomControl = true, className, size = "md", theme = "default", rounded = "md", ...props }, ref, ) => { const { thread } = useTambo(); const currentMessage = useTamboCurrentMessage(); const message = thread?.messages[thread?.messages.length - 1]; const isLatestMessage = message?.id && message.id === currentMessage?.id; const generationStage = thread?.generationStage; const isGenerating = generationStage && generationStage !== "COMPLETE" && generationStage !== "ERROR"; const validMarkers = useValidMarkers(markers); const validHeatData = useValidHeatData(heatData); // Loading/generation State if (isLatestMessage && isGenerating) { return (
); } if (!center) { return (

Invalid Map Data

Center coordinates are required to display the map.

); } return (
{validHeatData.length > 0 && ( )} { const count = cluster.getChildCount(); let size: "small" | "medium" | "large" = "small"; let colorClass = "bg-blue-500"; if (count < 10) { size = "small"; colorClass = "bg-blue-500"; } else if (count < 100) { size = "medium"; colorClass = "bg-orange-500"; } else { size = "large"; colorClass = "bg-red-500"; } const sizeClasses: Record<"small" | "medium" | "large", string> = { small: "w-8 h-8 text-xs", medium: "w-10 h-10 text-sm", large: "w-12 h-12 text-base", }; const iconSize = size === "small" ? 32 : size === "medium" ? 40 : 48; return L.divIcon({ html: `
${count}
`, className: "custom-cluster-icon", iconSize: L.point(iconSize, iconSize), iconAnchor: L.point(iconSize / 2, iconSize / 2), }); }} > {validMarkers.map((marker, idx) => ( {marker.label} ))}
); }, ); Map.displayName = "Map";