shadimage
tool-ui
blockgeo-mapsource: ok · hit

Geo Map

Display geolocated entities and fleet positions.

npx shadcn add "https://www.tool-ui.com/r/geo-map.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
frameworknext
formszod

Files13 · source cached 10/9/2026, 12:34:00 AM

components/tool-ui/geo-map/_adapter.tsx
/**
 * Adapter: UI and utility re-exports for copy-standalone portability.
 *
 * This file must stay free of leaflet: the facade imports it, and leaflet
 * reads `window` at module scope. Map primitives live in `_leaflet-adapter`,
 * which only client-only modules may import.
 *
 * When copying this component to another project, update these imports
 * to match your project's paths:
 *
 *   cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
 */

export { cn } from "@/lib/utils";
components/tool-ui/geo-map/_leaflet-adapter.tsx
/**
 * Adapter: react-leaflet re-exports for copy-standalone portability.
 *
 * Leaflet reads `window` at module scope, so only modules that already live
 * behind the client-only boundary (the engine and its overlays) may import
 * from this file. The facade stays on `_adapter` and must never depend on it.
 *
 * When copying this component to another project, update these imports
 * to match your project's paths:
 *
 *   Leaflet → map primitives from react-leaflet
 */

export {
  CircleMarker,
  MapContainer,
  Marker,
  Polyline,
  Popup,
  TileLayer,
  Tooltip,
  ZoomControl,
  useMap,
  useMapEvents,
} from "react-leaflet";
components/tool-ui/geo-map/geo-map-engine.tsx
"use client";

import type { Map as LeafletMap } from "leaflet";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Supercluster from "supercluster";
import {
  CircleMarker,
  MapContainer,
  Marker,
  Polyline,
  TileLayer,
  ZoomControl,
  useMap,
  useMapEvents,
} from "./_leaflet-adapter";
import { createClusterIcon, resolveMarkerIcon } from "./geo-map-icons";
import { GeoMapOverlays } from "./geo-map-overlays";
import type {
  GeoMapClustering,
  GeoMapFitTarget,
  GeoMapMarker,
  GeoMapRoute,
  GeoMapViewport,
} from "./schema";

const TILE_ATTRIBUTION =
  '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>';
const ROUTE_DEFAULT_COLOR = "var(--primary)";
const ROUTE_DEFAULT_WEIGHT = 3;
const ROUTE_DEFAULT_OPACITY = 0.85;
const EMPTY_ROUTES: GeoMapRoute[] = [];

const CLUSTER_RADIUS_DEFAULT = 60;
const CLUSTER_MAX_ZOOM_DEFAULT = 16;
const CLUSTER_MIN_POINTS_DEFAULT = 2;

const DEFAULT_CENTER: [number, number] = [20, 0];
export const DEFAULT_VIEW_ZOOM = 2;
const SINGLE_LOCATION_ZOOM = 13;
const DEFAULT_VIEWPORT_PADDING = 32;

type LeafletRuntime = Pick<
  typeof import("leaflet"),
  "divIcon" | "latLngBounds"
>;

export type GeoMapBbox = [
  west: number,
  south: number,
  east: number,
  north: number,
];
export type GeoMapLatLng = [lat: number, lng: number];

export type GeoMapClusterProperties = {
  cluster?: boolean;
  cluster_id?: number;
  point_count?: number;
  markerId?: string;
};

export type GeoMapClusterFeature = GeoJSON.Feature<
  GeoJSON.Point,
  GeoMapClusterProperties
>;

type MarkerClusterPointProperties = GeoMapClusterProperties & {
  markerId?: string;
  marker?: GeoMapMarker;
};

type MapViewportState = {
  bbox: GeoMapBbox;
  zoom: number;
};

function roundCoordinate(value: number): number {
  return Math.round(value * 1_000_000) / 1_000_000;
}

function normalizeViewportState(state: MapViewportState): MapViewportState {
  return {
    bbox: [
      roundCoordinate(state.bbox[0]),
      roundCoordinate(state.bbox[1]),
      roundCoordinate(state.bbox[2]),
      roundCoordinate(state.bbox[3]),
    ],
    zoom: state.zoom,
  };
}

function areViewportStatesEqual(
  a: MapViewportState | null,
  b: MapViewportState,
): boolean {
  if (!a) {
    return false;
  }

  return (
    a.zoom === b.zoom &&
    a.bbox[0] === b.bbox[0] &&
    a.bbox[1] === b.bbox[1] &&
    a.bbox[2] === b.bbox[2] &&
    a.bbox[3] === b.bbox[3]
  );
}

function serializeFitPoints(points: [number, number][]): string {
  return points
    .map(([lat, lng]) => `${roundCoordinate(lat)},${roundCoordinate(lng)}`)
    .join("|");
}

function readViewportState(map: LeafletMap): MapViewportState {
  const bounds = map.getBounds();
  return normalizeViewportState({
    bbox: [
      bounds.getWest(),
      bounds.getSouth(),
      bounds.getEast(),
      bounds.getNorth(),
    ],
    zoom: Math.round(map.getZoom()),
  });
}

export function collectFitPoints(
  markers: GeoMapMarker[],
  routes: GeoMapRoute[],
  target: GeoMapFitTarget,
): GeoMapLatLng[] {
  const markerPoints =
    target === "markers" || target === "all"
      ? markers.map((marker) => [marker.lat, marker.lng] as GeoMapLatLng)
      : [];

  const routePoints =
    target === "routes" || target === "all"
      ? routes.flatMap((route) =>
          route.points.map((point) => [point.lat, point.lng] as GeoMapLatLng),
        )
      : [];

  return [...markerPoints, ...routePoints];
}

export function resolveFitPointsWithFallback(
  markers: GeoMapMarker[],
  routes: GeoMapRoute[],
  target: GeoMapFitTarget,
): GeoMapLatLng[] {
  const selected = collectFitPoints(markers, routes, target);
  if (selected.length > 0) {
    return selected;
  }

  if (target !== "markers") {
    return collectFitPoints(markers, routes, "markers");
  }

  return [];
}

export function splitDatelineBbox(bbox: GeoMapBbox): GeoMapBbox[] {
  const [west, south, east, north] = bbox;

  if (west <= east) {
    return [bbox];
  }

  return [
    [west, south, 180, north],
    [-180, south, east, north],
  ];
}

function getClusterFeatureKey(feature: GeoMapClusterFeature): string {
  const properties = feature.properties ?? {};

  if (properties.cluster && typeof properties.cluster_id === "number") {
    return `cluster:${properties.cluster_id}`;
  }

  if (
    typeof properties.markerId === "string" &&
    properties.markerId.length > 0
  ) {
    return `marker:${properties.markerId}`;
  }

  if (feature.id !== undefined && feature.id !== null) {
    return `id:${String(feature.id)}`;
  }

  const [lng, lat] = feature.geometry.coordinates;
  return `point:${lat}:${lng}`;
}

