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 · mediumframeworknext
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 =
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
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>;