diff --git a/cli/README.md b/cli/README.md index ade76bd8f8..666bd7061f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -85,6 +85,7 @@ Available components: - `form` - Dynamic form with validation - `input-fields` - Text inputs +- `map` - Interactive Map #### Data Visualization diff --git a/cli/src/registry/map/config.json b/cli/src/registry/map/config.json new file mode 100644 index 0000000000..fc2b9b1941 --- /dev/null +++ b/cli/src/registry/map/config.json @@ -0,0 +1,22 @@ +{ + "name": "map", + "description": "Interactive map component with clustering and heatmap support.", + "componentName": "Map", + "dependencies": [ + "class-variance-authority", + "react-leaflet", + "@react-leaflet/core", + "leaflet", + "leaflet.heat", + "leaflet.markercluster", + "@tambo-ai/react" + ], + "devDependencies": [], + "requires": [], + "files": [ + { + "name": "map.tsx", + "content": "src/registry/map/map.tsx" + } + ] +} diff --git a/cli/src/registry/map/index.tsx b/cli/src/registry/map/index.tsx new file mode 100644 index 0000000000..2679cde086 --- /dev/null +++ b/cli/src/registry/map/index.tsx @@ -0,0 +1 @@ +export { Map, type MapProps } from "./map"; diff --git a/cli/src/registry/map/map.tsx b/cli/src/registry/map/map.tsx new file mode 100644 index 0000000000..671cead954 --- /dev/null +++ b/cli/src/registry/map/map.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { + createElementObject, + createLayerComponent, + updateGridLayer, + type LayerProps, + type LeafletContextInterface, +} from "@react-leaflet/core"; +import { useTambo, useTamboMessageContext } 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 { messageId } = useTamboMessageContext(); + + const message = thread?.messages[thread?.messages.length - 1]; + + const isLatestMessage = message?.id === messageId; + + 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"; diff --git a/package-lock.json b/package-lock.json index 86977fff61..8abccaea8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8428,6 +8428,17 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", @@ -10006,6 +10017,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.20", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz", + "integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet.heat": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/leaflet.heat/-/leaflet.heat-0.2.4.tgz", + "integrity": "sha512-Ocdgf2LUBsPAdQ29KY7QAwe5J5NDq/T2msTsA3OL7eYG3EVjMvkrPqXYJtLbXIauxP9e7bOReDie3GzC7lBLqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/leaflet": "*" + } + }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.5.tgz", + "integrity": "sha512-TkWOhSHDM1ANxmLi+uK0PjsVcjIKBr8CLV2WoF16dIdeFmC0Cj5P5axkI3C1Xsi4+ht6EU8+BfEbbqEF9icPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/leaflet": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -21134,6 +21175,26 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -24578,6 +24639,20 @@ "license": "MIT", "peer": true }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -28537,6 +28612,7 @@ "dependencies": { "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-slot": "^1.2.3", + "@react-leaflet/core": "^2.1.0", "@tambo-ai/react": "*", "@tambo-ai/typescript-sdk": "^0.63.0", "class-variance-authority": "^0.7.1", @@ -28545,12 +28621,16 @@ "geist": "^1.4.2", "highlight.js": "^11.11.1", "json-stringify-pretty-compact": "^4.0.0", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.525.0", "next": "^15.4.2", "next-themes": "^0.4.6", "radix-ui": "^1.4.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "react-markdown": "^10.1.0", "recharts": "^3.1.0", "tailwindcss-animate": "^1.0.7" @@ -28560,6 +28640,9 @@ "@svgr/webpack": "^8.1.0", "@tambo-ai/eslint-config": "*", "@tambo-ai/typescript-config": "*", + "@types/leaflet": "^1.9.20", + "@types/leaflet.heat": "^0.2.4", + "@types/leaflet.markercluster": "^1.5.5", "@types/node": "^22.15.32", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", diff --git a/react-sdk/src/hooks/use-component-state.tsx b/react-sdk/src/hooks/use-component-state.tsx index 7adddeea6c..cb1918e0f9 100644 --- a/react-sdk/src/hooks/use-component-state.tsx +++ b/react-sdk/src/hooks/use-component-state.tsx @@ -56,7 +56,7 @@ export function useTamboComponentState( initialValue: S, debounceTime?: number, ): StateUpdateResult; -// eslint-disable-next-line jsdoc/require-jsdoc + export function useTamboComponentState( keyName: string, initialValue?: S, diff --git a/showcase/next.config.ts b/showcase/next.config.ts index e5cdb868cc..04744b4eb3 100644 --- a/showcase/next.config.ts +++ b/showcase/next.config.ts @@ -1,6 +1,9 @@ import { type NextConfig } from "next"; const nextConfig: NextConfig = { + // We need to disable reactStrictMode because react-leaflet uses a global + // that doesn't work with strict mode. + reactStrictMode: false, webpack(config) { config.module.rules.push({ test: /\.svg$/, diff --git a/showcase/package.json b/showcase/package.json index 32d03e475e..a531ff5f30 100644 --- a/showcase/package.json +++ b/showcase/package.json @@ -12,6 +12,7 @@ "dependencies": { "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-slot": "^1.2.3", + "@react-leaflet/core": "^2.1.0", "@tambo-ai/react": "*", "@tambo-ai/typescript-sdk": "^0.63.0", "class-variance-authority": "^0.7.1", @@ -20,12 +21,16 @@ "geist": "^1.4.2", "highlight.js": "^11.11.1", "json-stringify-pretty-compact": "^4.0.0", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.525.0", "next": "^15.4.2", "next-themes": "^0.4.6", "radix-ui": "^1.4.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "react-markdown": "^10.1.0", "recharts": "^3.1.0", "tailwindcss-animate": "^1.0.7" @@ -35,6 +40,9 @@ "@svgr/webpack": "^8.1.0", "@tambo-ai/eslint-config": "*", "@tambo-ai/typescript-config": "*", + "@types/leaflet": "^1.9.20", + "@types/leaflet.heat": "^0.2.4", + "@types/leaflet.markercluster": "^1.5.5", "@types/node": "^22.15.32", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", diff --git a/showcase/src/app/components/(generative)/map/page.tsx b/showcase/src/app/components/(generative)/map/page.tsx new file mode 100644 index 0000000000..72c5b49b26 --- /dev/null +++ b/showcase/src/app/components/(generative)/map/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { CLI } from "@/components/cli"; +import { MapChatInterface } from "@/components/generative/MapChatInterface"; +import { CopyablePrompt, Section } from "@/components/ui/doc-components"; +import { ShowcaseThemeProvider } from "@/providers/showcase-theme-provider"; +import { TamboProvider } from "@tambo-ai/react"; +import { DemoWrapper } from "../../demo-wrapper"; + +export default function MapPage() { + const installCommand = "npx tambo add map"; + + const examplePrompt = `Create an interactive map showing coffee shops in Seattle: +- Center the map on Seattle (47.6062, -122.3321) +- Set zoom level to 12 +- Add markers for these locations: + - Pike Place Market (47.6097, -122.3417) + - Space Needle (47.6205, -122.3493) + - University of Washington (47.6553, -122.3035) + - Capitol Hill (47.6247, -122.3207) + - Fremont Troll (47.6513, -122.3471) +- Title: "Seattle Coffee Map" +- Use solid variant with large size`; + + return ( +
+ +
+
+