function dedupeClusterFeatures(
  features: GeoMapClusterFeature[],
): GeoMapClusterFeature[] {
  const seen = new Set<string>();
  const deduped: GeoMapClusterFeature[] = [];

  features.forEach((feature) => {
    const key = getClusterFeatureKey(feature);
    if (seen.has(key)) {
      return;
    }

    seen.add(key);
    deduped.push(feature);
  });

  return deduped;
}

export function getClustersForDatelineAwareBbox(
  bbox: GeoMapBbox,
  zoom: number,
  getClustersForBbox: (
    candidateBbox: GeoMapBbox,
    zoom: number,
  ) => GeoMapClusterFeature[],
): GeoMapClusterFeature[] {
  const queried = splitDatelineBbox(bbox).flatMap((candidateBbox) =>
    getClustersForBbox(candidateBbox, zoom),
  );

  return dedupeClusterFeatures(queried);
}

export function toSafeExpansionZoom(
  zoom: number,
  options?: { minZoom?: number; maxZoom?: number; fallback?: number },
): number {
  const minZoom = options?.minZoom ?? 1;
  const maxZoom = options?.maxZoom ?? 22;
  const fallback = options?.fallback ?? 2;

  if (!Number.isFinite(zoom)) {
    return fallback;
  }

  return Math.min(maxZoom, Math.max(minZoom, Math.round(zoom)));
}

function resolveInitialView(
  markers: GeoMapMarker[],
  routes: GeoMapRoute[],
  viewport: GeoMapViewport | undefined,
): { center: [number, number]; zoom: number } {
  if (viewport?.mode === "center") {
    return {
      center: [viewport.center.lat, viewport.center.lng],
      zoom: viewport.zoom,
    };
  }

  const fitTarget = viewport?.target ?? "all";
  const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);

  if (fitPoints.length === 1) {
    return {
      center: [fitPoints[0][0], fitPoints[0][1]],
      zoom: viewport?.maxZoom
        ? Math.min(SINGLE_LOCATION_ZOOM, viewport.maxZoom)
        : SINGLE_LOCATION_ZOOM,
    };
  }

  return { center: DEFAULT_CENTER, zoom: DEFAULT_VIEW_ZOOM };
}

function ViewportController({
  markers,
  routes,
  viewport,
  leafletRuntime,
}: {
  markers: GeoMapMarker[];
  routes: GeoMapRoute[];
  viewport: GeoMapViewport | undefined;
  leafletRuntime: LeafletRuntime;
}) {
  const map = useMap();
  const lastAppliedViewportRef = useRef<string | null>(null);

  useEffect(() => {
    lastAppliedViewportRef.current = null;
  }, [map]);

  useEffect(() => {
    if (viewport?.mode === "center") {
      const viewportKey = `center:${roundCoordinate(viewport.center.lat)}:${roundCoordinate(viewport.center.lng)}:${viewport.zoom}`;
      if (lastAppliedViewportRef.current === viewportKey) {
        return;
      }

      lastAppliedViewportRef.current = viewportKey;
      map.setView([viewport.center.lat, viewport.center.lng], viewport.zoom);
      return;
    }

    const fitTarget = viewport?.target ?? "all";
    const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget);
    if (fitPoints.length === 0) {
      return;
    }

    const maxZoom = viewport?.maxZoom;
    if (fitPoints.length === 1) {
      const [lat, lng] = fitPoints[0];
      const zoom = maxZoom
        ? Math.min(SINGLE_LOCATION_ZOOM, maxZoom)
        : SINGLE_LOCATION_ZOOM;
      const viewportKey = `fit-single:${roundCoordinate(lat)}:${roundCoordinate(lng)}:${zoom}`;
      if (lastAppliedViewportRef.current === viewportKey) {
        return;
      }

      lastAppliedViewportRef.current = viewportKey;
      map.setView([lat, lng], zoom);
      return;
    }

    const padding = viewport?.padding ?? DEFAULT_VIEWPORT_PADDING;
    const viewportKey = `fit:${fitTarget}:${padding}:${maxZoom ?? "none"}:${serializeFitPoints(fitPoints)}`;
    if (lastAppliedViewportRef.current === viewportKey) {
      return;
    }

    lastAppliedViewportRef.current = viewportKey;
    const bounds = leafletRuntime.latLngBounds(fitPoints);
    map.fitBounds(bounds, {
      maxZoom,
      padding: [padding, padding],
    });
  }, [leafletRuntime, map, markers, routes, viewport]);

  return null;
}

function MapObserver({
  onViewportChange,
  onMapReady,
}: {
  onViewportChange: (state: MapViewportState) => void;
  onMapReady: (map: LeafletMap) => void;
}) {
  const map = useMapEvents({
    moveend: () => {
      onViewportChange(readViewportState(map));
    },
    zoomend: () => {
      onViewportChange(readViewportState(map));
    },
  });

  useEffect(() => {
    onMapReady(map);
    onViewportChange(readViewportState(map));
  }, [map, onMapReady, onViewportChange]);

  return null;
}

function resolveMarkerAriaLabel(marker: GeoMapMarker): string {
  if (marker.label && marker.description) {
    return `${marker.label}. ${marker.description}`;
  }

  return (
    marker.label ??
    marker.description ??
    `Marker at ${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}`
  );
}

