(
+ () => [
+ {
+ id: hitLayerId,
+ type: 'line',
+ source: sourceId,
+ layout: DEFAULT_ARC_LAYOUT,
+ paint: {
+ 'line-color': 'rgba(0, 0, 0, 0)',
+ 'line-width': hitWidth,
+ 'line-opacity': 1,
+ },
+ },
+ {
+ id: layerId,
+ type: 'line',
+ source: sourceId,
+ layout: mergedLayout,
+ paint: mergedPaint,
+ },
+ ],
+ [hitLayerId, hitWidth, layerId, mergedLayout, mergedPaint, sourceId],
+ );
+ useGeoJSONLayerGroup({
+ sourceId,
+ data: geoJSON,
+ sourceOptions: { promoteId: 'id' },
+ layers,
+ beforeId,
+ });
+
+ const latest = useLatest({ data, onClick, onHover });
+
+ // Interaction handlers
+ useEffect(() => {
+ if (!isLoaded || !map || !interactive) return;
+
+ let hoveredId: string | number | null = null;
+
+ const setHover = (next: string | number | null) => {
+ if (next === hoveredId) return;
+ const sourceExists = !!map.getSource(sourceId);
+ if (hoveredId != null && sourceExists) {
+ map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false });
+ }
+ hoveredId = next;
+ if (next != null && sourceExists) {
+ map.setFeatureState({ source: sourceId, id: next }, { hover: true });
+ }
+ };
+
+ const findArc = (featureId: string | number | undefined) =>
+ featureId == null ? undefined : latest.current.data.find((arc) => String(arc.id) === String(featureId));
+
+ const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
+ const featureId = e.features?.[0]?.id as string | number | undefined;
+ if (featureId == null || featureId === hoveredId) return;
+
+ setHover(featureId);
+ map.getCanvas().style.cursor = 'pointer';
+
+ const arc = findArc(featureId);
+ if (arc) {
+ latest.current.onHover?.({
+ arc: arc as T,
+ longitude: e.lngLat.lng,
+ latitude: e.lngLat.lat,
+ originalEvent: e,
+ });
+ }
+ };
+
+ const handleMouseLeave = () => {
+ setHover(null);
+ map.getCanvas().style.cursor = '';
+ latest.current.onHover?.(null);
+ };
+
+ const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
+ const arc = findArc(e.features?.[0]?.id as string | number | undefined);
+ if (!arc) return;
+ latest.current.onClick?.({
+ arc: arc as T,
+ longitude: e.lngLat.lng,
+ latitude: e.lngLat.lat,
+ originalEvent: e,
+ });
+ };
+
+ map.on('mousemove', hitLayerId, handleMouseMove);
+ map.on('mouseleave', hitLayerId, handleMouseLeave);
+ map.on('click', hitLayerId, handleClick);
+
+ return () => {
+ map.off('mousemove', hitLayerId, handleMouseMove);
+ map.off('mouseleave', hitLayerId, handleMouseLeave);
+ map.off('click', hitLayerId, handleClick);
+ setHover(null);
+ map.getCanvas().style.cursor = '';
+ };
+ }, [hitLayerId, interactive, isLoaded, latest, map, sourceId]);
+
+ return null;
+}
+
+export { MapArc };
+export type { MapArcDatum, MapArcEvent, MapArcProps };
diff --git a/packages/ui/src/components/map/map-cluster.tsx b/packages/ui/src/components/map/map-cluster.tsx
new file mode 100644
index 0000000..d7eeeeb
--- /dev/null
+++ b/packages/ui/src/components/map/map-cluster.tsx
@@ -0,0 +1,223 @@
+'use client';
+
+import type * as GeoJSON from 'geojson';
+import type * as MapLibreGL from 'maplibre-gl';
+import { useEffect, useId, useMemo } from 'react';
+
+import { useMap } from './context';
+import { asError, resolveMapColor, useLatest } from './internal';
+import { useGeoJSONLayerGroup } from './layer-lifecycle';
+
+type MapClusterLayerProps = {
+ /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */
+ data: string | GeoJSON.FeatureCollection;
+ /** Maximum zoom level to cluster points on (default: 14) */
+ clusterMaxZoom?: number;
+ /** Radius of each cluster when clustering points in pixels (default: 50) */
+ clusterRadius?: number;
+ /** Colors for cluster circles: [small, medium, large]. Defaults to semantic map accent tokens. */
+ clusterColors?: [string, string, string];
+ /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */
+ clusterThresholds?: [number, number];
+ /** Color for unclustered individual points. Defaults to the semantic map accent token. */
+ pointColor?: string;
+ /** Callback when an unclustered point is clicked */
+ onPointClick?: (feature: GeoJSON.Feature, coordinates: [number, number]) => void;
+ /** Callback when a cluster is clicked. If not provided, zooms into the cluster */
+ onClusterClick?: (clusterId: number, coordinates: [number, number], pointCount: number) => void;
+};
+
+const DEFAULT_CLUSTER_THRESHOLDS: [number, number] = [100, 750];
+
+function MapClusterLayer({
+ data,
+ clusterMaxZoom = 14,
+ clusterRadius = 50,
+ clusterColors,
+ clusterThresholds = DEFAULT_CLUSTER_THRESHOLDS,
+ pointColor,
+ onPointClick,
+ onClusterClick,
+}: MapClusterLayerProps
): null {
+ const { map, maplibre, isLoaded, resolvedTheme } = useMap();
+ const id = useId();
+ const sourceId = `cluster-source-${id}`;
+ const clusterLayerId = `clusters-${id}`;
+ const clusterCountLayerId = `cluster-count-${id}`;
+ const unclusteredLayerId = `unclustered-point-${id}`;
+
+ const resolvedClusterColors = useMemo<[string, string, string]>(
+ () =>
+ clusterColors ?? [
+ resolveMapColor(map, 'accent', resolvedTheme),
+ resolveMapColor(map, 'accent-medium', resolvedTheme),
+ resolveMapColor(map, 'accent-strong', resolvedTheme),
+ ],
+ [clusterColors, map, resolvedTheme],
+ );
+ const resolvedPointColor = pointColor ?? resolveMapColor(map, 'accent', resolvedTheme);
+ const clusterBorderColor = resolveMapColor(map, 'surface-border', resolvedTheme);
+ const clusterTextColor = resolveMapColor(map, 'on-accent', resolvedTheme);
+
+ const layers = useMemo(
+ () => [
+ {
+ id: clusterLayerId,
+ type: 'circle',
+ source: sourceId,
+ filter: ['has', 'point_count'],
+ paint: {
+ 'circle-color': [
+ 'step',
+ ['get', 'point_count'],
+ resolvedClusterColors[0],
+ clusterThresholds[0],
+ resolvedClusterColors[1],
+ clusterThresholds[1],
+ resolvedClusterColors[2],
+ ],
+ 'circle-radius': ['step', ['get', 'point_count'], 20, clusterThresholds[0], 30, clusterThresholds[1], 40],
+ 'circle-stroke-width': 0.75,
+ 'circle-stroke-color': clusterBorderColor,
+ 'circle-opacity': 0.85,
+ },
+ },
+ {
+ id: clusterCountLayerId,
+ type: 'symbol',
+ source: sourceId,
+ filter: ['has', 'point_count'],
+ layout: {
+ 'text-field': '{point_count_abbreviated}',
+ 'text-font': ['Open Sans Semibold'],
+ 'text-size': 12,
+ },
+ paint: { 'text-color': clusterTextColor },
+ },
+ {
+ id: unclusteredLayerId,
+ type: 'circle',
+ source: sourceId,
+ filter: ['!', ['has', 'point_count']],
+ paint: {
+ 'circle-color': resolvedPointColor,
+ 'circle-radius': 5,
+ 'circle-stroke-width': 2,
+ 'circle-stroke-color': clusterBorderColor,
+ },
+ },
+ ],
+ [
+ clusterBorderColor,
+ clusterCountLayerId,
+ clusterLayerId,
+ clusterTextColor,
+ clusterThresholds,
+ resolvedClusterColors,
+ resolvedPointColor,
+ sourceId,
+ unclusteredLayerId,
+ ],
+ );
+ useGeoJSONLayerGroup({
+ sourceId,
+ data,
+ sourceOptions: { cluster: true, clusterMaxZoom, clusterRadius },
+ layers,
+ });
+
+ const callbacks = useLatest({ onClusterClick, onPointClick });
+
+ // Handle click events
+ useEffect(() => {
+ if (!isLoaded || !map) return;
+
+ let cancelled = false;
+
+ const handleClusterClick = async (
+ e: MapLibreGL.MapMouseEvent & {
+ features?: MapLibreGL.MapGeoJSONFeature[];
+ },
+ ) => {
+ const features = map.queryRenderedFeatures(e.point, {
+ layers: [clusterLayerId],
+ });
+ if (!features.length) return;
+
+ const feature = features[0];
+ const clusterId = feature.properties?.cluster_id as number;
+ const pointCount = feature.properties?.point_count as number;
+ const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [number, number];
+
+ if (callbacks.current.onClusterClick) {
+ callbacks.current.onClusterClick(clusterId, coordinates, pointCount);
+ } else {
+ try {
+ const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
+ const zoom = await source.getClusterExpansionZoom(clusterId);
+ if (!cancelled) map.easeTo({ center: coordinates, zoom });
+ } catch (value) {
+ if (!cancelled && maplibre) map.fire(new maplibre.ErrorEvent(asError(value)));
+ }
+ }
+ };
+
+ // Unclustered point click handler
+ const handlePointClick = (
+ e: MapLibreGL.MapMouseEvent & {
+ features?: MapLibreGL.MapGeoJSONFeature[];
+ },
+ ) => {
+ if (!callbacks.current.onPointClick || !e.features?.length) return;
+
+ const feature = e.features[0];
+ const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [number, number];
+
+ // Handle world copies
+ while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
+ coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
+ }
+
+ callbacks.current.onPointClick(feature as unknown as GeoJSON.Feature, coordinates);
+ };
+
+ // Cursor style handlers
+ const handleMouseEnterCluster = () => {
+ map.getCanvas().style.cursor = 'pointer';
+ };
+ const handleMouseLeaveCluster = () => {
+ map.getCanvas().style.cursor = '';
+ };
+ const handleMouseEnterPoint = () => {
+ if (callbacks.current.onPointClick) {
+ map.getCanvas().style.cursor = 'pointer';
+ }
+ };
+ const handleMouseLeavePoint = () => {
+ map.getCanvas().style.cursor = '';
+ };
+
+ map.on('click', clusterLayerId, handleClusterClick);
+ map.on('click', unclusteredLayerId, handlePointClick);
+ map.on('mouseenter', clusterLayerId, handleMouseEnterCluster);
+ map.on('mouseleave', clusterLayerId, handleMouseLeaveCluster);
+ map.on('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
+ map.on('mouseleave', unclusteredLayerId, handleMouseLeavePoint);
+
+ return () => {
+ cancelled = true;
+ map.off('click', clusterLayerId, handleClusterClick);
+ map.off('click', unclusteredLayerId, handlePointClick);
+ map.off('mouseenter', clusterLayerId, handleMouseEnterCluster);
+ map.off('mouseleave', clusterLayerId, handleMouseLeaveCluster);
+ map.off('mouseenter', unclusteredLayerId, handleMouseEnterPoint);
+ map.off('mouseleave', unclusteredLayerId, handleMouseLeavePoint);
+ map.getCanvas().style.cursor = '';
+ };
+ }, [callbacks, isLoaded, map, maplibre, clusterLayerId, unclusteredLayerId, sourceId]);
+
+ return null;
+}
+
+export { MapClusterLayer };
+export type { MapClusterLayerProps };
diff --git a/packages/ui/src/components/map/map-controls.tsx b/packages/ui/src/components/map/map-controls.tsx
new file mode 100644
index 0000000..cc81cd9
--- /dev/null
+++ b/packages/ui/src/components/map/map-controls.tsx
@@ -0,0 +1,202 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type * as React from 'react';
+import { LoaderCircleIcon, LocateIcon, MaximizeIcon, MinusIcon, PlusIcon } from 'lucide-react';
+
+import { cn } from '../../lib/utils';
+import { Button } from '../button';
+import { useMap } from './context';
+
+type MapControlsProps = {
+ /** Position of the controls on the map (default: "bottom-right") */
+ position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
+ /** Show zoom in/out buttons (default: true) */
+ showZoom?: boolean;
+ /** Show compass button to reset bearing (default: false) */
+ showCompass?: boolean;
+ /** Show locate button to find user's location (default: false) */
+ showLocate?: boolean;
+ /** Show fullscreen toggle button (default: false) */
+ showFullscreen?: boolean;
+ /** Additional CSS classes for the controls container */
+ className?: string;
+ /** Callback with user coordinates when located */
+ onLocate?: (coords: { longitude: number; latitude: number }) => void;
+};
+
+const positionClasses = {
+ 'top-left': 'top-2 left-2',
+ 'top-right': 'top-2 right-2',
+ 'bottom-left': 'bottom-2 left-2',
+ 'bottom-right': 'bottom-10 right-2',
+};
+
+function ControlGroup({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function ControlButton({
+ onClick,
+ label,
+ children,
+ disabled = false,
+}: {
+ onClick: () => void;
+ label: string;
+ children: React.ReactNode;
+ disabled?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function MapControls({
+ position = 'bottom-right',
+ showZoom = true,
+ showCompass = false,
+ showLocate = false,
+ showFullscreen = false,
+ className,
+ onLocate,
+}: MapControlsProps) {
+ const { map } = useMap();
+ const [waitingForLocation, setWaitingForLocation] = useState(false);
+
+ const handleZoomIn = useCallback(() => {
+ map?.zoomTo(map.getZoom() + 1, { duration: 300 });
+ }, [map]);
+
+ const handleZoomOut = useCallback(() => {
+ map?.zoomTo(map.getZoom() - 1, { duration: 300 });
+ }, [map]);
+
+ const handleResetBearing = useCallback(() => {
+ map?.resetNorthPitch({ duration: 300 });
+ }, [map]);
+
+ const handleLocate = useCallback(() => {
+ if (!('geolocation' in navigator)) return;
+ setWaitingForLocation(true);
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const coords = {
+ longitude: pos.coords.longitude,
+ latitude: pos.coords.latitude,
+ };
+ map?.flyTo({
+ center: [coords.longitude, coords.latitude],
+ zoom: 14,
+ duration: 1500,
+ });
+ onLocate?.(coords);
+ setWaitingForLocation(false);
+ },
+ (error) => {
+ console.error('Error getting location:', error);
+ setWaitingForLocation(false);
+ },
+ // Without a timeout the spec default is Infinity: a dismissed permission
+ // prompt would leave the button disabled forever.
+ { timeout: 10000 },
+ );
+ }, [map, onLocate]);
+
+ const handleFullscreen = useCallback(() => {
+ const container = map?.getContainer();
+ if (!container) return;
+ if (document.fullscreenElement) {
+ document.exitFullscreen();
+ } else {
+ container.requestFullscreen();
+ }
+ }, [map]);
+
+ return (
+
+ {showZoom && (
+
+
+
+
+
+
+
+
+ )}
+ {showCompass && (
+
+
+
+ )}
+ {showLocate && (
+
+
+ {waitingForLocation ? : }
+
+
+ )}
+ {showFullscreen && (
+
+
+
+
+
+ )}
+
+ );
+}
+
+function CompassButton({ onClick }: { onClick: () => void }) {
+ const { map } = useMap();
+ const compassRef = useRef(null);
+
+ useEffect(() => {
+ if (!map || !compassRef.current) return;
+
+ const compass = compassRef.current;
+
+ const updateRotation = () => {
+ const bearing = map.getBearing();
+ const pitch = map.getPitch();
+ compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;
+ };
+
+ map.on('rotate', updateRotation);
+ map.on('pitch', updateRotation);
+ updateRotation();
+
+ return () => {
+ map.off('rotate', updateRotation);
+ map.off('pitch', updateRotation);
+ };
+ }, [map]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export { MapControls };
+export type { MapControlsProps };
diff --git a/packages/ui/src/components/map/map-geojson.tsx b/packages/ui/src/components/map/map-geojson.tsx
new file mode 100644
index 0000000..b6d57fd
--- /dev/null
+++ b/packages/ui/src/components/map/map-geojson.tsx
@@ -0,0 +1,217 @@
+'use client';
+
+import type * as GeoJSON from 'geojson';
+import type * as MapLibreGL from 'maplibre-gl';
+import { useEffect, useId, useMemo } from 'react';
+
+import { useMap } from './context';
+import { mergeHoverPaint, resolveMapColor, useLatest } from './internal';
+import { useGeoJSONLayerGroup } from './layer-lifecycle';
+
+type MapGeoJSONData =
+ | GeoJSON.FeatureCollection
+ | GeoJSON.Feature
+ | GeoJSON.Geometry
+ | string;
+
+type MapFillPaint = NonNullable;
+type MapLinePaint = NonNullable;
+
+/** A rendered feature with strongly-typed `properties`. */
+type MapGeoJSONFeature = Omit<
+ MapLibreGL.MapGeoJSONFeature,
+ 'properties'
+> & { properties: P };
+
+/** Event payload passed to MapGeoJSON interaction callbacks. */
+type MapGeoJSONEvent
= {
+ /** The feature under the cursor, with its typed GeoJSON properties. */
+ feature: MapGeoJSONFeature
;
+ /** Longitude of the cursor at the time of the event. */
+ longitude: number;
+ /** Latitude of the cursor at the time of the event. */
+ latitude: number;
+ /** The underlying MapLibre mouse event for advanced use cases. */
+ originalEvent: MapLibreGL.MapLayerMouseEvent;
+};
+
+type MapGeoJSONProps
= {
+ /** GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it from. */
+ data: MapGeoJSONData
;
+ /** Optional unique identifier prefix for the source/layers. Auto-generated if not provided. */
+ id?: string;
+ /**
+ * Feature property to promote to the feature `id`. Required for hover
+ * feature-state (`fillHoverPaint`) and stable `onHover`/`onClick` payloads.
+ */
+ promoteId?: string;
+ /**
+ * Paint for the polygon fill layer. Merged on top of a theme-aware monochrome
+ * surface tone (`fill-color`). Pass `false` to omit the fill layer entirely
+ * (e.g. outlines only).
+ */
+ fillPaint?: MapFillPaint | false;
+ /**
+ * Paint for the outline layer. Merged on top of a hairline default
+ * (`line-color` = a near-surface neutral, `line-width` = 0.5) for thin
+ * separators. Override `line-color` if your container differs, or pass
+ * `false` to omit the layer.
+ */
+ linePaint?: MapLinePaint | false;
+ /**
+ * Paint merged onto the fill layer for the feature under the cursor, applied
+ * as a `case` expression keyed on hover feature-state. Requires `promoteId`.
+ */
+ fillHoverPaint?: MapFillPaint;
+ /** Callback when a feature is clicked. */
+ onClick?: (e: MapGeoJSONEvent
) => void;
+ /** Callback fired when the hovered feature changes; `null` when the cursor leaves. */
+ onHover?: (e: MapGeoJSONEvent
| null) => void;
+ /** Whether features respond to mouse events (default: false). */
+ interactive?: boolean;
+ /** Optional MapLibre layer id to insert the layers before (z-order control). */
+ beforeId?: string;
+};
+
+/**
+ * Renders arbitrary GeoJSON as fill + outline layers on the map. Composes like
+ * `MapRoute` / `MapArc` — drop it inside `` (typically with `blank`) for
+ * choropleths and region/data maps. For full control over expressions and
+ * multiple layers, manage layers directly via `useMap()` instead.
+ */
+function MapGeoJSON({
+ data,
+ id: propId,
+ promoteId,
+ fillPaint,
+ linePaint,
+ fillHoverPaint,
+ onClick,
+ onHover,
+ interactive = false,
+ beforeId,
+}: MapGeoJSONProps
): null {
+ const { map, isLoaded, resolvedTheme } = useMap();
+ const autoId = useId();
+ const id = propId ?? autoId;
+ const sourceId = `geojson-source-${id}`;
+ const fillLayerId = `geojson-fill-${id}`;
+ const lineLayerId = `geojson-line-${id}`;
+
+ const defaults = useMemo(
+ () => ({
+ fill: resolveMapColor(map, 'surface', resolvedTheme),
+ line: resolveMapColor(map, 'surface-border', resolvedTheme),
+ }),
+ [map, resolvedTheme],
+ );
+
+ const showFill = fillPaint !== false;
+ const showLine = linePaint !== false;
+
+ const mergedFillPaint = useMemo(
+ () => mergeHoverPaint({ 'fill-color': defaults.fill, 'fill-opacity': 1, ...(fillPaint || {}) }, fillHoverPaint),
+ [defaults.fill, fillPaint, fillHoverPaint],
+ );
+ const mergedLinePaint = useMemo(
+ () => ({
+ 'line-color': defaults.line,
+ 'line-width': 0.5,
+ ...(linePaint || {}),
+ }),
+ [defaults.line, linePaint],
+ );
+ const sourceOptions = useMemo(() => (promoteId ? { promoteId } : undefined), [promoteId]);
+ const layers = useMemo(() => {
+ const next: MapLibreGL.LayerSpecification[] = [];
+ if (showFill) {
+ next.push({
+ id: fillLayerId,
+ type: 'fill',
+ source: sourceId,
+ paint: mergedFillPaint,
+ });
+ }
+ if (showLine) {
+ next.push({
+ id: lineLayerId,
+ type: 'line',
+ source: sourceId,
+ paint: mergedLinePaint,
+ });
+ }
+ return next;
+ }, [fillLayerId, lineLayerId, mergedFillPaint, mergedLinePaint, showFill, showLine, sourceId]);
+ useGeoJSONLayerGroup({ sourceId, data, sourceOptions, layers, beforeId });
+
+ const callbacks = useLatest({ onClick, onHover });
+
+ // Interaction handlers (bound to the fill layer).
+ useEffect(() => {
+ if (!isLoaded || !map || !interactive || !showFill) return;
+
+ let hoveredId: string | number | null = null;
+
+ const setHover = (next: string | number | null) => {
+ if (next === hoveredId) return;
+ const sourceExists = !!map.getSource(sourceId);
+ if (hoveredId != null && sourceExists) {
+ map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false });
+ }
+ hoveredId = next;
+ if (next != null && sourceExists) {
+ map.setFeatureState({ source: sourceId, id: next }, { hover: true });
+ }
+ };
+
+ const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
+ const feature = e.features?.[0];
+ if (!feature) return;
+ map.getCanvas().style.cursor = 'pointer';
+
+ const featureId = feature.id;
+ if (featureId === hoveredId) return;
+ setHover(featureId ?? null);
+ callbacks.current.onHover?.({
+ feature: feature as unknown as MapGeoJSONFeature,
+ longitude: e.lngLat.lng,
+ latitude: e.lngLat.lat,
+ originalEvent: e,
+ });
+ };
+
+ const handleMouseLeave = () => {
+ setHover(null);
+ map.getCanvas().style.cursor = '';
+ callbacks.current.onHover?.(null);
+ };
+
+ const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
+ const feature = e.features?.[0];
+ if (!feature) return;
+ callbacks.current.onClick?.({
+ feature: feature as unknown as MapGeoJSONFeature
,
+ longitude: e.lngLat.lng,
+ latitude: e.lngLat.lat,
+ originalEvent: e,
+ });
+ };
+
+ map.on('mousemove', fillLayerId, handleMouseMove);
+ map.on('mouseleave', fillLayerId, handleMouseLeave);
+ map.on('click', fillLayerId, handleClick);
+
+ return () => {
+ map.off('mousemove', fillLayerId, handleMouseMove);
+ map.off('mouseleave', fillLayerId, handleMouseLeave);
+ map.off('click', fillLayerId, handleClick);
+ setHover(null);
+ map.getCanvas().style.cursor = '';
+ };
+ }, [callbacks, fillLayerId, interactive, isLoaded, map, showFill, sourceId]);
+
+ return null;
+}
+
+export { MapGeoJSON };
+export type { MapGeoJSONData, MapGeoJSONFeature, MapGeoJSONEvent, MapGeoJSONProps };
diff --git a/packages/ui/src/components/map/map-marker.tsx b/packages/ui/src/components/map/map-marker.tsx
new file mode 100644
index 0000000..34107a7
--- /dev/null
+++ b/packages/ui/src/components/map/map-marker.tsx
@@ -0,0 +1,389 @@
+'use client';
+
+import type * as MapLibreGL from 'maplibre-gl';
+import type { MarkerOptions, PopupOptions } from 'maplibre-gl';
+import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+import { XIcon } from 'lucide-react';
+
+import { cn } from '../../lib/utils';
+import { Button } from '../button';
+import { useMap, type MapLibreModule } from './context';
+import { useLatest } from './internal';
+
+type MarkerContextValue = {
+ marker: MapLibreGL.Marker;
+ map: MapLibreGL.Map | null;
+ maplibre: MapLibreModule;
+};
+
+const MarkerContext = createContext(null);
+
+function useMarkerContext() {
+ const context = useContext(MarkerContext);
+ if (!context) {
+ throw new Error('Marker components must be used within MapMarker');
+ }
+ return context;
+}
+
+type MapMarkerProps = {
+ /** Longitude coordinate for marker position */
+ longitude: number;
+ /** Latitude coordinate for marker position */
+ latitude: number;
+ /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */
+ children: ReactNode;
+ /** Callback when marker is clicked */
+ onClick?: (e: MouseEvent) => void;
+ /** Accessible name used when the marker is clickable. */
+ ariaLabel?: string;
+ /** Callback when mouse enters marker */
+ onMouseEnter?: (e: MouseEvent) => void;
+ /** Callback when mouse leaves marker */
+ onMouseLeave?: (e: MouseEvent) => void;
+ /** Callback when marker drag starts (requires draggable: true) */
+ onDragStart?: (lngLat: { lng: number; lat: number }) => void;
+ /** Callback during marker drag (requires draggable: true) */
+ onDrag?: (lngLat: { lng: number; lat: number }) => void;
+ /** Callback when marker drag ends (requires draggable: true) */
+ onDragEnd?: (lngLat: { lng: number; lat: number }) => void;
+} & Omit;
+
+function MapMarker({
+ longitude,
+ latitude,
+ children,
+ onClick,
+ ariaLabel,
+ onMouseEnter,
+ onMouseLeave,
+ onDragStart,
+ onDrag,
+ onDragEnd,
+ draggable = false,
+ ...markerOptions
+}: MapMarkerProps) {
+ const { map, maplibre } = useMap();
+ if (!maplibre) throw new Error('MapLibre is not loaded.');
+
+ const callbacksRef = useLatest({
+ onClick,
+ onMouseEnter,
+ onMouseLeave,
+ onDragStart,
+ onDrag,
+ onDragEnd,
+ });
+ const initialOptions = useRef({ markerOptions, draggable, longitude, latitude });
+
+ const marker = useMemo(() => {
+ const initial = initialOptions.current;
+ const markerInstance = new maplibre.Marker({
+ ...initial.markerOptions,
+ element: document.createElement('div'),
+ draggable: initial.draggable,
+ }).setLngLat([initial.longitude, initial.latitude]);
+
+ return markerInstance;
+ }, [maplibre]);
+
+ useEffect(() => {
+ const element = marker.getElement();
+
+ const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);
+ const handleMouseEnter = (e: MouseEvent) => callbacksRef.current.onMouseEnter?.(e);
+ const handleMouseLeave = (e: MouseEvent) => callbacksRef.current.onMouseLeave?.(e);
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (callbacksRef.current.onClick && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ element.click();
+ }
+ };
+
+ element.addEventListener('click', handleClick);
+ element.addEventListener('mouseenter', handleMouseEnter);
+ element.addEventListener('mouseleave', handleMouseLeave);
+ element.addEventListener('keydown', handleKeyDown);
+
+ const handleDragStart = () => {
+ const lngLat = marker.getLngLat();
+ callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });
+ };
+ const handleDrag = () => {
+ const lngLat = marker.getLngLat();
+ callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });
+ };
+ const handleDragEnd = () => {
+ const lngLat = marker.getLngLat();
+ callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });
+ };
+
+ marker.on('dragstart', handleDragStart);
+ marker.on('drag', handleDrag);
+ marker.on('dragend', handleDragEnd);
+
+ return () => {
+ element.removeEventListener('click', handleClick);
+ element.removeEventListener('mouseenter', handleMouseEnter);
+ element.removeEventListener('mouseleave', handleMouseLeave);
+ element.removeEventListener('keydown', handleKeyDown);
+ marker.off('dragstart', handleDragStart);
+ marker.off('drag', handleDrag);
+ marker.off('dragend', handleDragEnd);
+ };
+ }, [callbacksRef, marker]);
+
+ useEffect(() => {
+ if (!map) return;
+
+ marker.addTo(map);
+
+ return () => {
+ marker.remove();
+ };
+ }, [map, marker]);
+
+ const { offset, rotation, rotationAlignment, pitchAlignment } = markerOptions;
+
+ useEffect(() => {
+ const current = marker.getLngLat();
+ if (current.lng !== longitude || current.lat !== latitude) {
+ marker.setLngLat([longitude, latitude]);
+ }
+
+ if (marker.isDraggable() !== draggable) {
+ marker.setDraggable(draggable);
+ }
+
+ const currentOffset = marker.getOffset();
+ const newOffset = offset ?? [0, 0];
+ const [newOffsetX, newOffsetY] = Array.isArray(newOffset) ? newOffset : [newOffset.x, newOffset.y];
+ if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {
+ marker.setOffset(newOffset);
+ }
+
+ if (marker.getRotation() !== (rotation ?? 0)) {
+ marker.setRotation(rotation ?? 0);
+ }
+ if (marker.getRotationAlignment() !== (rotationAlignment ?? 'auto')) {
+ marker.setRotationAlignment(rotationAlignment ?? 'auto');
+ }
+ if (marker.getPitchAlignment() !== (pitchAlignment ?? 'auto')) {
+ marker.setPitchAlignment(pitchAlignment ?? 'auto');
+ }
+ }, [marker, longitude, latitude, draggable, offset, rotation, rotationAlignment, pitchAlignment]);
+
+ useEffect(() => {
+ const element = marker.getElement();
+ if (onClick) {
+ element.tabIndex = 0;
+ element.setAttribute('role', 'button');
+ element.setAttribute('aria-label', ariaLabel ?? 'Map marker');
+ return;
+ }
+ element.removeAttribute('tabindex');
+ element.removeAttribute('role');
+ element.removeAttribute('aria-label');
+ }, [ariaLabel, marker, onClick]);
+
+ return {children} ;
+}
+
+type MarkerContentProps = {
+ /** Custom marker content. Defaults to a primary-colored dot if not provided */
+ children?: ReactNode;
+ /** Additional CSS classes for the marker container */
+ className?: string;
+};
+
+function MarkerContent({ children, className }: MarkerContentProps) {
+ const { marker } = useMarkerContext();
+
+ return createPortal(
+
+ {children ?? }
+
,
+ marker.getElement(),
+ );
+}
+
+function DefaultMarkerIcon() {
+ return (
+
+ );
+}
+
+function PopupCloseButton({ onClick }: { onClick: () => void }) {
+ return (
+
+
+
+ );
+}
+
+type MarkerPopupProps = {
+ /** Popup content */
+ children: ReactNode;
+ /** Additional CSS classes for the popup container */
+ className?: string;
+ /** Show a close button in the popup (default: false) */
+ closeButton?: boolean;
+} & Omit;
+
+function MarkerPopup({ children, className, closeButton = false, ...popupOptions }: MarkerPopupProps) {
+ const { marker, map, maplibre } = useMarkerContext();
+ const container = useMemo(() => document.createElement('div'), []);
+ const { offset, maxWidth } = popupOptions;
+ const initialPopupOptions = useRef(popupOptions);
+
+ const popup = useMemo(() => {
+ const popupInstance = new maplibre.Popup({
+ offset: 16,
+ ...initialPopupOptions.current,
+ closeButton: false,
+ })
+ .setMaxWidth('none')
+ .setDOMContent(container);
+ return popupInstance;
+ }, [container, maplibre]);
+
+ useEffect(() => {
+ if (!map) return;
+
+ popup.setDOMContent(container);
+ marker.setPopup(popup);
+
+ return () => {
+ marker.setPopup(null);
+ };
+ }, [container, map, marker, popup]);
+
+ // Sync popup options when they change.
+ useEffect(() => {
+ popup.setOffset(offset ?? 16);
+ popup.setMaxWidth(maxWidth ?? 'none');
+ }, [popup, offset, maxWidth]);
+
+ const handleClose = () => popup.remove();
+
+ return createPortal(
+
+ {closeButton &&
}
+ {children}
+
,
+ container,
+ );
+}
+
+type MarkerTooltipProps = {
+ /** Tooltip content */
+ children: ReactNode;
+ /** Additional CSS classes for the tooltip container */
+ className?: string;
+} & Omit;
+
+function MarkerTooltip({ children, className, ...popupOptions }: MarkerTooltipProps) {
+ const { marker, map, maplibre } = useMarkerContext();
+ const container = useMemo(() => document.createElement('div'), []);
+ const { offset, maxWidth } = popupOptions;
+ const initialPopupOptions = useRef(popupOptions);
+
+ const tooltip = useMemo(() => {
+ const tooltipInstance = new maplibre.Popup({
+ offset: 16,
+ ...initialPopupOptions.current,
+ closeOnClick: true,
+ closeButton: false,
+ }).setMaxWidth('none');
+ return tooltipInstance;
+ }, [maplibre]);
+
+ useEffect(() => {
+ if (!map) return;
+
+ tooltip.setDOMContent(container);
+
+ const handleMouseEnter = () => {
+ tooltip.setLngLat(marker.getLngLat()).addTo(map);
+ };
+ const handleMouseLeave = () => tooltip.remove();
+
+ marker.getElement()?.addEventListener('mouseenter', handleMouseEnter);
+ marker.getElement()?.addEventListener('mouseleave', handleMouseLeave);
+
+ return () => {
+ marker.getElement()?.removeEventListener('mouseenter', handleMouseEnter);
+ marker.getElement()?.removeEventListener('mouseleave', handleMouseLeave);
+ tooltip.remove();
+ };
+ }, [container, map, marker, tooltip]);
+
+ // Sync tooltip options when they change.
+ useEffect(() => {
+ tooltip.setOffset(offset ?? 16);
+ tooltip.setMaxWidth(maxWidth ?? 'none');
+ }, [tooltip, offset, maxWidth]);
+
+ return createPortal(
+
+ {children}
+
,
+ container,
+ );
+}
+
+type MarkerLabelProps = {
+ /** Label text content */
+ children: ReactNode;
+ /** Additional CSS classes for the label */
+ className?: string;
+ /** Position of the label relative to the marker (default: "top") */
+ position?: 'top' | 'bottom';
+};
+
+function MarkerLabel({ children, className, position = 'top' }: MarkerLabelProps) {
+ const positionClasses = {
+ top: 'bottom-full mb-1',
+ bottom: 'top-full mt-1',
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export { MapMarker, MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel, PopupCloseButton };
+
+export type { MapMarkerProps, MarkerContentProps, MarkerPopupProps, MarkerTooltipProps, MarkerLabelProps };
diff --git a/packages/ui/src/components/map/map-popup.tsx b/packages/ui/src/components/map/map-popup.tsx
new file mode 100644
index 0000000..3092099
--- /dev/null
+++ b/packages/ui/src/components/map/map-popup.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import type { PopupOptions } from 'maplibre-gl';
+import { useEffect, useMemo, useRef, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+
+import { cn } from '../../lib/utils';
+import { useMap } from './context';
+import { useLatest } from './internal';
+import { PopupCloseButton } from './map-marker';
+
+type MapPopupProps = {
+ /** Longitude coordinate for popup position */
+ longitude: number;
+ /** Latitude coordinate for popup position */
+ latitude: number;
+ /** Callback when popup is closed */
+ onClose?: () => void;
+ /** Popup content */
+ children: ReactNode;
+ /** Additional CSS classes for the popup container */
+ className?: string;
+ /** Show a close button in the popup (default: false) */
+ closeButton?: boolean;
+} & Omit;
+
+function MapPopup({
+ longitude,
+ latitude,
+ onClose,
+ children,
+ className,
+ closeButton = false,
+ ...popupOptions
+}: MapPopupProps) {
+ const { map, maplibre } = useMap();
+ if (!maplibre) throw new Error('MapLibre is not loaded.');
+ const onCloseRef = useLatest(onClose);
+ const container = useMemo(() => document.createElement('div'), []);
+ const { offset, maxWidth } = popupOptions;
+ const initialPopupOptions = useRef(popupOptions);
+ const initialPosition = useRef<[number, number]>([longitude, latitude]);
+
+ const popup = useMemo(() => {
+ const popupInstance = new maplibre.Popup({
+ offset: 16,
+ ...initialPopupOptions.current,
+ closeButton: false,
+ })
+ .setMaxWidth('none')
+ .setLngLat(initialPosition.current);
+ return popupInstance;
+ }, [maplibre]);
+
+ useEffect(() => {
+ if (!map) return;
+
+ const onCloseProp = () => onCloseRef.current?.();
+
+ popup.on('close', onCloseProp);
+
+ popup.setDOMContent(container);
+ popup.addTo(map);
+
+ return () => {
+ popup.off('close', onCloseProp);
+ if (popup.isOpen()) {
+ popup.remove();
+ }
+ };
+ }, [container, map, onCloseRef, popup]);
+
+ // Sync popup position and options when they change.
+ useEffect(() => {
+ const current = popup.getLngLat();
+ if (!current || current.lng !== longitude || current.lat !== latitude) {
+ popup.setLngLat([longitude, latitude]);
+ }
+ popup.setOffset(offset ?? 16);
+ popup.setMaxWidth(maxWidth ?? 'none');
+ }, [popup, longitude, latitude, offset, maxWidth]);
+
+ const handleClose = () => {
+ popup.remove();
+ };
+
+ return createPortal(
+
+ {closeButton &&
}
+ {children}
+
,
+ container,
+ );
+}
+
+export { MapPopup };
+export type { MapPopupProps };
diff --git a/packages/ui/src/components/map/map-root.tsx b/packages/ui/src/components/map/map-root.tsx
new file mode 100644
index 0000000..c66e019
--- /dev/null
+++ b/packages/ui/src/components/map/map-root.tsx
@@ -0,0 +1,284 @@
+'use client';
+
+import type * as MapLibreGL from 'maplibre-gl';
+import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from 'react';
+import { MapPinOffIcon } from 'lucide-react';
+
+import { cn } from '../../lib/utils';
+import { Alert, AlertDescription, AlertTitle } from '../alert';
+import { Skeleton } from '../skeleton';
+import { MapContext } from './context';
+import {
+ asError,
+ blankMapStyle,
+ defaultStyles,
+ discardFailedMapContainer,
+ hasInitializedPainter,
+ loadMapLibre,
+ supportsMapLibreWebGL,
+ useDeepStableValue,
+ useLatest,
+ useResolvedTheme,
+} from './internal';
+
+export interface MapViewport {
+ /** Center coordinates in [longitude, latitude] order. */
+ center: [number, number];
+ zoom: number;
+ bearing: number;
+ pitch: number;
+}
+
+export type MapStyleOption = string | MapLibreGL.StyleSpecification;
+export type MapRef = MapLibreGL.Map;
+
+export type MapProps = {
+ children?: ReactNode;
+ className?: string;
+ theme?: 'light' | 'dark';
+ /** Missing light or dark entries reuse the single supplied style. */
+ styles?: { light?: MapStyleOption; dark?: MapStyleOption };
+ /** Use a transparent, tile-less style when no explicit styles are supplied. */
+ blank?: boolean;
+ projection?: MapLibreGL.ProjectionSpecification;
+ viewport?: Partial;
+ onViewportChange?: (viewport: MapViewport) => void;
+ loading?: boolean;
+ fallback?: ReactNode;
+ onError?: (error: Error) => void;
+} & Omit;
+
+function DefaultLoader() {
+ return (
+
+
+ Loading map
+
+ );
+}
+
+function DefaultFallback() {
+ return (
+
+
+
+ Map unavailable
+
+ This browser cannot start the interactive map. Coordinate and JSON controls remain available.
+
+
+
+ );
+}
+
+function getViewport(map: MapLibreGL.Map): MapViewport {
+ const center = map.getCenter();
+ return {
+ center: [center.lng, center.lat],
+ zoom: map.getZoom(),
+ bearing: map.getBearing(),
+ pitch: map.getPitch(),
+ };
+}
+
+function sameViewport(left: MapViewport, right: MapViewport): boolean {
+ return (
+ left.center[0] === right.center[0] &&
+ left.center[1] === right.center[1] &&
+ left.zoom === right.zoom &&
+ left.bearing === right.bearing &&
+ left.pitch === right.pitch
+ );
+}
+
+export const Map = forwardRef(function Map(
+ {
+ children,
+ className,
+ theme: themeProp,
+ styles,
+ blank = false,
+ projection,
+ viewport,
+ onViewportChange,
+ loading = false,
+ fallback,
+ onError,
+ ...mapOptions
+ },
+ ref,
+) {
+ const containerRef = useRef(null);
+ const [mapInstance, setMapInstance] = useState(null);
+ const [maplibre, setMapLibre] = useState> | null>(null);
+ const [mapLoaded, setMapLoaded] = useState(false);
+ const [styleLoaded, setStyleLoaded] = useState(false);
+ const [initializationError, setInitializationError] = useState(null);
+ const internalViewportUpdate = useRef(false);
+ const resolvedTheme = useResolvedTheme(themeProp);
+ const [appliedTheme, setAppliedTheme] = useState(resolvedTheme);
+ const pendingTheme = useRef(resolvedTheme);
+ const stableStyles = useDeepStableValue(styles);
+ const onViewportChangeRef = useLatest(onViewportChange);
+ const onErrorRef = useLatest(onError);
+
+ const mapStyles = useMemo(() => {
+ const sharedStyle = stableStyles?.light ?? stableStyles?.dark;
+ if (sharedStyle) {
+ return {
+ light: stableStyles?.light ?? sharedStyle,
+ dark: stableStyles?.dark ?? sharedStyle,
+ };
+ }
+ if (blank) return { light: blankMapStyle, dark: blankMapStyle };
+ return defaultStyles;
+ }, [blank, stableStyles]);
+
+ const selectedStyle = resolvedTheme === 'dark' ? mapStyles.dark : mapStyles.light;
+ const initialConfiguration = useRef({
+ mapOptions,
+ style: selectedStyle,
+ viewport,
+ });
+ const currentStyle = useRef(initialConfiguration.current.style);
+ const isControlled = viewport !== undefined && onViewportChange !== undefined;
+
+ useImperativeHandle(ref, () => mapInstance!, [mapInstance]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const initial = initialConfiguration.current;
+ if (!supportsMapLibreWebGL(initial.mapOptions.canvasContextAttributes)) {
+ const error = new Error('MapLibre requires WebGL 2 support.');
+ setInitializationError(error);
+ onErrorRef.current?.(error);
+ return;
+ }
+
+ let cancelled = false;
+ let map: MapLibreGL.Map | null = null;
+ let detachListeners: (() => void) | undefined;
+
+ void loadMapLibre()
+ .then((MapLibre) => {
+ if (cancelled) return;
+ try {
+ map = new MapLibre.Map({
+ container,
+ style: initial.style,
+ renderWorldCopies: false,
+ attributionControl: { compact: true },
+ ...initial.mapOptions,
+ ...initial.viewport,
+ });
+ } catch (value) {
+ const error = asError(value);
+ setInitializationError(error);
+ onErrorRef.current?.(error);
+ return;
+ }
+
+ if (!hasInitializedPainter(map)) {
+ const error = new Error('MapLibre could not initialize its WebGL 2 renderer.');
+ discardFailedMapContainer(container);
+ map = null;
+ setInitializationError(error);
+ onErrorRef.current?.(error);
+ return;
+ }
+
+ const activeMap = map;
+ const handleLoad = () => setMapLoaded(true);
+ const handleStyleLoad = () => {
+ setAppliedTheme(pendingTheme.current);
+ setStyleLoaded(true);
+ };
+ const handleMove = () => {
+ if (!internalViewportUpdate.current) {
+ onViewportChangeRef.current?.(getViewport(activeMap));
+ }
+ };
+ const handleError = (event: MapLibreGL.ErrorEvent) => {
+ onErrorRef.current?.(asError(event.error));
+ };
+
+ activeMap.on('load', handleLoad);
+ activeMap.on('style.load', handleStyleLoad);
+ activeMap.on('move', handleMove);
+ activeMap.on('error', handleError);
+ // Inline styles can finish during construction, before listeners are
+ // attached. Reconcile the current state so declarative child layers
+ // are not left behind a style-load gate forever.
+ if (activeMap.isStyleLoaded()) handleStyleLoad();
+ if (activeMap.loaded()) handleLoad();
+ detachListeners = () => {
+ activeMap.off('load', handleLoad);
+ activeMap.off('style.load', handleStyleLoad);
+ activeMap.off('move', handleMove);
+ activeMap.off('error', handleError);
+ };
+ setMapLibre(MapLibre);
+ setMapInstance(activeMap);
+ })
+ .catch((value: unknown) => {
+ if (cancelled) return;
+ const error = asError(value);
+ setInitializationError(error);
+ onErrorRef.current?.(error);
+ });
+
+ return () => {
+ cancelled = true;
+ detachListeners?.();
+ map?.remove();
+ };
+ }, [onErrorRef, onViewportChangeRef]);
+
+ useEffect(() => {
+ if (!mapInstance || !isControlled || !viewport || mapInstance.isMoving()) return;
+ const current = getViewport(mapInstance);
+ const next: MapViewport = {
+ center: viewport.center ?? current.center,
+ zoom: viewport.zoom ?? current.zoom,
+ bearing: viewport.bearing ?? current.bearing,
+ pitch: viewport.pitch ?? current.pitch,
+ };
+ if (sameViewport(current, next)) return;
+ internalViewportUpdate.current = true;
+ mapInstance.jumpTo(next);
+ internalViewportUpdate.current = false;
+ }, [isControlled, mapInstance, viewport]);
+
+ useEffect(() => {
+ if (!mapInstance || currentStyle.current === selectedStyle) return;
+ currentStyle.current = selectedStyle;
+ pendingTheme.current = resolvedTheme;
+ setStyleLoaded(false);
+ mapInstance.setStyle(selectedStyle, { diff: false });
+ }, [mapInstance, resolvedTheme, selectedStyle]);
+
+ useEffect(() => {
+ if (!mapInstance || !styleLoaded || !projection) return;
+ mapInstance.setProjection(projection);
+ }, [mapInstance, projection, styleLoaded]);
+
+ const contextValue = useMemo(
+ () => ({
+ map: mapInstance,
+ maplibre,
+ isLoaded: mapLoaded && styleLoaded,
+ resolvedTheme: appliedTheme,
+ }),
+ [appliedTheme, mapInstance, mapLoaded, maplibre, styleLoaded],
+ );
+
+ return (
+
+
+ {initializationError ? (fallback ?? ) : (!mapLoaded || loading) && }
+ {!initializationError && mapInstance && maplibre && children}
+
+
+ );
+});
diff --git a/packages/ui/src/components/map/map-route.tsx b/packages/ui/src/components/map/map-route.tsx
new file mode 100644
index 0000000..9bcb99f
--- /dev/null
+++ b/packages/ui/src/components/map/map-route.tsx
@@ -0,0 +1,120 @@
+'use client';
+
+import type * as GeoJSON from 'geojson';
+import type * as MapLibreGL from 'maplibre-gl';
+import { useEffect, useId, useMemo } from 'react';
+
+import { useMap } from './context';
+import { resolveMapColor, useLatest } from './internal';
+import { useGeoJSONLayerGroup } from './layer-lifecycle';
+
+type MapRouteProps = {
+ /** Optional unique identifier for the route layer */
+ id?: string;
+ /** Array of [longitude, latitude] coordinate pairs defining the route */
+ coordinates: [number, number][];
+ /** Line color as a MapLibre CSS color value. Defaults to the semantic map accent token. */
+ color?: string;
+ /** Line width in pixels (default: 3) */
+ width?: number;
+ /** Line opacity from 0 to 1 (default: 0.8) */
+ opacity?: number;
+ /** Dash pattern [dash length, gap length] for dashed lines */
+ dashArray?: [number, number];
+ /** Callback when the route line is clicked */
+ onClick?: () => void;
+ /** Callback when mouse enters the route line */
+ onMouseEnter?: () => void;
+ /** Callback when mouse leaves the route line */
+ onMouseLeave?: () => void;
+ /** Whether the route is interactive - shows pointer cursor on hover (default: true) */
+ interactive?: boolean;
+};
+
+function MapRoute({
+ id: propId,
+ coordinates,
+ color,
+ width = 3,
+ opacity = 0.8,
+ dashArray,
+ onClick,
+ onMouseEnter,
+ onMouseLeave,
+ interactive = true,
+}: MapRouteProps): null {
+ const { map, isLoaded, resolvedTheme } = useMap();
+ const autoId = useId();
+ const id = propId ?? autoId;
+ const sourceId = `route-source-${id}`;
+ const layerId = `route-layer-${id}`;
+ const resolvedColor = color ?? resolveMapColor(map, 'accent', resolvedTheme);
+
+ const data = useMemo>(
+ () => ({
+ type: 'FeatureCollection',
+ features:
+ coordinates.length < 2
+ ? []
+ : [
+ {
+ type: 'Feature',
+ properties: {},
+ geometry: { type: 'LineString', coordinates },
+ },
+ ],
+ }),
+ [coordinates],
+ );
+ const layers = useMemo(
+ () => [
+ {
+ id: layerId,
+ type: 'line',
+ source: sourceId,
+ layout: { 'line-join': 'round', 'line-cap': 'round' },
+ paint: {
+ 'line-color': resolvedColor,
+ 'line-width': width,
+ 'line-opacity': opacity,
+ ...(dashArray ? { 'line-dasharray': dashArray } : {}),
+ },
+ },
+ ],
+ [dashArray, layerId, opacity, resolvedColor, sourceId, width],
+ );
+ useGeoJSONLayerGroup({ sourceId, data, layers });
+
+ const callbacks = useLatest({ onClick, onMouseEnter, onMouseLeave });
+
+ // Handle click and hover events
+ useEffect(() => {
+ if (!isLoaded || !map || !interactive) return;
+
+ const handleClick = () => callbacks.current.onClick?.();
+ const handleMouseEnter = () => {
+ map.getCanvas().style.cursor = 'pointer';
+ callbacks.current.onMouseEnter?.();
+ };
+ const handleMouseLeave = () => {
+ map.getCanvas().style.cursor = '';
+ callbacks.current.onMouseLeave?.();
+ };
+
+ map.on('click', layerId, handleClick);
+ map.on('mouseenter', layerId, handleMouseEnter);
+ map.on('mouseleave', layerId, handleMouseLeave);
+
+ return () => {
+ map.off('click', layerId, handleClick);
+ map.off('mouseenter', layerId, handleMouseEnter);
+ map.off('mouseleave', layerId, handleMouseLeave);
+ map.getCanvas().style.cursor = '';
+ };
+ }, [callbacks, interactive, isLoaded, layerId, map]);
+
+ return null;
+}
+
+export { MapRoute };
+export type { MapRouteProps };
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index e4c5c6a..beebe56 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -202,6 +202,7 @@ export {
// Complex components
export { FlowZoomPanel } from './components/flow-zoom-panel';
+export * from './components/map';
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from './components/command';
export {
Combobox,
diff --git a/packages/ui/src/stories/Map.stories.tsx b/packages/ui/src/stories/Map.stories.tsx
new file mode 100644
index 0000000..59261c9
--- /dev/null
+++ b/packages/ui/src/stories/Map.stories.tsx
@@ -0,0 +1,160 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { useEffect, useState } from 'react';
+
+import { Button } from '../components/button';
+import { Map, MapArc, MapControls, MapMarker, MarkerContent, MarkerPopup, useMap } from '../components/map';
+import { LocalCanvasBasemap } from './map-preview';
+
+const NEW_YORK_CENTER: [number, number] = [-73.9857, 40.7484];
+
+const NEW_YORK_DESTINATIONS = [
+ { id: 'central-park', label: 'Central Park', position: [-73.9654, 40.7829] },
+ { id: 'washington-square', label: 'Washington Square Park', position: [-73.9973, 40.7308] },
+ { id: 'columbia', label: 'Columbia University', position: [-73.9626, 40.8075] },
+] satisfies Array<{ id: string; label: string; position: [number, number] }>;
+
+function randomNewYorkPosition(): [number, number] {
+ const progress = Math.random();
+ return [
+ -74.013 + progress * 0.073 + (Math.random() - 0.5) * 0.012,
+ 40.704 + progress * 0.108 + (Math.random() - 0.5) * 0.01,
+ ];
+}
+
+function useRandomNewYorkPosition() {
+ const [position, setPosition] = useState<[number, number]>(NEW_YORK_CENTER);
+ useEffect(() => setPosition(randomNewYorkPosition()), []);
+ return [position, setPosition] as const;
+}
+
+function ClickToSelect({
+ position,
+ onChange,
+}: {
+ position: [number, number];
+ onChange: (position: [number, number]) => void;
+}) {
+ const { map, isLoaded } = useMap();
+ useEffect(() => {
+ if (!map || !isLoaded) return;
+ const handleClick = (event: { lngLat: { lng: number; lat: number } }) => {
+ onChange([event.lngLat.lng, event.lngLat.lat]);
+ };
+ map.on('click', handleClick);
+ return () => {
+ map.off('click', handleClick);
+ };
+ }, [isLoaded, map, onChange]);
+
+ return (
+ onChange([lng, lat])}
+ >
+
+
+ Selected point
+
+ {position[1].toFixed(5)}, {position[0].toFixed(5)}
+
+
+
+ );
+}
+
+function LocationPickerStory() {
+ const [position, setPosition] = useRandomNewYorkPosition();
+ const [error, setError] = useState(null);
+ return (
+
+
+
+
+
+
+
setPosition(randomNewYorkPosition())}
+ size="sm"
+ variant="secondary"
+ >
+ Random New York position
+
+ {error && (
+
+ {error.message}
+
+ )}
+
+ );
+}
+
+function BlankConnectionsStory() {
+ const [position, setPosition] = useRandomNewYorkPosition();
+ const connections = NEW_YORK_DESTINATIONS.map(({ id, position: destination }) => ({
+ id,
+ from: position,
+ to: destination,
+ }));
+
+ return (
+
+
+
+
+
+
+
+ Random New York position
+
+ {position[1].toFixed(5)}, {position[0].toFixed(5)}
+
+
+
+ {NEW_YORK_DESTINATIONS.map((destination) => (
+
+
+ {destination.label}
+
+ ))}
+
+
+
setPosition(randomNewYorkPosition())}
+ size="sm"
+ variant="secondary"
+ >
+ Randomize origin
+
+
+ );
+}
+
+const meta = {
+ title: 'Data Display/Map',
+ component: Map,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component:
+ 'The New York stories use a tile-free local canvas source so they work without third-party requests or workers. Map defaults to the official MapLibre demo style; production hosts should provide licensed light and dark styles.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const LocationPicker: Story = {
+ render: () => ,
+};
+
+export const BlankConnections: Story = {
+ render: () => ,
+};
diff --git a/packages/ui/src/stories/map-preview.tsx b/packages/ui/src/stories/map-preview.tsx
new file mode 100644
index 0000000..a528b60
--- /dev/null
+++ b/packages/ui/src/stories/map-preview.tsx
@@ -0,0 +1,217 @@
+'use client';
+
+import { useEffect } from 'react';
+
+import { useMap } from '../components/map';
+
+type PreviewColors = {
+ background: string;
+ label: string;
+ land: string;
+ major: string;
+ minor: string;
+ park: string;
+ water: string;
+};
+
+type CanvasCoordinates = [[number, number], [number, number], [number, number], [number, number]];
+
+type LocalCanvasBasemapProps = {
+ coordinates?: CanvasCoordinates;
+ placeLabel?: string;
+ waterLabel?: string;
+};
+
+const NEW_YORK_COORDINATES: CanvasCoordinates = [
+ [-74.06, 40.83],
+ [-73.91, 40.83],
+ [-73.91, 40.68],
+ [-74.06, 40.68],
+];
+
+const PREVIEW_COLORS: Record<'light' | 'dark', PreviewColors> = {
+ dark: {
+ background: '#172023',
+ label: '#b7c4c8',
+ land: '#202c30',
+ major: '#aeb9bd',
+ minor: '#526168',
+ park: '#294a38',
+ water: '#22556c',
+ },
+ light: {
+ background: '#dce4df',
+ label: '#53635c',
+ land: '#e7ece8',
+ major: '#ffffff',
+ minor: '#b6c0ba',
+ park: '#c7ddc9',
+ water: '#b7dce8',
+ },
+};
+
+const MINOR_ROADS: Array> = [
+ [
+ [60, 180],
+ [790, 690],
+ ],
+ [
+ [30, 300],
+ [810, 780],
+ ],
+ [
+ [130, 60],
+ [795, 540],
+ ],
+ [
+ [120, 820],
+ [770, 130],
+ ],
+ [
+ [220, 0],
+ [260, 900],
+ ],
+ [
+ [410, 0],
+ [455, 900],
+ ],
+ [
+ [610, 0],
+ [650, 900],
+ ],
+ [
+ [0, 210],
+ [820, 250],
+ ],
+ [
+ [0, 440],
+ [825, 470],
+ ],
+ [
+ [0, 665],
+ [785, 700],
+ ],
+];
+
+const MAJOR_ROADS: Array> = [
+ [
+ [-40, 790],
+ [815, 35],
+ ],
+ [
+ [90, -30],
+ [715, 930],
+ ],
+ [
+ [-30, 510],
+ [850, 520],
+ ],
+];
+
+function drawPolygon(context: CanvasRenderingContext2D, points: Array<[number, number]>) {
+ context.beginPath();
+ points.forEach(([x, y], index) => (index === 0 ? context.moveTo(x, y) : context.lineTo(x, y)));
+ context.closePath();
+ context.fill();
+}
+
+function drawRoads(
+ context: CanvasRenderingContext2D,
+ roads: Array>,
+ color: string,
+ width: number,
+) {
+ context.strokeStyle = color;
+ context.lineCap = 'round';
+ context.lineWidth = width;
+ for (const road of roads) {
+ context.beginPath();
+ road.forEach(([x, y], index) => (index === 0 ? context.moveTo(x, y) : context.lineTo(x, y)));
+ context.stroke();
+ }
+}
+
+function createPreviewCanvas(colors: PreviewColors, placeLabel: string, waterLabel: string): HTMLCanvasElement | null {
+ const canvas = document.createElement('canvas');
+ canvas.width = 1200;
+ canvas.height = 900;
+ const context = canvas.getContext('2d');
+ if (!context) return null;
+
+ context.fillStyle = colors.land;
+ context.fillRect(0, 0, canvas.width, canvas.height);
+
+ context.fillStyle = colors.water;
+ context.beginPath();
+ context.moveTo(820, -20);
+ context.bezierCurveTo(760, 150, 880, 260, 805, 420);
+ context.bezierCurveTo(750, 540, 850, 640, 725, 920);
+ context.lineTo(910, 920);
+ context.bezierCurveTo(1000, 690, 875, 560, 930, 430);
+ context.bezierCurveTo(1010, 250, 910, 120, 980, -20);
+ context.closePath();
+ context.fill();
+
+ context.fillStyle = colors.park;
+ drawPolygon(context, [
+ [330, 250],
+ [430, 220],
+ [480, 315],
+ [360, 350],
+ ]);
+ drawPolygon(context, [
+ [190, 610],
+ [300, 575],
+ [350, 690],
+ [230, 730],
+ ]);
+
+ drawRoads(context, MINOR_ROADS, colors.minor, 7);
+ drawRoads(context, MAJOR_ROADS, colors.background, 28);
+ drawRoads(context, MAJOR_ROADS, colors.major, 16);
+
+ context.fillStyle = colors.label;
+ context.font = '600 28px system-ui, sans-serif';
+ context.fillText(placeLabel, 455, 410);
+ context.save();
+ context.translate(875, 500);
+ context.rotate(Math.PI / 2);
+ context.fillText(waterLabel, -90, 0);
+ context.restore();
+
+ return canvas;
+}
+
+/** A georeferenced, worker-free basemap for local Storybook rendering. */
+export function LocalCanvasBasemap({
+ coordinates = NEW_YORK_COORDINATES,
+ placeLabel = 'Manhattan',
+ waterLabel = 'East River',
+}: LocalCanvasBasemapProps = {}) {
+ const { isLoaded, map, resolvedTheme } = useMap();
+
+ useEffect(() => {
+ if (!isLoaded || !map) return;
+ const canvas = createPreviewCanvas(PREVIEW_COLORS[resolvedTheme], placeLabel, waterLabel);
+ if (!canvas) return;
+
+ const sourceId = 'local-preview-canvas';
+ const layerId = 'local-preview-canvas-layer';
+ if (map.getLayer(layerId)) map.removeLayer(layerId);
+ if (map.getSource(sourceId)) map.removeSource(sourceId);
+ map.addSource(sourceId, {
+ type: 'canvas',
+ canvas,
+ animate: false,
+ coordinates,
+ });
+ map.addLayer({ id: layerId, type: 'raster', source: sourceId, paint: { 'raster-fade-duration': 0 } });
+
+ return () => {
+ if (map.getLayer(layerId)) map.removeLayer(layerId);
+ if (map.getSource(sourceId)) map.removeSource(sourceId);
+ };
+ }, [coordinates, isLoaded, map, placeLabel, resolvedTheme, waterLabel]);
+
+ return null;
+}
diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css
index b3eaf7a..7f120ff 100644
--- a/packages/ui/src/styles/globals.css
+++ b/packages/ui/src/styles/globals.css
@@ -1,6 +1,7 @@
/* Generated by scripts/generate-theme.ts. Edit src/theme.ts instead. */
@import 'tailwindcss';
@import '@xyflow/react/dist/style.css';
+@import 'maplibre-gl/dist/maplibre-gl.css';
@source "../../dist";
@@ -45,6 +46,13 @@
--success-foreground: var(--color-emerald-700);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-700);
+ --map-control-filter: none;
+ --map-accent: hsl(221 83% 53%);
+ --map-accent-medium: hsl(224 76% 48%);
+ --map-accent-strong: hsl(226 71% 40%);
+ --map-surface: hsl(0 0% 83%);
+ --map-surface-border: hsl(0 0% 100%);
+ --map-on-accent: hsl(0 0% 100%);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
@@ -106,6 +114,13 @@
--success-foreground: var(--color-emerald-400);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-400);
+ --map-control-filter: invert(1);
+ --map-accent: hsl(217 91% 60%);
+ --map-accent-medium: hsl(213 94% 68%);
+ --map-accent-strong: hsl(211 96% 78%);
+ --map-surface: hsl(0 0% 25%);
+ --map-surface-border: hsl(0 0% 9%);
+ --map-on-accent: hsl(222 47% 11%);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
@@ -436,6 +451,40 @@
}
}
+[data-slot="map"] {
+ @apply bg-muted/30 text-foreground;
+}
+[data-slot="map-loading"] {
+ @apply bg-muted/40 text-muted-foreground;
+}
+[data-slot="map-fallback"] {
+ @apply bg-muted/30 text-foreground;
+}
+[data-slot="map-control-group"] {
+ @apply border-border bg-background text-foreground;
+}
+[data-slot="map-marker"] {
+ @apply border-primary-foreground bg-primary text-primary-foreground;
+}
+[data-slot="map"] .maplibregl-popup-content {
+ @apply rounded-none! bg-transparent! p-0! shadow-none!;
+}
+[data-slot="map"] .maplibregl-popup-tip {
+ @apply hidden!;
+}
+[data-slot="map"] .maplibregl-ctrl-attrib {
+ @apply rounded-md! border! border-border! bg-background! text-muted-foreground! shadow-sm!;
+}
+[data-slot="map"] .maplibregl-ctrl-attrib a {
+ @apply text-muted-foreground!;
+}
+[data-slot="map"] .maplibregl-ctrl-attrib a:hover {
+ @apply text-foreground!;
+}
+[data-slot="map"] .maplibregl-ctrl-attrib-button {
+ @apply bg-transparent!;
+ filter: var(--map-control-filter);
+}
[data-slot="portal-root"] > * {
pointer-events: auto;
}
diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts
index 503981d..e7c828e 100644
--- a/packages/ui/src/theme.ts
+++ b/packages/ui/src/theme.ts
@@ -1,367 +1,414 @@
export type ThemeTokenMap = Readonly>;
export interface ThemeCssObject {
- readonly [name: string]: string | ThemeCssObject;
+ readonly [name: string]: string | ThemeCssObject;
}
export interface ConstructiveThemeDefinition {
- npmImports: readonly string[];
- npmSource: string;
- darkVariant: string;
- light: ThemeTokenMap;
- dark: ThemeTokenMap;
- tailwind: ThemeTokenMap;
- zIndex: ThemeTokenMap;
- baseCss: ThemeCssObject;
- utilities: ThemeCssObject;
- keyframes: Readonly>;
- globalCss: ThemeCssObject;
+ npmImports: readonly string[];
+ npmSource: string;
+ darkVariant: string;
+ light: ThemeTokenMap;
+ dark: ThemeTokenMap;
+ tailwind: ThemeTokenMap;
+ zIndex: ThemeTokenMap;
+ baseCss: ThemeCssObject;
+ utilities: ThemeCssObject;
+ keyframes: Readonly>;
+ globalCss: ThemeCssObject;
}
const sharedTypographyAndShape = {
- 'font-sans': 'Open Sans, sans-serif',
- 'font-serif': 'Georgia, serif',
- 'font-mono': 'Menlo, monospace',
- radius: '0.5rem',
+ 'font-sans': 'Open Sans, sans-serif',
+ 'font-serif': 'Georgia, serif',
+ 'font-mono': 'Menlo, monospace',
+ radius: '0.5rem',
} as const;
/** Elevation shadows — were fully transparent (`/ 0`), so shadow-* utilities did nothing. */
const lightElevationShadows = {
- 'shadow-2xs': '0 1px rgb(0 0 0 / 0.05)',
- 'shadow-xs': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
- 'shadow-sm': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
- shadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
- 'shadow-md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
- 'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
- 'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
- 'shadow-2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
+ 'shadow-2xs': '0 1px rgb(0 0 0 / 0.05)',
+ 'shadow-xs': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
+ 'shadow-sm': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
+ shadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
+ 'shadow-md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
+ 'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
+ 'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
+ 'shadow-2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
} as const;
const darkElevationShadows = {
- 'shadow-2xs': '0 1px rgb(0 0 0 / 0.2)',
- 'shadow-xs': '0 1px 2px 0 rgb(0 0 0 / 0.25)',
- 'shadow-sm': '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25)',
- shadow: '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25)',
- 'shadow-md': '0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.3)',
- 'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)',
- 'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / 0.45), 0 8px 10px -6px rgb(0 0 0 / 0.35)',
- 'shadow-2xl': '0 25px 50px -12px rgb(0 0 0 / 0.55)',
+ 'shadow-2xs': '0 1px rgb(0 0 0 / 0.2)',
+ 'shadow-xs': '0 1px 2px 0 rgb(0 0 0 / 0.25)',
+ 'shadow-sm': '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25)',
+ shadow: '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.25)',
+ 'shadow-md': '0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.3)',
+ 'shadow-lg': '0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)',
+ 'shadow-xl': '0 20px 25px -5px rgb(0 0 0 / 0.45), 0 8px 10px -6px rgb(0 0 0 / 0.35)',
+ 'shadow-2xl': '0 25px 50px -12px rgb(0 0 0 / 0.55)',
} as const;
export const constructiveTheme = {
- npmImports: ['tailwindcss', '@xyflow/react/dist/style.css'],
- npmSource: '../../dist',
- darkVariant: '&:is(.dark *)',
- light: {
- background: 'oklch(1 0 0)',
- foreground: 'oklch(0.3211 0 0)',
- card: 'oklch(1 0 0)',
- 'card-foreground': 'oklch(0.3211 0 0)',
- popover: 'oklch(1 0 0)',
- 'popover-foreground': 'oklch(0.3211 0 0)',
- primary: 'oklch(0.688 0.1754 245.6151)',
- 'primary-foreground': 'oklch(0.979 0.021 166.113)',
- secondary: 'oklch(0.967 0.001 286.375)',
- 'secondary-foreground': 'oklch(0.21 0.006 285.885)',
- muted: 'oklch(0.967 0.001 286.375)',
- 'muted-foreground': 'oklch(0.552 0.016 285.938)',
- accent: 'oklch(0.967 0.001 286.375)',
- 'accent-foreground': 'oklch(0.21 0.006 285.885)',
- destructive: 'oklch(0.55 0.2 25)',
- 'destructive-foreground': 'oklch(0.985 0 0)',
- border: 'oklch(0.92 0.004 286.32)',
- input: 'oklch(0.93 0.004 286.32)',
- ring: 'oklch(0.871 0.006 286.286)',
- 'chart-1': 'oklch(0.646 0.222 41.116)',
- 'chart-2': 'oklch(0.6 0.118 184.704)',
- 'chart-3': 'oklch(0.398 0.07 227.392)',
- 'chart-4': 'oklch(0.828 0.189 84.429)',
- 'chart-5': 'oklch(0.769 0.188 70.08)',
- sidebar: 'oklch(0.985 0 0)',
- 'sidebar-foreground': 'oklch(0.37 0.013 285.805)',
- 'sidebar-primary': 'oklch(0.985 0 0)',
- 'sidebar-primary-foreground': 'oklch(0.21 0.006 285.885)',
- 'sidebar-accent': 'oklch(0.967 0.001 286.375)',
- 'sidebar-accent-foreground': 'oklch(0.21 0.006 285.885)',
- 'sidebar-border': 'oklch(0.92 0.004 286.32)',
- 'sidebar-ring': 'oklch(0.871 0.006 286.286)',
- info: 'var(--color-blue-500)',
- 'info-foreground': 'var(--color-blue-700)',
- success: 'var(--color-emerald-500)',
- 'success-foreground': 'var(--color-emerald-700)',
- warning: 'var(--color-amber-500)',
- 'warning-foreground': 'var(--color-amber-700)',
- ...sharedTypographyAndShape,
- 'shadow-border':
- '0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 1px -0.5px rgb(35 35 35 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.04), 0 2px 4px -1px rgb(35 35 35 / 0.03)',
- 'shadow-border-hover':
- '0 0 0 1px oklch(0 0 0 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.08), 0 2px 4px -2px rgb(35 35 35 / 0.06), 0 4px 8px -2px rgb(35 35 35 / 0.04), 0 8px 16px -4px rgb(35 35 35 / 0.03)',
- ...lightElevationShadows,
- },
- dark: {
- background: 'oklch(0.21 0.006 285.885)',
- foreground: 'oklch(0.985 0 0)',
- card: 'oklch(0.21 0.006 285.885)',
- 'card-foreground': 'oklch(0.985 0 0)',
- popover: 'oklch(0.21 0.006 285.885)',
- 'popover-foreground': 'oklch(0.985 0 0)',
- primary: 'oklch(0.688 0.1754 245.6151)',
- 'primary-foreground': 'oklch(0.979 0.021 166.113)',
- secondary: 'oklch(0.274 0.006 286.033)',
- 'secondary-foreground': 'oklch(0.985 0 0)',
- muted: 'oklch(0.244 0.006 285.97)',
- 'muted-foreground': 'oklch(0.705 0.015 286.067)',
- accent: 'oklch(0.244 0.006 285.97)',
- 'accent-foreground': 'oklch(0.985 0 0)',
- destructive: 'oklch(0.55 0.2 25)',
- 'destructive-foreground': 'oklch(0.985 0 0)',
- border: 'oklch(0.29 0.009 285.83)',
- input: 'oklch(0.29 0.009 285.83)',
- ring: 'oklch(0.442 0.017 285.786)',
- 'chart-1': 'oklch(0.488 0.243 264.376)',
- 'chart-2': 'oklch(0.696 0.17 162.48)',
- 'chart-3': 'oklch(0.769 0.188 70.08)',
- 'chart-4': 'oklch(0.627 0.265 303.9)',
- 'chart-5': 'oklch(0.645 0.246 16.439)',
- sidebar: 'oklch(0.244 0.006 285.97)',
- 'sidebar-foreground': 'oklch(0.967 0.001 286.375)',
- 'sidebar-primary': 'oklch(0.596 0.145 163.225)',
- 'sidebar-primary-foreground': 'oklch(1 0 0)',
- 'sidebar-accent': 'oklch(0.274 0.006 286.033)',
- 'sidebar-accent-foreground': 'oklch(0.967 0.001 286.375)',
- 'sidebar-border': 'oklch(0.274 0.006 286.033)',
- 'sidebar-ring': 'oklch(0.442 0.017 285.786)',
- info: 'var(--color-blue-500)',
- 'info-foreground': 'var(--color-blue-400)',
- success: 'var(--color-emerald-500)',
- 'success-foreground': 'var(--color-emerald-400)',
- warning: 'var(--color-amber-500)',
- 'warning-foreground': 'var(--color-amber-400)',
- ...sharedTypographyAndShape,
- 'shadow-border':
- '0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 1px -0.5px rgb(0 0 0 / 0.25), 0 1px 2px -1px rgb(0 0 0 / 0.15), 0 2px 4px -1px rgb(0 0 0 / 0.10)',
- 'shadow-border-hover':
- '0 0 0 1px oklch(1 0 0 / 0.13), 0 1px 2px -1px rgb(0 0 0 / 0.30), 0 2px 4px -2px rgb(0 0 0 / 0.20), 0 4px 8px -2px rgb(0 0 0 / 0.15), 0 8px 16px -4px rgb(0 0 0 / 0.10)',
- ...darkElevationShadows,
- },
- tailwind: {
- '--color-background': 'var(--background)',
- '--color-foreground': 'var(--foreground)',
- '--font-sans': 'var(--font-geist-sans, "Open Sans", ui-sans-serif, system-ui, sans-serif)',
- '--font-mono': 'var(--font-geist-mono, Menlo, Monaco, Consolas, "Liberation Mono", monospace)',
- '--color-sidebar-ring': 'var(--sidebar-ring)',
- '--color-sidebar-border': 'var(--sidebar-border)',
- '--color-sidebar-accent-foreground': 'var(--sidebar-accent-foreground)',
- '--color-sidebar-accent': 'var(--sidebar-accent)',
- '--color-sidebar-primary-foreground': 'var(--sidebar-primary-foreground)',
- '--color-sidebar-primary': 'var(--sidebar-primary)',
- '--color-sidebar-foreground': 'var(--sidebar-foreground)',
- '--color-sidebar': 'var(--sidebar)',
- '--color-chart-5': 'var(--chart-5)',
- '--color-chart-4': 'var(--chart-4)',
- '--color-chart-3': 'var(--chart-3)',
- '--color-chart-2': 'var(--chart-2)',
- '--color-chart-1': 'var(--chart-1)',
- '--color-ring': 'var(--ring)',
- '--color-input': 'var(--input)',
- '--color-border': 'var(--border)',
- '--color-destructive': 'var(--destructive)',
- '--color-accent-foreground': 'var(--accent-foreground)',
- '--color-accent': 'var(--accent)',
- '--color-muted-foreground': 'var(--muted-foreground)',
- '--color-muted': 'var(--muted)',
- '--color-secondary-foreground': 'var(--secondary-foreground)',
- '--color-secondary': 'var(--secondary)',
- '--color-primary-foreground': 'var(--primary-foreground)',
- '--color-primary': 'var(--primary)',
- '--color-popover-foreground': 'var(--popover-foreground)',
- '--color-popover': 'var(--popover)',
- '--color-card-foreground': 'var(--card-foreground)',
- '--color-card': 'var(--card)',
- '--radius-xs': 'calc(var(--radius) - 6px)',
- '--radius-sm': 'calc(var(--radius) - 4px)',
- '--radius-md': 'var(--radius)',
- '--radius-lg': 'calc(var(--radius) + 2px)',
- '--radius-xl': 'calc(var(--radius) + 6px)',
- '--radius-2xl': 'calc(var(--radius) + 10px)',
- '--color-warning-foreground': 'var(--warning-foreground)',
- '--color-warning': 'var(--warning)',
- '--color-success-foreground': 'var(--success-foreground)',
- '--color-success': 'var(--success)',
- '--color-info-foreground': 'var(--info-foreground)',
- '--color-info': 'var(--info)',
- '--color-destructive-foreground': 'var(--destructive-foreground)',
- },
- zIndex: {
- 'z-layer-portal-root': '9999',
- 'z-layer-floating': '1000',
- 'z-layer-modal-backdrop': '2000',
- 'z-layer-modal-content': '2001',
- 'z-layer-floating-elevated': '3000',
- 'z-layer-toast': '4000',
- },
- baseCss: {
- ':root': {
- 'color-scheme': 'light',
- },
- '.dark': {
- 'color-scheme': 'dark',
- },
- '*': {
- '@apply border-border/60 outline-ring/50': {},
- },
- body: {
- '@apply bg-background font-sans text-foreground antialiased': {},
- 'font-synthesis': 'none',
- position: 'relative',
- },
- '#__next, [data-nextjs-root-layout]': {
- isolation: 'isolate',
- },
- '@media (prefers-reduced-motion: reduce)': {
- '*, *::before, *::after': {
- 'scroll-behavior': 'auto !important',
- 'animation-duration': '0.01ms !important',
- 'animation-iteration-count': '1 !important',
- 'transition-duration': '0.01ms !important',
- },
- },
- },
- utilities: {
- '.shadow-card': {
- 'box-shadow': 'var(--shadow-border)',
- },
- '.shadow-card-lg': {
- 'box-shadow': 'var(--shadow-border-hover)',
- },
- '.scrollbar-hide': {
- '-ms-overflow-style': 'none',
- 'scrollbar-width': 'none',
- },
- '.scrollbar-hide::-webkit-scrollbar': {
- display: 'none',
- },
- '.scrollbar-neutral-thin': {
- 'scrollbar-width': 'thin',
- 'scrollbar-color':
- 'color-mix(in oklab, var(--muted-foreground) 30%, transparent) transparent',
- },
- '.scrollbar-neutral-thin::-webkit-scrollbar': {
- height: '6px',
- width: '6px',
- },
- '.scrollbar-neutral-thin::-webkit-scrollbar-track': {
- background: 'transparent',
- },
- '.scrollbar-neutral-thin::-webkit-scrollbar-thumb': {
- 'background-color': 'color-mix(in oklab, var(--muted-foreground) 30%, transparent)',
- 'border-radius': '3px',
- },
- '.scrollbar-neutral-thin::-webkit-scrollbar-thumb:hover': {
- 'background-color': 'color-mix(in oklab, var(--muted-foreground) 50%, transparent)',
- },
- '.animate-shimmer': {
- animation: 'shimmer 2s ease-in-out infinite',
- },
- '.animate-ai-shimmer-text': {
- animation: 'ai-shimmer-text 1.4s linear infinite',
- },
- '.animate-ai-pixel-on': {
- animation: 'ai-pixel-on 650ms ease-in-out infinite',
- },
- '.animate-ai-fade-up': {
- animation: 'ai-fade-up 300ms cubic-bezier(0.23, 1, 0.32, 1) both',
- },
- },
- keyframes: {
- 'pulse-glow': {
- '0%, 100%': {
- opacity: '0.3',
- transform: 'translate(-50%, -50%) scale(0.95)',
- },
- '50%': {
- opacity: '0.5',
- transform: 'translate(-50%, -50%) scale(1.05)',
- },
- },
- 'fade-scale-in': {
- from: { opacity: '0', transform: 'scale(0.9)' },
- to: { opacity: '1', transform: 'scale(1)' },
- },
- 'scale-in': {
- from: { opacity: '0', transform: 'scale(0.8)' },
- to: { opacity: '1', transform: 'scale(1)' },
- },
- 'bounce-soft': {
- '0%, 100%': { transform: 'translateY(0)' },
- '50%': { transform: 'translateY(-4px)' },
- },
- 'slide-up': {
- from: { opacity: '0', transform: 'translateY(20px)' },
- to: { opacity: '1', transform: 'translateY(0)' },
- },
- 'fade-in': {
- from: { opacity: '0' },
- to: { opacity: '1' },
- },
- 'fade-out': {
- from: { opacity: '1' },
- to: { opacity: '0' },
- },
- 'command-in': {
- from: { opacity: '0', scale: '0.98' },
- to: { opacity: '1', scale: '1' },
- },
- 'command-out': {
- from: { opacity: '1', scale: '1' },
- to: { opacity: '0', scale: '0.98' },
- },
- shimmer: {
- '0%': { 'background-position': '-200% 0' },
- '100%': { 'background-position': '200% 0' },
- },
- 'shimmer-slide': {
- to: { transform: 'translateX(100%)' },
- },
- // AI / agentic surfaces (Beautiful UI motion craft, Constructive tokens)
- 'ai-shimmer-text': {
- '0%': { 'background-position': '100% 0' },
- '100%': { 'background-position': '-100% 0' },
- },
- 'ai-pixel-on': {
- '0%, 100%': { opacity: '0.15' },
- '40%, 60%': { opacity: '0.95' },
- },
- 'ai-fade-up': {
- from: { opacity: '0', transform: 'translateY(6px)' },
- to: { opacity: '1', transform: 'translateY(0)' },
- },
- 'ai-bounce-dots': {
- '0%, 80%, 100%': { transform: 'scale(0.6)', opacity: '0.4' },
- '40%': { transform: 'scale(1)', opacity: '1' },
- },
- 'ai-typing': {
- '0%, 60%, 100%': { transform: 'translateY(0)', opacity: '0.4' },
- '30%': { transform: 'translateY(-3px)', opacity: '1' },
- },
- 'ai-wave': {
- '0%, 100%': { transform: 'scaleY(0.5)' },
- '50%': { transform: 'scaleY(1)' },
- },
- 'ai-pulse-dot': {
- '0%, 100%': { transform: 'scale(0.85)', opacity: '0.5' },
- '50%': { transform: 'scale(1.1)', opacity: '1' },
- },
- 'ai-loading-dots': {
- '0%, 20%': { opacity: '0' },
- '40%': { opacity: '1' },
- '100%': { opacity: '0' },
- },
- },
- globalCss: {
- '[data-slot="portal-root"] > *': {
- 'pointer-events': 'auto',
- },
- },
+ npmImports: ['tailwindcss', '@xyflow/react/dist/style.css', 'maplibre-gl/dist/maplibre-gl.css'],
+ npmSource: '../../dist',
+ darkVariant: '&:is(.dark *)',
+ light: {
+ background: 'oklch(1 0 0)',
+ foreground: 'oklch(0.3211 0 0)',
+ card: 'oklch(1 0 0)',
+ 'card-foreground': 'oklch(0.3211 0 0)',
+ popover: 'oklch(1 0 0)',
+ 'popover-foreground': 'oklch(0.3211 0 0)',
+ primary: 'oklch(0.688 0.1754 245.6151)',
+ 'primary-foreground': 'oklch(0.979 0.021 166.113)',
+ secondary: 'oklch(0.967 0.001 286.375)',
+ 'secondary-foreground': 'oklch(0.21 0.006 285.885)',
+ muted: 'oklch(0.967 0.001 286.375)',
+ 'muted-foreground': 'oklch(0.552 0.016 285.938)',
+ accent: 'oklch(0.967 0.001 286.375)',
+ 'accent-foreground': 'oklch(0.21 0.006 285.885)',
+ destructive: 'oklch(0.55 0.2 25)',
+ 'destructive-foreground': 'oklch(0.985 0 0)',
+ border: 'oklch(0.92 0.004 286.32)',
+ input: 'oklch(0.93 0.004 286.32)',
+ ring: 'oklch(0.871 0.006 286.286)',
+ 'chart-1': 'oklch(0.646 0.222 41.116)',
+ 'chart-2': 'oklch(0.6 0.118 184.704)',
+ 'chart-3': 'oklch(0.398 0.07 227.392)',
+ 'chart-4': 'oklch(0.828 0.189 84.429)',
+ 'chart-5': 'oklch(0.769 0.188 70.08)',
+ sidebar: 'oklch(0.985 0 0)',
+ 'sidebar-foreground': 'oklch(0.37 0.013 285.805)',
+ 'sidebar-primary': 'oklch(0.985 0 0)',
+ 'sidebar-primary-foreground': 'oklch(0.21 0.006 285.885)',
+ 'sidebar-accent': 'oklch(0.967 0.001 286.375)',
+ 'sidebar-accent-foreground': 'oklch(0.21 0.006 285.885)',
+ 'sidebar-border': 'oklch(0.92 0.004 286.32)',
+ 'sidebar-ring': 'oklch(0.871 0.006 286.286)',
+ info: 'var(--color-blue-500)',
+ 'info-foreground': 'var(--color-blue-700)',
+ success: 'var(--color-emerald-500)',
+ 'success-foreground': 'var(--color-emerald-700)',
+ warning: 'var(--color-amber-500)',
+ 'warning-foreground': 'var(--color-amber-700)',
+ 'map-control-filter': 'none',
+ 'map-accent': 'hsl(221 83% 53%)',
+ 'map-accent-medium': 'hsl(224 76% 48%)',
+ 'map-accent-strong': 'hsl(226 71% 40%)',
+ 'map-surface': 'hsl(0 0% 83%)',
+ 'map-surface-border': 'hsl(0 0% 100%)',
+ 'map-on-accent': 'hsl(0 0% 100%)',
+ ...sharedTypographyAndShape,
+ 'shadow-border':
+ '0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 1px -0.5px rgb(35 35 35 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.04), 0 2px 4px -1px rgb(35 35 35 / 0.03)',
+ 'shadow-border-hover':
+ '0 0 0 1px oklch(0 0 0 / 0.08), 0 1px 2px -1px rgb(35 35 35 / 0.08), 0 2px 4px -2px rgb(35 35 35 / 0.06), 0 4px 8px -2px rgb(35 35 35 / 0.04), 0 8px 16px -4px rgb(35 35 35 / 0.03)',
+ ...lightElevationShadows,
+ },
+ dark: {
+ background: 'oklch(0.21 0.006 285.885)',
+ foreground: 'oklch(0.985 0 0)',
+ card: 'oklch(0.21 0.006 285.885)',
+ 'card-foreground': 'oklch(0.985 0 0)',
+ popover: 'oklch(0.21 0.006 285.885)',
+ 'popover-foreground': 'oklch(0.985 0 0)',
+ primary: 'oklch(0.688 0.1754 245.6151)',
+ 'primary-foreground': 'oklch(0.979 0.021 166.113)',
+ secondary: 'oklch(0.274 0.006 286.033)',
+ 'secondary-foreground': 'oklch(0.985 0 0)',
+ muted: 'oklch(0.244 0.006 285.97)',
+ 'muted-foreground': 'oklch(0.705 0.015 286.067)',
+ accent: 'oklch(0.244 0.006 285.97)',
+ 'accent-foreground': 'oklch(0.985 0 0)',
+ destructive: 'oklch(0.55 0.2 25)',
+ 'destructive-foreground': 'oklch(0.985 0 0)',
+ border: 'oklch(0.29 0.009 285.83)',
+ input: 'oklch(0.29 0.009 285.83)',
+ ring: 'oklch(0.442 0.017 285.786)',
+ 'chart-1': 'oklch(0.488 0.243 264.376)',
+ 'chart-2': 'oklch(0.696 0.17 162.48)',
+ 'chart-3': 'oklch(0.769 0.188 70.08)',
+ 'chart-4': 'oklch(0.627 0.265 303.9)',
+ 'chart-5': 'oklch(0.645 0.246 16.439)',
+ sidebar: 'oklch(0.244 0.006 285.97)',
+ 'sidebar-foreground': 'oklch(0.967 0.001 286.375)',
+ 'sidebar-primary': 'oklch(0.596 0.145 163.225)',
+ 'sidebar-primary-foreground': 'oklch(1 0 0)',
+ 'sidebar-accent': 'oklch(0.274 0.006 286.033)',
+ 'sidebar-accent-foreground': 'oklch(0.967 0.001 286.375)',
+ 'sidebar-border': 'oklch(0.274 0.006 286.033)',
+ 'sidebar-ring': 'oklch(0.442 0.017 285.786)',
+ info: 'var(--color-blue-500)',
+ 'info-foreground': 'var(--color-blue-400)',
+ success: 'var(--color-emerald-500)',
+ 'success-foreground': 'var(--color-emerald-400)',
+ warning: 'var(--color-amber-500)',
+ 'warning-foreground': 'var(--color-amber-400)',
+ 'map-control-filter': 'invert(1)',
+ 'map-accent': 'hsl(217 91% 60%)',
+ 'map-accent-medium': 'hsl(213 94% 68%)',
+ 'map-accent-strong': 'hsl(211 96% 78%)',
+ 'map-surface': 'hsl(0 0% 25%)',
+ 'map-surface-border': 'hsl(0 0% 9%)',
+ 'map-on-accent': 'hsl(222 47% 11%)',
+ ...sharedTypographyAndShape,
+ 'shadow-border':
+ '0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 1px -0.5px rgb(0 0 0 / 0.25), 0 1px 2px -1px rgb(0 0 0 / 0.15), 0 2px 4px -1px rgb(0 0 0 / 0.10)',
+ 'shadow-border-hover':
+ '0 0 0 1px oklch(1 0 0 / 0.13), 0 1px 2px -1px rgb(0 0 0 / 0.30), 0 2px 4px -2px rgb(0 0 0 / 0.20), 0 4px 8px -2px rgb(0 0 0 / 0.15), 0 8px 16px -4px rgb(0 0 0 / 0.10)',
+ ...darkElevationShadows,
+ },
+ tailwind: {
+ '--color-background': 'var(--background)',
+ '--color-foreground': 'var(--foreground)',
+ '--font-sans': 'var(--font-geist-sans, "Open Sans", ui-sans-serif, system-ui, sans-serif)',
+ '--font-mono': 'var(--font-geist-mono, Menlo, Monaco, Consolas, "Liberation Mono", monospace)',
+ '--color-sidebar-ring': 'var(--sidebar-ring)',
+ '--color-sidebar-border': 'var(--sidebar-border)',
+ '--color-sidebar-accent-foreground': 'var(--sidebar-accent-foreground)',
+ '--color-sidebar-accent': 'var(--sidebar-accent)',
+ '--color-sidebar-primary-foreground': 'var(--sidebar-primary-foreground)',
+ '--color-sidebar-primary': 'var(--sidebar-primary)',
+ '--color-sidebar-foreground': 'var(--sidebar-foreground)',
+ '--color-sidebar': 'var(--sidebar)',
+ '--color-chart-5': 'var(--chart-5)',
+ '--color-chart-4': 'var(--chart-4)',
+ '--color-chart-3': 'var(--chart-3)',
+ '--color-chart-2': 'var(--chart-2)',
+ '--color-chart-1': 'var(--chart-1)',
+ '--color-ring': 'var(--ring)',
+ '--color-input': 'var(--input)',
+ '--color-border': 'var(--border)',
+ '--color-destructive': 'var(--destructive)',
+ '--color-accent-foreground': 'var(--accent-foreground)',
+ '--color-accent': 'var(--accent)',
+ '--color-muted-foreground': 'var(--muted-foreground)',
+ '--color-muted': 'var(--muted)',
+ '--color-secondary-foreground': 'var(--secondary-foreground)',
+ '--color-secondary': 'var(--secondary)',
+ '--color-primary-foreground': 'var(--primary-foreground)',
+ '--color-primary': 'var(--primary)',
+ '--color-popover-foreground': 'var(--popover-foreground)',
+ '--color-popover': 'var(--popover)',
+ '--color-card-foreground': 'var(--card-foreground)',
+ '--color-card': 'var(--card)',
+ '--radius-xs': 'calc(var(--radius) - 6px)',
+ '--radius-sm': 'calc(var(--radius) - 4px)',
+ '--radius-md': 'var(--radius)',
+ '--radius-lg': 'calc(var(--radius) + 2px)',
+ '--radius-xl': 'calc(var(--radius) + 6px)',
+ '--radius-2xl': 'calc(var(--radius) + 10px)',
+ '--color-warning-foreground': 'var(--warning-foreground)',
+ '--color-warning': 'var(--warning)',
+ '--color-success-foreground': 'var(--success-foreground)',
+ '--color-success': 'var(--success)',
+ '--color-info-foreground': 'var(--info-foreground)',
+ '--color-info': 'var(--info)',
+ '--color-destructive-foreground': 'var(--destructive-foreground)',
+ },
+ zIndex: {
+ 'z-layer-portal-root': '9999',
+ 'z-layer-floating': '1000',
+ 'z-layer-modal-backdrop': '2000',
+ 'z-layer-modal-content': '2001',
+ 'z-layer-floating-elevated': '3000',
+ 'z-layer-toast': '4000',
+ },
+ baseCss: {
+ ':root': {
+ 'color-scheme': 'light',
+ },
+ '.dark': {
+ 'color-scheme': 'dark',
+ },
+ '*': {
+ '@apply border-border/60 outline-ring/50': {},
+ },
+ body: {
+ '@apply bg-background font-sans text-foreground antialiased': {},
+ 'font-synthesis': 'none',
+ position: 'relative',
+ },
+ '#__next, [data-nextjs-root-layout]': {
+ isolation: 'isolate',
+ },
+ '@media (prefers-reduced-motion: reduce)': {
+ '*, *::before, *::after': {
+ 'scroll-behavior': 'auto !important',
+ 'animation-duration': '0.01ms !important',
+ 'animation-iteration-count': '1 !important',
+ 'transition-duration': '0.01ms !important',
+ },
+ },
+ },
+ utilities: {
+ '.shadow-card': {
+ 'box-shadow': 'var(--shadow-border)',
+ },
+ '.shadow-card-lg': {
+ 'box-shadow': 'var(--shadow-border-hover)',
+ },
+ '.scrollbar-hide': {
+ '-ms-overflow-style': 'none',
+ 'scrollbar-width': 'none',
+ },
+ '.scrollbar-hide::-webkit-scrollbar': {
+ display: 'none',
+ },
+ '.scrollbar-neutral-thin': {
+ 'scrollbar-width': 'thin',
+ 'scrollbar-color': 'color-mix(in oklab, var(--muted-foreground) 30%, transparent) transparent',
+ },
+ '.scrollbar-neutral-thin::-webkit-scrollbar': {
+ height: '6px',
+ width: '6px',
+ },
+ '.scrollbar-neutral-thin::-webkit-scrollbar-track': {
+ background: 'transparent',
+ },
+ '.scrollbar-neutral-thin::-webkit-scrollbar-thumb': {
+ 'background-color': 'color-mix(in oklab, var(--muted-foreground) 30%, transparent)',
+ 'border-radius': '3px',
+ },
+ '.scrollbar-neutral-thin::-webkit-scrollbar-thumb:hover': {
+ 'background-color': 'color-mix(in oklab, var(--muted-foreground) 50%, transparent)',
+ },
+ '.animate-shimmer': {
+ animation: 'shimmer 2s ease-in-out infinite',
+ },
+ '.animate-ai-shimmer-text': {
+ animation: 'ai-shimmer-text 1.4s linear infinite',
+ },
+ '.animate-ai-pixel-on': {
+ animation: 'ai-pixel-on 650ms ease-in-out infinite',
+ },
+ '.animate-ai-fade-up': {
+ animation: 'ai-fade-up 300ms cubic-bezier(0.23, 1, 0.32, 1) both',
+ },
+ },
+ keyframes: {
+ 'pulse-glow': {
+ '0%, 100%': {
+ opacity: '0.3',
+ transform: 'translate(-50%, -50%) scale(0.95)',
+ },
+ '50%': {
+ opacity: '0.5',
+ transform: 'translate(-50%, -50%) scale(1.05)',
+ },
+ },
+ 'fade-scale-in': {
+ from: { opacity: '0', transform: 'scale(0.9)' },
+ to: { opacity: '1', transform: 'scale(1)' },
+ },
+ 'scale-in': {
+ from: { opacity: '0', transform: 'scale(0.8)' },
+ to: { opacity: '1', transform: 'scale(1)' },
+ },
+ 'bounce-soft': {
+ '0%, 100%': { transform: 'translateY(0)' },
+ '50%': { transform: 'translateY(-4px)' },
+ },
+ 'slide-up': {
+ from: { opacity: '0', transform: 'translateY(20px)' },
+ to: { opacity: '1', transform: 'translateY(0)' },
+ },
+ 'fade-in': {
+ from: { opacity: '0' },
+ to: { opacity: '1' },
+ },
+ 'fade-out': {
+ from: { opacity: '1' },
+ to: { opacity: '0' },
+ },
+ 'command-in': {
+ from: { opacity: '0', scale: '0.98' },
+ to: { opacity: '1', scale: '1' },
+ },
+ 'command-out': {
+ from: { opacity: '1', scale: '1' },
+ to: { opacity: '0', scale: '0.98' },
+ },
+ shimmer: {
+ '0%': { 'background-position': '-200% 0' },
+ '100%': { 'background-position': '200% 0' },
+ },
+ 'shimmer-slide': {
+ to: { transform: 'translateX(100%)' },
+ },
+ // AI / agentic surfaces (Beautiful UI motion craft, Constructive tokens)
+ 'ai-shimmer-text': {
+ '0%': { 'background-position': '100% 0' },
+ '100%': { 'background-position': '-100% 0' },
+ },
+ 'ai-pixel-on': {
+ '0%, 100%': { opacity: '0.15' },
+ '40%, 60%': { opacity: '0.95' },
+ },
+ 'ai-fade-up': {
+ from: { opacity: '0', transform: 'translateY(6px)' },
+ to: { opacity: '1', transform: 'translateY(0)' },
+ },
+ 'ai-bounce-dots': {
+ '0%, 80%, 100%': { transform: 'scale(0.6)', opacity: '0.4' },
+ '40%': { transform: 'scale(1)', opacity: '1' },
+ },
+ 'ai-typing': {
+ '0%, 60%, 100%': { transform: 'translateY(0)', opacity: '0.4' },
+ '30%': { transform: 'translateY(-3px)', opacity: '1' },
+ },
+ 'ai-wave': {
+ '0%, 100%': { transform: 'scaleY(0.5)' },
+ '50%': { transform: 'scaleY(1)' },
+ },
+ 'ai-pulse-dot': {
+ '0%, 100%': { transform: 'scale(0.85)', opacity: '0.5' },
+ '50%': { transform: 'scale(1.1)', opacity: '1' },
+ },
+ 'ai-loading-dots': {
+ '0%, 20%': { opacity: '0' },
+ '40%': { opacity: '1' },
+ '100%': { opacity: '0' },
+ },
+ },
+ globalCss: {
+ '[data-slot="map"]': {
+ '@apply bg-muted/30 text-foreground': {},
+ },
+ '[data-slot="map-loading"]': {
+ '@apply bg-muted/40 text-muted-foreground': {},
+ },
+ '[data-slot="map-fallback"]': {
+ '@apply bg-muted/30 text-foreground': {},
+ },
+ '[data-slot="map-control-group"]': {
+ '@apply border-border bg-background text-foreground': {},
+ },
+ '[data-slot="map-marker"]': {
+ '@apply border-primary-foreground bg-primary text-primary-foreground': {},
+ },
+ '[data-slot="map"] .maplibregl-popup-content': {
+ '@apply rounded-none! bg-transparent! p-0! shadow-none!': {},
+ },
+ '[data-slot="map"] .maplibregl-popup-tip': {
+ '@apply hidden!': {},
+ },
+ '[data-slot="map"] .maplibregl-ctrl-attrib': {
+ '@apply rounded-md! border! border-border! bg-background! text-muted-foreground! shadow-sm!': {},
+ },
+ '[data-slot="map"] .maplibregl-ctrl-attrib a': {
+ '@apply text-muted-foreground!': {},
+ },
+ '[data-slot="map"] .maplibregl-ctrl-attrib a:hover': {
+ '@apply text-foreground!': {},
+ },
+ '[data-slot="map"] .maplibregl-ctrl-attrib-button': {
+ '@apply bg-transparent!': {},
+ filter: 'var(--map-control-filter)',
+ },
+ '[data-slot="portal-root"] > *': {
+ 'pointer-events': 'auto',
+ },
+ },
} as const satisfies ConstructiveThemeDefinition;
export type ConstructiveTheme = typeof constructiveTheme;
diff --git a/packages/ui/test/map.test.tsx b/packages/ui/test/map.test.tsx
new file mode 100644
index 0000000..7398a42
--- /dev/null
+++ b/packages/ui/test/map.test.tsx
@@ -0,0 +1,648 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { readFileSync } from 'node:fs';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const maplibreMock = vi.hoisted(() => ({
+ constructorError: null as Error | null,
+ gpuInitializationFailure: false,
+ initiallyLoaded: false,
+ initialStyleLoaded: false,
+ maps: [] as Array>,
+ markers: [] as Array>,
+ popups: [] as Array>,
+}));
+
+vi.mock('maplibre-gl', () => {
+ class GPUInitializationError extends Error {}
+
+ class MockMap {
+ options: Record;
+ painter: object | undefined;
+ handlers = new globalThis.Map void>>();
+ sources = new globalThis.Map>();
+ layers = new globalThis.Map>();
+ canvas = document.createElement('canvas');
+ viewport: { center: [number, number]; zoom: number; bearing: number; pitch: number };
+ remove = vi.fn();
+ setStyle = vi.fn(() => {
+ this.sources.clear();
+ this.layers.clear();
+ return this;
+ });
+ setProjection = vi.fn();
+ addSource = vi.fn((id: string, source: Record) => {
+ const runtimeSource = {
+ ...source,
+ setData: vi.fn((data: unknown) => {
+ runtimeSource.data = data;
+ }),
+ getClusterExpansionZoom: vi.fn(async () => 12),
+ };
+ this.sources.set(id, runtimeSource);
+ return this;
+ });
+ removeSource = vi.fn((id: string) => {
+ this.sources.delete(id);
+ return this;
+ });
+ addLayer = vi.fn((layer: Record, beforeId?: string) => {
+ this.layers.set(layer.id, { ...layer, beforeId });
+ return this;
+ });
+ removeLayer = vi.fn((id: string) => {
+ this.layers.delete(id);
+ return this;
+ });
+ easeTo = vi.fn();
+ flyTo = vi.fn();
+ zoomTo = vi.fn();
+ resetNorthPitch = vi.fn();
+ queryRenderedFeatures = vi.fn(() => []);
+ jumpTo = vi.fn((viewport: Partial) => {
+ this.viewport = { ...this.viewport, ...viewport };
+ });
+
+ constructor(options: Record) {
+ if (maplibreMock.constructorError) throw maplibreMock.constructorError;
+ this.options = options;
+ this.painter = maplibreMock.gpuInitializationFailure ? undefined : {};
+ this.viewport = {
+ center: options.center ?? [0, 0],
+ zoom: options.zoom ?? 0,
+ bearing: options.bearing ?? 0,
+ pitch: options.pitch ?? 0,
+ };
+ maplibreMock.maps.push(this);
+ }
+
+ on(event: string, ...args: any[]) {
+ const handler = args.at(-1);
+ const handlers = this.handlers.get(event) ?? new Set();
+ handlers.add(handler);
+ this.handlers.set(event, handlers);
+ return this;
+ }
+
+ off(event: string, ...args: any[]) {
+ this.handlers.get(event)?.delete(args.at(-1));
+ return this;
+ }
+
+ emit(event: string, payload?: unknown) {
+ for (const handler of this.handlers.get(event) ?? []) handler(payload);
+ }
+
+ fire(event: { type: string }) {
+ this.emit(event.type, event);
+ return this;
+ }
+
+ getSource(id: string) {
+ return this.sources.get(id);
+ }
+ getLayer(id: string) {
+ return this.layers.get(id);
+ }
+ getCanvas() {
+ return this.canvas;
+ }
+ getContainer() {
+ return this.options.container;
+ }
+
+ getCenter() {
+ return { lng: this.viewport.center[0], lat: this.viewport.center[1] };
+ }
+
+ getZoom() {
+ return this.viewport.zoom;
+ }
+
+ getBearing() {
+ return this.viewport.bearing;
+ }
+
+ getPitch() {
+ return this.viewport.pitch;
+ }
+
+ isMoving() {
+ return false;
+ }
+
+ loaded() {
+ return maplibreMock.initiallyLoaded;
+ }
+
+ isStyleLoaded() {
+ return maplibreMock.initialStyleLoaded;
+ }
+ }
+
+ class MockMarker {
+ options: Record;
+ element: HTMLElement;
+ handlers = new globalThis.Map void>>();
+ position = { lng: 0, lat: 0 };
+ draggable: boolean;
+ rotation = 0;
+ rotationAlignment = 'auto';
+ pitchAlignment = 'auto';
+ offset = { x: 0, y: 0 };
+ addTo = vi.fn(() => this);
+ remove = vi.fn();
+
+ constructor(options: Record) {
+ this.options = options;
+ this.element = options.element;
+ this.draggable = options.draggable ?? false;
+ maplibreMock.markers.push(this);
+ }
+
+ setLngLat([lng, lat]: [number, number]) {
+ this.position = { lng, lat };
+ return this;
+ }
+
+ getLngLat() {
+ return this.position;
+ }
+ getElement() {
+ return this.element;
+ }
+ isDraggable() {
+ return this.draggable;
+ }
+ setDraggable(value: boolean) {
+ this.draggable = value;
+ return this;
+ }
+ getOffset() {
+ return this.offset;
+ }
+ setOffset(value: [number, number]) {
+ this.offset = { x: value[0], y: value[1] };
+ return this;
+ }
+ getRotation() {
+ return this.rotation;
+ }
+ setRotation(value: number) {
+ this.rotation = value;
+ return this;
+ }
+ getRotationAlignment() {
+ return this.rotationAlignment;
+ }
+ setRotationAlignment(value: string) {
+ this.rotationAlignment = value;
+ return this;
+ }
+ getPitchAlignment() {
+ return this.pitchAlignment;
+ }
+ setPitchAlignment(value: string) {
+ this.pitchAlignment = value;
+ return this;
+ }
+ setPopup() {
+ return this;
+ }
+
+ on(event: string, handler: () => void) {
+ const handlers = this.handlers.get(event) ?? new Set();
+ handlers.add(handler);
+ this.handlers.set(event, handlers);
+ return this;
+ }
+
+ off(event: string, handler: () => void) {
+ this.handlers.get(event)?.delete(handler);
+ return this;
+ }
+
+ emit(event: string) {
+ for (const handler of this.handlers.get(event) ?? []) handler();
+ }
+ }
+
+ class MockPopup {
+ position: { lng: number; lat: number } | undefined;
+ open = false;
+ constructor() {
+ maplibreMock.popups.push(this);
+ }
+ setMaxWidth() {
+ return this;
+ }
+ setDOMContent() {
+ return this;
+ }
+ setOffset() {
+ return this;
+ }
+ setLngLat([lng, lat]: [number, number]) {
+ this.position = { lng, lat };
+ return this;
+ }
+ getLngLat() {
+ return this.position;
+ }
+ addTo() {
+ this.open = true;
+ return this;
+ }
+ remove() {
+ this.open = false;
+ }
+ isOpen() {
+ return this.open;
+ }
+ on() {
+ return this;
+ }
+ off() {
+ return this;
+ }
+ }
+
+ return {
+ GPUInitializationError,
+ Map: MockMap,
+ Marker: MockMarker,
+ Popup: MockPopup,
+ };
+});
+
+import {
+ Map,
+ MapArc,
+ MapClusterLayer,
+ MapControls,
+ MapGeoJSON,
+ MapMarker,
+ MapPopup,
+ MapRoute,
+ MarkerContent,
+ type MapViewport,
+} from '../src/components/map';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+let root: Root | undefined;
+
+function mockWebGL(supported = true) {
+ return vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(((kind: string) => {
+ if (!supported || kind !== 'webgl2') return null;
+ return { getExtension: vi.fn(() => null) } as unknown as WebGL2RenderingContext;
+ }) as typeof HTMLCanvasElement.prototype.getContext);
+}
+
+async function render(element: React.ReactNode) {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ await act(async () => root?.render(element));
+ return container;
+}
+
+async function waitForMap() {
+ await act(async () => {
+ await vi.waitFor(() => expect(maplibreMock.maps).toHaveLength(1));
+ });
+ return maplibreMock.maps[0];
+}
+
+async function waitForMarker() {
+ await act(async () => {
+ await vi.waitFor(() => expect(maplibreMock.markers).toHaveLength(1));
+ });
+ return maplibreMock.markers[0];
+}
+
+async function loadRenderedMap(map: Record) {
+ await act(async () => {
+ map.emit('load');
+ map.emit('style.load');
+ });
+}
+
+beforeEach(() => {
+ maplibreMock.constructorError = null;
+ maplibreMock.gpuInitializationFailure = false;
+ maplibreMock.initiallyLoaded = false;
+ maplibreMock.initialStyleLoaded = false;
+ maplibreMock.maps.length = 0;
+ maplibreMock.markers.length = 0;
+ maplibreMock.popups.length = 0;
+ mockWebGL();
+ Object.defineProperty(window, 'matchMedia', {
+ configurable: true,
+ value: vi.fn(() => ({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+ });
+});
+
+afterEach(async () => {
+ if (root) await act(async () => root?.unmount());
+ root = undefined;
+ document.body.replaceChildren();
+ vi.restoreAllMocks();
+});
+
+describe('Map', () => {
+ it('initializes with the demo style, reports viewport movement, and cleans up', async () => {
+ const onViewportChange = vi.fn();
+ const container = await render( );
+ const map = await waitForMap();
+
+ expect(map.options.style).toBe('https://demotiles.maplibre.org/style.json');
+ expect(map.options.attributionControl).toEqual({ compact: true });
+ await act(async () => {
+ map.emit('load');
+ map.emit('style.load');
+ map.viewport = { center: [106.7, 10.8], zoom: 12, bearing: 5, pitch: 20 };
+ map.emit('move');
+ });
+
+ expect(container.querySelector('[role="status"]')).toBeNull();
+ expect(onViewportChange).toHaveBeenCalledWith({
+ center: [106.7, 10.8],
+ zoom: 12,
+ bearing: 5,
+ pitch: 20,
+ });
+
+ await act(async () => root?.unmount());
+ root = undefined;
+ expect(map.remove).toHaveBeenCalledOnce();
+ });
+
+ it('reuses a single themed style and switches when a distinct style is supplied', async () => {
+ await render( );
+ const map = await waitForMap();
+ expect(map.options.style).toBe('light-style');
+
+ await act(async () => root?.render( ));
+ expect(map.setStyle).not.toHaveBeenCalled();
+
+ await act(async () => root?.render( ));
+ expect(map.setStyle).toHaveBeenCalledWith('dark-style', { diff: false });
+ });
+
+ it('resolves explicit styles before blank mode and blank mode before the demo style', async () => {
+ await render( );
+ expect((await waitForMap()).options.style).toEqual({
+ version: 8,
+ sources: {},
+ layers: [{ id: 'background', type: 'background', paint: { 'background-color': 'rgba(0, 0, 0, 0)' } }],
+ });
+
+ await act(async () => root?.unmount());
+ root = undefined;
+ maplibreMock.maps.length = 0;
+ await render( );
+ expect((await waitForMap()).options.style).toBe('host-style');
+ });
+
+ it('reconciles map and style state that completed during construction', async () => {
+ maplibreMock.initiallyLoaded = true;
+ maplibreMock.initialStyleLoaded = true;
+ const container = await render(
+
+
+ ,
+ );
+ const map = await waitForMap();
+
+ await act(async () => {
+ await vi.waitFor(() => expect(map.addSource).toHaveBeenCalledOnce());
+ });
+ expect(container.querySelector('[data-slot="map-loading"]')).toBeNull();
+ expect(map.sources.has('geojson-source-offline-preview')).toBe(true);
+ });
+
+ it('applies controlled viewport changes without echoing them', async () => {
+ const onViewportChange = vi.fn();
+ const initial: Partial = { center: [0, 0], zoom: 3 };
+ await render( );
+ const map = await waitForMap();
+ await act(async () =>
+ root?.render( ),
+ );
+
+ expect(map.jumpTo).toHaveBeenCalledWith({
+ center: [12, 34],
+ zoom: 8,
+ bearing: 0,
+ pitch: 0,
+ });
+ expect(onViewportChange).not.toHaveBeenCalled();
+ });
+
+ it('forwards marker drag coordinates', async () => {
+ const onDragEnd = vi.fn();
+ await render(
+
+
+
+
+ ,
+ );
+ const marker = await waitForMarker();
+ marker.position = { lng: 106.7, lat: 10.8 };
+ await act(async () => marker.emit('dragend'));
+ expect(onDragEnd).toHaveBeenCalledWith({ lng: 106.7, lat: 10.8 });
+ });
+
+ it('makes clickable markers keyboard accessible', async () => {
+ const onClick = vi.fn();
+ await render(
+
+
+
+
+ ,
+ );
+ const element = (await waitForMarker()).element as HTMLElement;
+ expect(element.getAttribute('role')).toBe('button');
+ expect(element.getAttribute('aria-label')).toBe('Warehouse');
+ expect(element.tabIndex).toBe(0);
+
+ await act(async () => element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it('renders a fallback for unsupported WebGL and constructor failures', async () => {
+ vi.restoreAllMocks();
+ mockWebGL(false);
+ const unsupportedError = vi.fn();
+ let container = await render(Coordinate editor only