Map

+

+ An interactive map component with markers, pan/zoom functionality, + and tooltip support powered by Leaflet and OpenStreetMap. +

+
+ +
+

Installation

+
+ +
+
+ +
+ +
+ + + + + + +
+
+
+ ); +} diff --git a/showcase/src/app/layout.tsx b/showcase/src/app/layout.tsx index 9e9ced3354..9add5b33a0 100644 --- a/showcase/src/app/layout.tsx +++ b/showcase/src/app/layout.tsx @@ -1,5 +1,6 @@ import { GeistMono, GeistSans, sentientLight } from "@/lib/fonts"; import { cn } from "@/lib/utils"; +import "leaflet/dist/leaflet.css"; import type { Metadata } from "next"; import "../styles/showcase-theme.css"; import "./globals.css"; diff --git a/showcase/src/components/generative/MapChatInterface.tsx b/showcase/src/components/generative/MapChatInterface.tsx new file mode 100644 index 0000000000..1b437663cf --- /dev/null +++ b/showcase/src/components/generative/MapChatInterface.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { MessageThreadFull } from "@/components/ui/message-thread-full"; +import { useUserContextKey } from "@/lib/useUserContextKey"; +import { useTambo } from "@tambo-ai/react"; +import { useEffect } from "react"; + +export const MapChatInterface = () => { + const userContextKey = useUserContextKey("map-thread"); + const { registerComponent } = useTambo(); + + useEffect(() => { + const register = async () => { + /* Dynamically import the Map component and its schema */ + const mod = await import("@/components/ui/map"); + const mapSchema = mod.mapSchema; + const Map = mod.Map; + + registerComponent({ + name: "Map", + description: `Interactive map for visualizing geographic data with markers, clustering, and optional heatmap overlays. Ideal for dashboards, store locators, event maps, and spatial analytics. + Features: + - Pan/zoom controls + - Custom markers with tooltips + - Marker clustering for large datasets + - Optional heatmap for density visualization + - Responsive and mobile-friendly + + Props: + - center: { lat, lng } (required) + - markers: Array<{ lat, lng, label, id? }> (required) + - heatData: Array<{ lat, lng, intensity? }> (optional) + - zoom: number (1-20, default 10) + + Best practices: + - Use valid coordinates (lat: -90 to 90, lng: -180 to 180) + - Provide clear labels for markers + - Choose zoom level appropriate for your data + + Example use cases: store locations, real estate, events, analytics, travel, business intelligence.`, + component: Map, + propsSchema: mapSchema, + }); + }; + + register(); + }, [registerComponent]); + + return ( +
+ +
+ ); +}; diff --git a/showcase/src/components/ui/map.tsx b/showcase/src/components/ui/map.tsx new file mode 100644 index 0000000000..80a25f0a9a --- /dev/null +++ b/showcase/src/components/ui/map.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { + createElementObject, + createLayerComponent, + updateGridLayer, + type LayerProps, + type LeafletContextInterface, +} from "@react-leaflet/core"; +import { useTambo, useTamboMessageContext } 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 { messageId } = useTamboMessageContext(); + + const message = thread?.messages[thread?.messages.length - 1]; + + const isLatestMessage = message?.id === messageId; + + 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"; diff --git a/showcase/src/lib/navigation.ts b/showcase/src/lib/navigation.ts index 7ceb92bb4b..5e8eb12bb5 100644 --- a/showcase/src/lib/navigation.ts +++ b/showcase/src/lib/navigation.ts @@ -73,6 +73,10 @@ export const navigation: NavigationItem[] = [ title: "Graph", href: "/components/graph", }, + { + title: "Map", + href: "/components/map", + }, ], }, {