export const GeoMapEngine = memo(function GeoMapEngine({
  id,
  markers,
  routes,
  clustering,
  viewport,
  showZoomControl,
  tileUrl,
  mapAriaLabel,
  tooltipClassName,
  popupClassName,
  onMarkerClick,
  onRouteClick,
  onReadyChange,
}: {
  id: string;
  markers: GeoMapMarker[];
  routes?: GeoMapRoute[];
  clustering?: GeoMapClustering;
  viewport?: GeoMapViewport;
  showZoomControl: boolean;
  tileUrl: string;
  mapAriaLabel: string;
  tooltipClassName?: string;
  popupClassName?: string;
  onMarkerClick?: (marker: GeoMapMarker) => void;
  onRouteClick?: (route: GeoMapRoute) => void;
  onReadyChange?: (isReady: boolean) => void;
}) {
  const resolvedRoutes = routes ?? EMPTY_ROUTES;
  const [leafletRuntime, setLeafletRuntime] = useState<LeafletRuntime | null>(
    null,
  );
  const [mapInstance, setMapInstance] = useState<LeafletMap | null>(null);
  const [viewportState, setViewportState] = useState<MapViewportState | null>(
    null,
  );

  const handleViewportChange = useCallback((nextState: MapViewportState) => {
    const normalized = normalizeViewportState(nextState);
    setViewportState((previousState) =>
      areViewportStatesEqual(previousState, normalized)
        ? previousState
        : normalized,
    );
  }, []);

  useEffect(() => {
    let isActive = true;

    void import("leaflet").then((module) => {
      if (!isActive) {
        return;
      }

      setLeafletRuntime({
        divIcon: module.divIcon,
        latLngBounds: module.latLngBounds,
      });
    });

    return () => {
      isActive = false;
    };
  }, []);

  const isReady = leafletRuntime !== null;

  useEffect(() => {
    onReadyChange?.(isReady);
  }, [isReady, onReadyChange]);

  useEffect(() => {
    if (!mapInstance) {
      return;
    }

    const container = mapInstance.getContainer();
    container.setAttribute("role", "region");
    container.setAttribute("aria-label", mapAriaLabel);
  }, [mapAriaLabel, mapInstance]);

  useEffect(() => {
    if (!mapInstance) {
      return;
    }

    const handleEscape = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        mapInstance.closePopup();
      }
    };

    document.addEventListener("keydown", handleEscape);
    return () => {
      document.removeEventListener("keydown", handleEscape);
    };
  }, [mapInstance]);

  const initialView = useMemo(
    () => resolveInitialView(markers, resolvedRoutes, viewport),
    [markers, resolvedRoutes, viewport],
  );

  const markerById = useMemo(() => {
    const map = new Map<string, GeoMapMarker>();
    markers.forEach((marker, index) => {
      map.set(marker.id ?? `marker-${index}`, marker);
    });
    return map;
  }, [markers]);

  const clusterConfig = useMemo(
    () => ({
      enabled: clustering?.enabled === true,
      radius: clustering?.radius ?? CLUSTER_RADIUS_DEFAULT,
      maxZoom: clustering?.maxZoom ?? CLUSTER_MAX_ZOOM_DEFAULT,
      minPoints: clustering?.minPoints ?? CLUSTER_MIN_POINTS_DEFAULT,
    }),
    [clustering],
  );

  const clusterIndex = useMemo(() => {
    if (!clusterConfig.enabled) {
      return null;
    }

    const index = new Supercluster<MarkerClusterPointProperties>({
      radius: clusterConfig.radius,
      maxZoom: clusterConfig.maxZoom,
      minPoints: clusterConfig.minPoints,
    });

    const points = markers.map((marker, index) => {
      const markerId = marker.id ?? `marker-${index}`;
      return {
        type: "Feature" as const,
        id: markerId,
        geometry: {
          type: "Point" as const,
          coordinates: [marker.lng, marker.lat] as [number, number],
        },
        properties: {
          markerId,
          marker,
        },
      };
    });

    index.load(points);
    return index;
  }, [
    clusterConfig.enabled,
    clusterConfig.maxZoom,
    clusterConfig.minPoints,
    clusterConfig.radius,
    markers,
  ]);

  const clusteredFeatures = useMemo(() => {
    if (!clusterConfig.enabled || !clusterIndex || !viewportState) {
      return [] as GeoMapClusterFeature[];
    }

    return getClustersForDatelineAwareBbox(
      viewportState.bbox,
      viewportState.zoom,
      (bbox, zoom) =>
        clusterIndex.getClusters(bbox, zoom) as GeoMapClusterFeature[],
    );
  }, [clusterConfig.enabled, clusterIndex, viewportState]);

  const renderMarker = useCallback(
    (
      marker: GeoMapMarker,
      markerKey: string,
      markerPositionOverride?: [number, number],
    ) => {
      const markerPosition: [number, number] = markerPositionOverride ?? [
        marker.lat,
        marker.lng,
      ];
      const tooltipMode = marker.tooltip ?? "hover";
      const tooltipContent = marker.label ?? marker.description;
      const icon = marker.icon;
      const markerAriaLabel = resolveMarkerAriaLabel(marker);

      if (!leafletRuntime) {
        return null;
      }

      const leafletIcon = resolveMarkerIcon(icon, leafletRuntime);
      if (leafletIcon) {
        return (
          <Marker
            key={markerKey}
            position={markerPosition}
            icon={leafletIcon}
            title={markerAriaLabel}
            alt={markerAriaLabel}
            eventHandlers={{
              click: () => onMarkerClick?.(marker),
            }}
          >
            <GeoMapOverlays
              tooltipMode={tooltipMode}
              tooltipContent={tooltipContent}
              label={marker.label}
              description={marker.description}
              tooltipClassName={tooltipClassName}
              popupClassName={popupClassName}
            />
          </Marker>
        );
      }

      const markerStroke =
        icon?.type === "dot"
          ? (icon.borderColor ?? "var(--border)")
          : "var(--border)";
      const markerFill =
        icon?.type === "dot"
          ? (icon.color ?? "var(--primary)")
          : "var(--primary)";
      const markerRadius = icon?.type === "dot" ? (icon.radius ?? 7) : 7;

      return (
        <CircleMarker
          key={markerKey}
          center={markerPosition}
          radius={markerRadius}
          pathOptions={{
            color: markerStroke,
            fillColor: markerFill,
            fillOpacity: 0.95,
            weight: 2,
          }}
          eventHandlers={{
            click: () => onMarkerClick?.(marker),
          }}
        >
          <GeoMapOverlays
            tooltipMode={tooltipMode}
            tooltipContent={tooltipContent}
            label={marker.label}
            description={marker.description}
            tooltipClassName={tooltipClassName}
            popupClassName={popupClassName}
          />
        </CircleMarker>
      );
    },
    [leafletRuntime, onMarkerClick, popupClassName, tooltipClassName],
  );

  if (!leafletRuntime) {
    return null;
  }

  return (
    <MapContainer
      center={initialView.center}
      zoom={initialView.zoom}
      zoomControl={false}
      className="h-full w-full"
      scrollWheelZoom
    >
      <TileLayer attribution={TILE_ATTRIBUTION} url={tileUrl} />
      {showZoomControl && <ZoomControl position="topright" />}
      <MapObserver
        onMapReady={setMapInstance}
        onViewportChange={handleViewportChange}
      />
      <ViewportController
        leafletRuntime={leafletRuntime}
        markers={markers}
        routes={resolvedRoutes}
        viewport={viewport}
      />

      {resolvedRoutes.map((route, routeIndex) => {
        const routeKey = route.id ?? `${id}-route-${routeIndex}`;
        const positions = route.points.map((point) => [
          point.lat,
          point.lng,
        ]) as [number, number][];
        const tooltipMode = route.tooltip ?? "hover";
        const tooltipContent = route.label ?? route.description;

        return (
          <Polyline
            key={routeKey}
            positions={positions}
            pathOptions={{
              color: route.color ?? ROUTE_DEFAULT_COLOR,
              weight: route.weight ?? ROUTE_DEFAULT_WEIGHT,
              opacity: route.opacity ?? ROUTE_DEFAULT_OPACITY,
              dashArray: route.dashArray,
            }}
            eventHandlers={{
              click: () => onRouteClick?.(route),
            }}
          >
            <GeoMapOverlays
              tooltipMode={tooltipMode}
              tooltipContent={tooltipContent}
              label={route.label}
              description={route.description}
              tooltipClassName={tooltipClassName}
              popupClassName={popupClassName}
            />
          </Polyline>
        );
      })}

      {clusterConfig.enabled && clusterIndex && viewportState
        ? clusteredFeatures.map((feature, index) => {
            const [lng, lat] = feature.geometry.coordinates;
            const properties = (feature.properties ??
              {}) as MarkerClusterPointProperties;

            if (
              properties.cluster &&
              typeof properties.cluster_id === "number"
            ) {
              const pointCount = properties.point_count ?? 0;
              const clusterId = properties.cluster_id;
              const clusterIcon = createClusterIcon(pointCount, leafletRuntime);
              const clusterAriaLabel = `Cluster containing ${pointCount} locations`;

              return (
                <Marker
                  key={`cluster-${clusterId}`}
                  position={[lat, lng]}
                  icon={clusterIcon}
                  title={clusterAriaLabel}
                  alt={clusterAriaLabel}
                  eventHandlers={{
                    click: () => {
                      if (!mapInstance) {
                        return;
                      }

                      const expansionZoom = toSafeExpansionZoom(
                        clusterIndex.getClusterExpansionZoom(clusterId),
                        {
                          maxZoom: 22,
                          fallback:
                            (viewportState.zoom ?? DEFAULT_VIEW_ZOOM) + 2,
                        },
                      );
                      mapInstance.flyTo([lat, lng], expansionZoom);
                    },
                  }}
                />
              );
            }

            const marker =
              properties.marker ??
              markerById.get(properties.markerId ?? `marker-${index}`);
            if (!marker) {
              return null;
            }

            const markerKey =
              marker.id ?? properties.markerId ?? `${id}-cluster-leaf-${index}`;
            return renderMarker(marker, markerKey, [lat, lng]);
          })
        : markers.map((marker, index) =>
            renderMarker(marker, marker.id ?? `${id}-marker-${index}`),
          )}
    </MapContainer>
  );
});
components/tool-ui/geo-map/geo-map-icons.ts
import type { DivIcon } from "leaflet";
import type { GeoMapMarker } from "./schema";

type LeafletIconRuntime = Pick<typeof import("leaflet"), "divIcon">;

function isSafeHttpUrl(value: string | undefined): boolean {
  if (!value) {
    return false;
  }

  try {
    const parsed = new URL(value);
    return parsed.protocol === "http:" || parsed.protocol === "https:";
  } catch {
    return false;
  }
}

function escapeHtml(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

function createEmojiIcon(
  icon: Extract<NonNullable<GeoMapMarker["icon"]>, { type: "emoji" }>,
  leafletRuntime: LeafletIconRuntime,
): DivIcon {
  const size = icon.size ?? 24;
  const background = icon.bgColor ?? "var(--card)";
  const border = icon.borderColor ?? "var(--border)";

  return leafletRuntime.divIcon({
    className: "",
    html: `<span style="
display:flex;
align-items:center;
justify-content:center;
width:${size}px;
height:${size}px;
border-radius:999px;
background:${background};
border:1px solid ${border};
font-size:${Math.round(size * 0.62)}px;
line-height:1;
box-shadow:0 1px 3px oklch(from var(--foreground) l c h / 0.22);
">${escapeHtml(icon.value)}</span>`,
    iconSize: [size, size],
    iconAnchor: [size / 2, size / 2],
    popupAnchor: [0, -Math.round(size / 2)],
    tooltipAnchor: [0, -Math.round(size / 2)],
  });
}

function createImageIcon(
  icon: Extract<NonNullable<GeoMapMarker["icon"]>, { type: "image" }>,
  leafletRuntime: LeafletIconRuntime,
): DivIcon {
  const width = icon.width ?? 28;
  const height = icon.height ?? 28;
  const borderRadius = icon.borderRadius ?? Math.min(width, height) / 2;
  const border = icon.borderColor ?? "var(--border)";

  return leafletRuntime.divIcon({
    className: "",
    html: `<span style="
display:block;
width:${width}px;
height:${height}px;
border-radius:${borderRadius}px;
overflow:hidden;
border:1px solid ${border};
background:var(--card);
box-shadow:0 1px 3px oklch(from var(--foreground) l c h / 0.22);
"><img src="${escapeHtml(icon.url)}" alt="" style="width:100%;height:100%;object-fit:cover;display:block;" /></span>`,
    iconSize: [width, height],
    iconAnchor: [width / 2, height / 2],
    popupAnchor: [0, -Math.round(height / 2)],
    tooltipAnchor: [0, -Math.round(height / 2)],
  });
}

export function createClusterIcon(
  count: number,
  leafletRuntime: LeafletIconRuntime,
): DivIcon {
  const size = count >= 100 ? 42 : count >= 10 ? 38 : 34;
  const background = "var(--primary)";
  const border = "var(--background)";

  return leafletRuntime.divIcon({
    className: "",
    html: `<span style="
display:flex;
align-items:center;
justify-content:center;
width:${size}px;
height:${size}px;
border-radius:999px;
background:${background};
border:2px solid ${border};
color:var(--primary-foreground);
font-size:12px;
font-weight:700;
line-height:1;
box-shadow:0 2px 6px oklch(from var(--foreground) l c h / 0.25);
">${count}</span>`,
    iconSize: [size, size],
    iconAnchor: [size / 2, size / 2],
    popupAnchor: [0, -Math.round(size / 2)],
    tooltipAnchor: [0, -Math.round(size / 2)],
  });
}

export function resolveMarkerIcon(
  icon: GeoMapMarker["icon"] | undefined,
  leafletRuntime: LeafletIconRuntime,
): DivIcon | null {
  if (icon?.type === "emoji") {
    return createEmojiIcon(icon, leafletRuntime);
  }

  if (icon?.type === "image" && isSafeHttpUrl(icon.url)) {
    return createImageIcon(icon, leafletRuntime);
  }

  return null;
}
components/tool-ui/geo-map/geo-map-overlays.tsx
"use client";

import { useMemo, useState } from "react";

import { cn } from "./_adapter";
import { Popup, Tooltip } from "./_leaflet-adapter";

function GeoMapPopupContent({
  label,
  description,
}: {
  label?: string;
  description?: string;
}) {
  return (
    <div className="flex flex-col gap-0.5">
      {label && (
        <p className="block text-sm leading-tight font-semibold tracking-tight text-foreground">
          {label}
        </p>
      )}
      {description && (
        <p className="block text-xs leading-relaxed text-muted-foreground">
          {description}
        </p>
      )}
    </div>
  );
}

function GeoMapTooltipContent({ text }: { text: string }) {
  return <span className="block">{text}</span>;
}

export function GeoMapOverlays({
  tooltipMode,
  tooltipContent,
  label,
  description,
  tooltipClassName,
  popupClassName,
}: {
  tooltipMode: "none" | "hover" | "always";
  tooltipContent?: string;
  label?: string;
  description?: string;
  tooltipClassName?: string;
  popupClassName?: string;
}) {
  const hasPopup = Boolean(label || description);
  const [isPopupOpen, setIsPopupOpen] = useState(false);
  const shouldRenderTooltip =
    tooltipMode !== "none" && tooltipContent && (!hasPopup || !isPopupOpen);
  const popupEventHandlers = useMemo(
    () => ({
      add: () => setIsPopupOpen(true),
      remove: () => setIsPopupOpen(false),
    }),
    [],
  );

  return (
    <>
      {shouldRenderTooltip && (
        <Tooltip
          direction="top"
          permanent={tooltipMode === "always"}
          className={cn("geo-map-tooltip", tooltipClassName)}
        >
          <GeoMapTooltipContent text={tooltipContent} />
        </Tooltip>
      )}
      {hasPopup && (
        <Popup
          className={cn("geo-map-popup", popupClassName)}
          closeButton
          closeOnEscapeKey
          minWidth={0}
          maxWidth={288}
          eventHandlers={popupEventHandlers}
        >
          <GeoMapPopupContent label={label} description={description} />
        </Popup>
      )}
    </>
  );
}
components/tool-ui/geo-map/geo-map-theme.module.css
.root[data-slot="geo-map"] {
  --geo-map-canvas-bg: var(--muted);
  --geo-map-tooltip-bg: var(--foreground);
  --geo-map-tooltip-fg: var(--background);
  --geo-map-tooltip-shadow: 0 8px 20px
    oklch(from var(--foreground) l c h / 0.18);
  --geo-map-tooltip-radius: calc(var(--radius) - 2px);
  --geo-map-tooltip-padding: 0.375rem 0.625rem;
  --geo-map-tooltip-font-size: 0.75rem;
  --geo-map-tooltip-font-weight: 500;
  --geo-map-tooltip-line-height: 1.2;
  --geo-map-popup-margin-bottom: 12px;
  --geo-map-popup-border: var(--border);
  --geo-map-popup-radius: calc(var(--radius) + 2px);
  --geo-map-popup-bg: oklch(from var(--popover) l c h / 0.96);
  --geo-map-popup-fg: var(--popover-foreground);
  --geo-map-popup-shadow: 0 10px 30px oklch(from var(--foreground) l c h / 0.12);
  --geo-map-popup-blur: 8px;
  --geo-map-popup-content-padding: 0.625rem 0.75rem;
  --geo-map-popup-max-width: min(80vw, 18rem);
  --geo-map-popup-font-family: var(
    --font-sans,
    ui-sans-serif,
    system-ui,
    sans-serif
  );
  --geo-map-zoom-bg: oklch(from var(--background) l c h / 0.78);
  --geo-map-zoom-fg: var(--foreground);
  --geo-map-zoom-border: var(--border);
  --geo-map-zoom-hover-bg: oklch(from var(--accent) l c h / 0.82);
  --geo-map-zoom-hover-fg: var(--accent-foreground);
  --geo-map-zoom-disabled-bg: oklch(from var(--muted) l c h / 0.72);
  --geo-map-zoom-disabled-fg: var(--muted-foreground);
  --geo-map-zoom-shadow: 0 1px 2px oklch(from var(--foreground) l c h / 0.08);
  --geo-map-zoom-focus-ring: var(--ring);
  --geo-map-zoom-radius: 0.5rem;
  --geo-map-zoom-size: 2.25rem;
  --geo-map-zoom-font-size: 1.125rem;
}

.root[data-slot="geo-map"] :global(.leaflet-container) {
  background: var(--geo-map-canvas-bg);
}

.root[data-slot="geo-map"] :global(.leaflet-control-zoom) {
  border: 1px solid var(--geo-map-zoom-border);
  box-shadow: var(--geo-map-zoom-shadow);
  background: var(--geo-map-zoom-bg);
  backdrop-filter: blur(var(--geo-map-popup-blur));
  -webkit-backdrop-filter: blur(var(--geo-map-popup-blur));
}

.root[data-slot="geo-map"] :global(.leaflet-control-zoom.leaflet-bar) {
  border-radius: var(--geo-map-zoom-radius) !important;
}

.root[data-slot="geo-map"] :global(.leaflet-control-zoom a) {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--geo-map-zoom-size);
  height: var(--geo-map-zoom-size);
  line-height: 1;
  text-indent: 0;
  border: 0;
  background: transparent;
  color: var(--geo-map-zoom-fg);
  font-size: var(--geo-map-zoom-font-size);
  font-weight: 500;
  box-shadow: none;
  cursor: default;
  transition:
    background-color 150ms ease,
    color 150ms ease,
    border-color 150ms ease,
    box-shadow 150ms ease,
    opacity 150ms ease;
  border-radius: 0 !important;
}

.root[data-slot="geo-map"] :global(.leaflet-control-zoom a + a) {
  border-top: 1px solid var(--geo-map-zoom-border);
}

.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:first-child),
.root[data-slot="geo-map"]
  :global(.leaflet-touch .leaflet-control-zoom a:first-child),
.root[data-slot="geo-map"]
  :global(.leaflet-control-zoom .leaflet-control-zoom-in) {
  border-radius: var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) 0 0 !important;
}

.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:last-child),
.root[data-slot="geo-map"]
  :global(.leaflet-touch .leaflet-control-zoom a:last-child),
.root[data-slot="geo-map"]
  :global(.leaflet-control-zoom .leaflet-control-zoom-out) {
  border-top: 0;
  border-radius: 0 0 var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) !important;
}

.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:hover) {
  background: var(--geo-map-zoom-hover-bg);
  color: var(--geo-map-zoom-hover-fg);
}

.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus),
.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus-visible) {
  position: relative;
  z-index: 1;
  outline: 2px solid var(--geo-map-zoom-focus-ring);
  outline-offset: 1px;
}

.root[data-slot="geo-map"] :global(.leaflet-control-zoom a.leaflet-disabled),
.root[data-slot="geo-map"]
  :global(.leaflet-control-zoom a.leaflet-disabled:hover) {
  background: var(--geo-map-zoom-disabled-bg);
  color: var(--geo-map-zoom-disabled-fg);
  opacity: 0.55;
}

.root[data-slot="geo-map"] :global(.leaflet-tooltip.geo-map-tooltip) {
  border: 0;
  border-radius: var(--geo-map-tooltip-radius);
  background: var(--geo-map-tooltip-bg);
  color: var(--geo-map-tooltip-fg);
  box-shadow: var(--geo-map-tooltip-shadow);
  font-size: var(--geo-map-tooltip-font-size);
  font-weight: var(--geo-map-tooltip-font-weight);
  line-height: var(--geo-map-tooltip-line-height);
  padding: var(--geo-map-tooltip-padding);
}

.root[data-slot="geo-map"]
  :global(.leaflet-tooltip-top.geo-map-tooltip::before) {
  border-top-color: var(--geo-map-tooltip-bg);
}

.root[data-slot="geo-map"]
  :global(.leaflet-tooltip-bottom.geo-map-tooltip::before) {
  border-bottom-color: var(--geo-map-tooltip-bg);
}

.root[data-slot="geo-map"]
  :global(.leaflet-tooltip-left.geo-map-tooltip::before) {
  border-left-color: var(--geo-map-tooltip-bg);
}

.root[data-slot="geo-map"]
  :global(.leaflet-tooltip-right.geo-map-tooltip::before) {
  border-right-color: var(--geo-map-tooltip-bg);
}

.root[data-slot="geo-map"] :global(.leaflet-popup.geo-map-popup) {
  margin-bottom: var(--geo-map-popup-margin-bottom);
}

.root[data-slot="geo-map"]
  :global(.leaflet-popup.geo-map-popup .leaflet-popup-content-wrapper) {
  border: 1px solid var(--geo-map-popup-border);
  border-radius: var(--geo-map-popup-radius);
  background: var(--geo-map-popup-bg);
  color: var(--geo-map-popup-fg);
  box-shadow: var(--geo-map-popup-shadow);
  backdrop-filter: blur(var(--geo-map-popup-blur));
  -webkit-backdrop-filter: blur(var(--geo-map-popup-blur));
  padding: 0;
}

.root[data-slot="geo-map"]
  :global(.leaflet-popup.geo-map-popup .leaflet-popup-content) {
  margin: 0;
  min-width: 0;
  width: max-content;
  max-width: var(--geo-map-popup-max-width);
  padding: var(--geo-map-popup-content-padding);
  font-family: var(--geo-map-popup-font-family);
}

.root[data-slot="geo-map"]
  :global(.leaflet-popup.geo-map-popup .leaflet-popup-content p) {
  margin: 0;
}

.root[data-slot="geo-map"]
  :global(.leaflet-popup.geo-map-popup .leaflet-popup-tip-container) {
  display: none;
}

.root[data-slot="geo-map"]
  :global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button) {
  color: var(--geo-map-popup-fg);
  opacity: 0.75;
  top: 0.25rem;
  right: 0.25rem;
  width: 1.5rem;
  height: 1.5rem;
  font-size: 1rem;
  line-height: 1.5rem;
  border-radius: calc(var(--radius) - 2px);
}

.root[data-slot="geo-map"]
  :global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button:hover) {
  opacity: 1;
  background: oklch(from var(--muted) l c h / 0.65);
}

.root[data-slot="geo-map"]
  :global(
    .leaflet-popup.geo-map-popup .leaflet-popup-close-button:focus-visible
  ) {
  outline: 2px solid var(--ring);
  outline-offset: 1px;
}
components/tool-ui/geo-map/geo-map.tsx
"use client";

import dynamic from "next/dynamic";
import { memo, useEffect, useState } from "react";
import { cn } from "./_adapter";
import styles from "./geo-map-theme.module.css";
import type { GeoMapProps, GeoMapStyle } from "./schema";

// Leaflet touches `window` at module scope, so the engine must never be
// evaluated during SSR; every GeoMap consumer inherits this guard.
const GeoMapEngine = dynamic(
  () => import("./geo-map-engine").then((m) => m.GeoMapEngine),
  { ssr: false },
);

const LIGHT_TILE_URL =
  "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
const DARK_TILE_URL =
  "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";

function getSystemTheme(): "light" | "dark" {
  if (typeof window === "undefined") return "light";
  return window.matchMedia?.("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}

function getDocumentTheme(): "light" | "dark" | null {
  if (typeof document === "undefined") return null;

  const root = document.documentElement;
  const dataTheme = root.getAttribute("data-theme")?.toLowerCase();
  if (dataTheme === "dark") return "dark";
  if (dataTheme === "light") return "light";
  if (root.classList.contains("dark")) return "dark";
  if (root.classList.contains("light")) return "light";

  return null;
}

function useInheritedTheme(): "light" | "dark" {
  const [theme, setTheme] = useState<"light" | "dark">(() => {
    return getDocumentTheme() ?? getSystemTheme();
  });

  useEffect(() => {
    if (typeof window === "undefined" || typeof document === "undefined") {
      return;
    }

    const update = () => setTheme(getDocumentTheme() ?? getSystemTheme());

    const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
    mql?.addEventListener("change", update);

    const observer = new MutationObserver(update);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme"],
    });

    return () => {
      mql?.removeEventListener("change", update);
      observer.disconnect();
    };
  }, []);

  return theme;
}

function resolveMapAriaLabel(title?: string, description?: string): string {
  if (title && description) {
    return `${title}. ${description}`;
  }

  return title ?? description ?? "Geographic map";
}

export const GeoMap = memo(function GeoMap({
  id,
  role: _role,
  receipt: _receipt,
  title,
  description,
  markers,
  routes,
  clustering,
  viewport,
  showZoomControl = true,
  theme,
  className,
  style,
  tooltipClassName,
  popupClassName,
  onMarkerClick,
  onRouteClick,
}: GeoMapProps) {
  const inheritedTheme = useInheritedTheme();
  const resolvedTheme = theme ?? inheritedTheme;
  const [isMapReady, setIsMapReady] = useState(false);
  const tileUrl = resolvedTheme === "dark" ? DARK_TILE_URL : LIGHT_TILE_URL;
  const mapAriaLabel = resolveMapAriaLabel(title, description);
  const resolvedRootStyle: GeoMapStyle = {
    "--geo-map-canvas-bg":
      resolvedTheme === "dark" ? "var(--background)" : "var(--muted)",
    ...style,
  };

  return (
    <div
      className={cn("w-full min-w-80", styles.root, className)}
      style={resolvedRootStyle}
      data-slot="geo-map"
      data-tool-ui-id={id}
    >
      <div
        className="bg-muted/20 relative h-[320px] w-full overflow-hidden rounded-lg border"
        role="region"
        aria-label={mapAriaLabel}
      >
        <GeoMapEngine
          id={id}
          markers={markers}
          routes={routes}
          clustering={clustering}
          viewport={viewport}
          showZoomControl={showZoomControl}
          tileUrl={tileUrl}
          mapAriaLabel={mapAriaLabel}
          tooltipClassName={tooltipClassName}
          popupClassName={popupClassName}
          onMarkerClick={onMarkerClick}
          onRouteClick={onRouteClick}
          onReadyChange={setIsMapReady}
        />

        {(title || description) && (
          <div
            className={cn(
              "pointer-events-none absolute top-3 left-3 z-[900]",
              "max-w-[min(75%,22rem)] rounded-lg border border-border/70 bg-background/70 px-3 py-2",
              "shadow-sm backdrop-blur-md",
            )}
          >
            {title && (
              <p className="text-foreground text-sm leading-tight font-semibold">
                {title}
              </p>
            )}
            {description && (
              <p className="text-muted-foreground mt-1 text-xs leading-snug">
                {description}
              </p>
            )}
          </div>
        )}

        {!isMapReady && (
          <div
            data-slot="geo-map-loading"
            className="bg-muted/30 text-muted-foreground pointer-events-none absolute inset-0 flex items-center justify-center"
          >
            <span data-slot="geo-map-loading-label">Loading map...</span>
          </div>
        )}
      </div>
    </div>
  );
});
components/tool-ui/geo-map/index.tsx
export { GeoMap } from "./geo-map";
export {
  type GeoMapClustering,
  type GeoMapFitTarget,
  type GeoMapMarker,
  type GeoMapMarkerIcon,
  type GeoMapStyle,
  type GeoMapRoute,
  type GeoMapViewport,
  type GeoMapProps,
  type GeoMapClientProps,
  type SerializableGeoMap,
} from "./schema";
components/tool-ui/geo-map/README.md
# Geo Map

Implementation for the "geo-map" Tool UI surface.

## Files

- public exports: components/tool-ui/geo-map/index.tsx
- serializable schema + parse helpers: components/tool-ui/geo-map/schema.ts
- public facade component: components/tool-ui/geo-map/geo-map.tsx
- internal Leaflet engine: components/tool-ui/geo-map/geo-map-engine.tsx
- colocated Leaflet shell theme styles: components/tool-ui/geo-map/geo-map-theme.module.css
- icon construction helpers: components/tool-ui/geo-map/geo-map-icons.ts
- popup/tooltip overlay renderer: components/tool-ui/geo-map/geo-map-overlays.tsx

## Companion assets

- Docs page: app/docs/geo-map/content.mdx
- Preset payload: lib/presets/geo-map.ts

## Quick check

Run this after edits:

pnpm test
components/tool-ui/geo-map/schema.ts
import { z } from "zod";
import type { CSSProperties } from "react";
import { defineToolUiContract } from "../shared/contract";
import {
  ToolUIIdSchema,
  ToolUIReceiptSchema,
  ToolUIRoleSchema,
} from "../shared/schema";

const LatitudeSchema = z.number().finite().min(-90).max(90);
const LongitudeSchema = z.number().finite().min(-180).max(180);
const HttpUrlSchema = z
  .string()
  .url()
  .refine((value) => /^https?:\/\//i.test(value), {
    message: "Expected an http or https URL.",
  });

const GeoMapMarkerIconDotSchema = z.object({
  type: z.literal("dot"),
  color: z.string().optional(),
  borderColor: z.string().optional(),
  radius: z.number().min(3).max(16).optional(),
});

const GeoMapMarkerIconEmojiSchema = z.object({
  type: z.literal("emoji"),
  value: z.string().min(1),
  size: z.number().min(16).max(40).optional(),
  bgColor: z.string().optional(),
  borderColor: z.string().optional(),
});

const GeoMapMarkerIconImageSchema = z.object({
  type: z.literal("image"),
  url: HttpUrlSchema,
  width: z.number().min(16).max(64).optional(),
  height: z.number().min(16).max(64).optional(),
  borderRadius: z.number().min(0).max(999).optional(),
  borderColor: z.string().optional(),
});

export const GeoMapMarkerIconSchema = z.union([
  GeoMapMarkerIconDotSchema,
  GeoMapMarkerIconEmojiSchema,
  GeoMapMarkerIconImageSchema,
]);

export type GeoMapMarkerIcon = z.infer<typeof GeoMapMarkerIconSchema>;

export const GeoMapMarkerSchema = z.object({
  id: z.string().min(1).optional(),
  lat: LatitudeSchema,
  lng: LongitudeSchema,
  label: z.string().optional(),
  description: z.string().optional(),
  tooltip: z.enum(["none", "hover", "always"]).optional(),
  icon: GeoMapMarkerIconSchema.optional(),
});

export type GeoMapMarker = z.infer<typeof GeoMapMarkerSchema>;

export const GeoMapRoutePointSchema = z.object({
  lat: LatitudeSchema,
  lng: LongitudeSchema,
});

export const GeoMapRouteSchema = z.object({
  id: z.string().min(1).optional(),
  points: z.array(GeoMapRoutePointSchema).min(2),
  label: z.string().optional(),
  description: z.string().optional(),
  tooltip: z.enum(["none", "hover", "always"]).optional(),
  color: z.string().optional(),
  weight: z.number().min(1).max(12).optional(),
  opacity: z.number().min(0).max(1).optional(),
  dashArray: z.string().optional(),
});

export type GeoMapRoute = z.infer<typeof GeoMapRouteSchema>;

export const GeoMapClusteringSchema = z.object({
  enabled: z.boolean().optional(),
  radius: z.number().min(20).max(120).optional(),
  maxZoom: z.number().min(1).max(22).optional(),
  minPoints: z.number().min(2).max(20).optional(),
});

export type GeoMapClustering = z.infer<typeof GeoMapClusteringSchema>;

export const GeoMapFitTargetSchema = z.enum(["markers", "routes", "all"]);
export type GeoMapFitTarget = z.infer<typeof GeoMapFitTargetSchema>;

const GeoMapFitViewportSchema = z.object({
  mode: z.literal("fit"),
  padding: z.number().nonnegative().optional(),
  maxZoom: z.number().min(1).max(22).optional(),
  target: GeoMapFitTargetSchema.optional(),
});

const GeoMapCenterViewportSchema = z.object({
  mode: z.literal("center"),
  center: z.object({
    lat: LatitudeSchema,
    lng: LongitudeSchema,
  }),
  zoom: z.number().min(1).max(22),
});

export const GeoMapViewportSchema = z.union([
  GeoMapFitViewportSchema,
  GeoMapCenterViewportSchema,
]);

export type GeoMapViewport = z.infer<typeof GeoMapViewportSchema>;

export const GeoMapPropsSchema = z
  .object({
    id: ToolUIIdSchema,
    role: ToolUIRoleSchema.optional(),
    receipt: ToolUIReceiptSchema.optional(),
    title: z.string().optional(),
    description: z.string().optional(),
    markers: z.array(GeoMapMarkerSchema).min(1),
    routes: z.array(GeoMapRouteSchema).optional(),
    clustering: GeoMapClusteringSchema.optional(),
    viewport: GeoMapViewportSchema.optional(),
    showZoomControl: z.boolean().optional(),
    theme: z.enum(["light", "dark"]).optional(),
  })
  .superRefine((value, ctx) => {
    const seenMarkerIds = new Set<string>();

    value.markers.forEach((marker, index) => {
      if (!marker.id) {
        return;
      }

      if (seenMarkerIds.has(marker.id)) {
        ctx.addIssue({
          code: "custom",
          path: ["markers", index, "id"],
          message: `Duplicate marker id "${marker.id}".`,
        });
        return;
      }

      seenMarkerIds.add(marker.id);
    });

    const seenRouteIds = new Set<string>();
    value.routes?.forEach((route, index) => {
      if (!route.id) {
        return;
      }

      if (seenRouteIds.has(route.id)) {
        ctx.addIssue({
          code: "custom",
          path: ["routes", index, "id"],
          message: `Duplicate route id "${route.id}".`,
        });
        return;
      }

      seenRouteIds.add(route.id);
    });
  });

export type GeoMapStyle = CSSProperties &
  Partial<Record<`--${string}`, string | number>>;

export type GeoMapClientProps = {
  className?: string;
  style?: GeoMapStyle;
  tooltipClassName?: string;
  popupClassName?: string;
  onMarkerClick?: (marker: GeoMapMarker) => void;
  onRouteClick?: (route: GeoMapRoute) => void;
};

export type GeoMapProps = z.infer<typeof GeoMapPropsSchema> & GeoMapClientProps;

export const SerializableGeoMapSchema = GeoMapPropsSchema;

export type SerializableGeoMap = z.infer<typeof SerializableGeoMapSchema>;

const SerializableGeoMapSchemaContract = defineToolUiContract(
  "GeoMap",
  SerializableGeoMapSchema,
);

export const parseSerializableGeoMap: (input: unknown) => SerializableGeoMap =
  SerializableGeoMapSchemaContract.parse;

export const safeParseSerializableGeoMap: (
  input: unknown,
) => SerializableGeoMap | null = SerializableGeoMapSchemaContract.safeParse;
components/tool-ui/shared/contract.ts
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";

export interface ToolUiContract<T> {
  schema: z.ZodType<T>;
  parse: (input: unknown) => T;
  safeParse: (input: unknown) => T | null;
}

export function defineToolUiContract<T>(
  componentName: string,
  schema: z.ZodType<T>,
): ToolUiContract<T> {
  return {
    schema,
    parse: (input: unknown) => parseWithSchema(schema, input, componentName),
    safeParse: (input: unknown) => safeParseWithSchema(schema, input),
  };
}
components/tool-ui/shared/parse.ts
import { z } from "zod";

function formatZodPath(path: Array<string | number | symbol>): string {
  if (path.length === 0) return "root";
  return path
    .map((segment) =>
      typeof segment === "number" ? `[${segment}]` : String(segment),
    )
    .join(".");
}

/**
 * Format Zod errors into a compact `path: message` string.
 */
export function formatZodError(error: z.ZodError): string {
  const parts = error.issues.map((issue) => {
    const path = formatZodPath(issue.path);
    return `${path}: ${issue.message}`;
  });

  return Array.from(new Set(parts)).join("; ");
}

/**
 * Parse unknown input and throw a readable error.
 */
export function parseWithSchema<T>(
  schema: z.ZodType<T>,
  input: unknown,
  name: string,
): T {
  const res = schema.safeParse(input);
  if (!res.success) {
    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
  }
  return res.data;
}

/**
 * Parse unknown input, returning `null` instead of throwing on failure.
 *
 * Use this in assistant-ui `render` functions where `args` stream in
 * incrementally and may be incomplete until the tool call finishes.
 */
export function safeParseWithSchema<T>(
  schema: z.ZodType<T>,
  input: unknown,
): T | null {
  const res = schema.safeParse(input);
  return res.success ? res.data : null;
}
components/tool-ui/shared/schema.ts
import { z } from "zod";
import type { ReactNode } from "react";

/**
 * Tool UI conventions:
 * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
 * - Schema: `SerializableXSchema`
 * - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
 * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
 * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
 * - Root attrs: `data-tool-ui-id` + `data-slot`
 */

/**
 * Schema for tool UI identity.
 *
 * Every tool UI should have a unique identifier that:
 * - Is stable across re-renders
 * - Is meaningful (not auto-generated)
 * - Is unique within the conversation
 *
 * Format recommendation: `{component-type}-{semantic-identifier}`
 * Examples: "data-table-expenses-q3", "option-list-deploy-target"
 */
export const ToolUIIdSchema = z.string().min(1);

export type ToolUIId = z.infer<typeof ToolUIIdSchema>;

/**
 * Primary role of a Tool UI surface in a chat context.
 */
export const ToolUIRoleSchema = z.enum([
  "information",
  "decision",
  "control",
  "state",
  "composite",
]);

export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;

export const ToolUIReceiptOutcomeSchema = z.enum([
  "success",
  "partial",
  "failed",
  "cancelled",
]);

export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;

/**
 * Optional receipt metadata: a durable summary of an outcome.
 */
export const ToolUIReceiptSchema = z.object({
  outcome: ToolUIReceiptOutcomeSchema,
  summary: z.string().min(1),
  identifiers: z.record(z.string(), z.string()).optional(),
  at: z.string().datetime(),
});

export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;

/**
 * Base schema for Tool UI payloads (id + optional role/receipt).
 */
export const ToolUISurfaceSchema = z.object({
  id: ToolUIIdSchema,
  role: ToolUIRoleSchema.optional(),
  receipt: ToolUIReceiptSchema.optional(),
});

export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;

export const ActionSchema = z.object({
  id: z.string().min(1),
  label: z.string().min(1),
  /**
   * Canonical narration the assistant can use after this action is taken.
   *
   * Example: "I exported the table as CSV." / "I opened the link in a new tab."
   */
  sentence: z.string().optional(),
  confirmLabel: z.string().optional(),
  variant: z
    .enum(["default", "destructive", "secondary", "ghost", "outline"])
    .optional(),
  icon: z.custom<ReactNode>().optional(),
  loading: z.boolean().optional(),
  disabled: z.boolean().optional(),
  shortcut: z.string().optional(),
});

export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;

export const DecisionResultSchema = z.object({
  kind: z.literal("decision"),
  version: z.literal(1),
  decisionId: z.string().min(1),
  actionId: z.string().min(1),
  actionLabel: z.string().min(1),
  at: z.string().datetime(),
  payload: z.record(z.string(), z.unknown()).optional(),
});

export type DecisionResult<
  TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
  payload?: TPayload;
};

export function createDecisionResult<
  TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
  decisionId: string;
  action: { id: string; label: string };
  payload?: TPayload;
}): DecisionResult<TPayload> {
  return {
    kind: "decision",
    version: 1,
    decisionId: args.decisionId,
    actionId: args.action.id,
    actionLabel: args.action.label,
    at: new Date().toISOString(),
    payload: args.payload,
  };
}

export const ActionButtonsPropsSchema = z.object({
  actions: z.array(ActionSchema).min(1),
  align: z.enum(["left", "center", "right"]).optional(),
  confirmTimeout: z.number().positive().optional(),
  className: z.string().optional(),
});

export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
  actions: z.array(SerializableActionSchema),
}).omit({ className: true });

export interface ActionsConfig {
  items: Action[];
  align?: "left" | "center" | "right";
  confirmTimeout?: number;
}

export const SerializableActionsConfigSchema = z.object({
  items: z.array(SerializableActionSchema).min(1),
  align: z.enum(["left", "center", "right"]).optional(),
  confirmTimeout: z.number().positive().optional(),
});

export type SerializableActionsConfig = z.infer<
  typeof SerializableActionsConfigSchema
>;

export type SerializableAction = z.infer<typeof SerializableActionSchema>;

More from tool-ui

Browse all components in this library →