/**
* Map — a themed vector map.
*
* Everything visible is built from theme tokens rather than shipped as a
* finished style, so the map belongs to whatever theme is active instead of
* being the one rectangle on the screen that stayed grey. See `basemap.ts` for
* how the style is assembled; the short version is that the tiles are bought
* and the colours are ours.
*
* ```tsx
*
* ```
*
* The renderer is native and optional, so `Map` has a state no other component
* here does: it may be unable to draw at all. It says so in place rather than
* throwing, because the usual way to reach that state is running in a client
* that cannot load native modules, and a screen explaining the build you need
* is more use than a stack trace.
*
* `Map.Popup` reads its position from the marker it sits inside, the way a
* frame's title reads its weight from its slot. Given a `lngLat` of its own it
* anchors to that coordinate instead, so the same name covers both the popup
* attached to a pin and the one floating over a place with no pin at all.
*/
import {
Children,
createContext,
forwardRef,
isValidElement,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { Pressable, StyleSheet, View, type PressableProps, type ViewProps } from 'react-native';
import { useCSSVariable } from 'uniwind';
import { tv } from 'tailwind-variants';
import { CompassIcon, CrosshairIcon, MinusIcon, PlusIcon } from '../../icons';
import { Text, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
import {
buildBasemapStyle,
CARTO_SOURCE,
type BasemapSource,
type BasemapTokens,
} from './basemap';
import {
hasMapLibre,
MapLibre,
type CameraRef,
type LngLat,
type LngLatBounds,
type MapRef,
type PixelPoint,
type StyleSpecification,
type ViewState,
} from './maplibre';
import {
describeMapFeatures,
type MapFeatureAccessibility,
type MapFeatureAccessibilityDescription,
} from './map-accessibility';
import { asMapLayer, partitionMapChildren } from './map-children';
export { hasMapLibre, CARTO_SOURCE };
export type { BasemapSource, BasemapTokens, LngLat, LngLatBounds, PixelPoint, ViewState };
export type { MapFeatureAccessibility } from './map-accessibility';
const mapVariants = tv({
slots: {
root: 'flex-1 overflow-hidden',
controls: 'absolute gap-2',
// A control group is one surface split by hairlines rather than separate
// buttons: at 36pt a gap between them reads as damage, not as spacing.
group: 'overflow-hidden rounded-xl border border-border bg-card shadow-sm',
control: 'h-9 w-9 items-center justify-center active:bg-muted',
popup: 'rounded-xl border border-border bg-popover px-3 py-2 shadow-lg',
pin: 'h-3.5 w-3.5 rounded-full border-2 border-background bg-primary shadow-md',
// Opaque enough to read over a landmass fill, with an edge and a shadow so
// it sits on the map rather than in it. At 85% the basemap's own roads and
// borders showed through the text.
label: 'rounded-md border border-border bg-background/95 shadow-sm',
labelText: 'text-foreground',
},
variants: {
position: {
'top-left': { controls: 'left-3 top-3' },
'top-right': { controls: 'right-3 top-3' },
'bottom-left': { controls: 'bottom-3 left-3' },
'bottom-right': { controls: 'bottom-3 right-3' },
},
},
defaultVariants: { position: 'bottom-right' },
});
/**
* The two knobs on a label's pill.
*
* Lookups rather than `tv()` variants because they belong to `Map.Label`, not
* to `Map` — a variant here would be read off the root and documented as
* something you could pass to the map itself.
*/
const labelSize: Record<'sm' | 'md', string> = {
sm: 'px-1.5 py-px',
md: 'px-2 py-0.5',
};
const labelTone: Record<'default' | 'muted' | 'primary', { pill: string; text: string }> = {
default: { pill: '', text: '' },
muted: { pill: '', text: 'text-muted-foreground' },
primary: {
pill: 'border-primary/24 bg-primary',
text: 'text-primary-foreground',
},
};
export type MapControlsPosition =
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
/** Reads a theme token, falling back when it resolves to something unusable. */
function useToken(name: string, fallback: string): string {
const value = useCSSVariable(name);
return typeof value === 'string' && value.length > 0 ? value : fallback;
}
/** True for a theme token name, as opposed to a literal colour. */
function isToken(value: string | undefined): value is string {
return typeof value === 'string' && value.startsWith('--');
}
/**
* The same colour at a given alpha, for a ramp built from one hue.
*
* Style expressions are handed to the renderer as strings, so this produces
* `rgba()` rather than reaching for a colour type the layer would not accept.
*/
function withAlpha(color: string, alpha: number): string {
if (color.startsWith('#')) {
const hex = color.slice(1);
const full =
hex.length === 3
? hex
.split('')
.map((c) => c + c)
.join('')
: hex.slice(0, 6);
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
if (Number.isNaN(r + g + b)) return color;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
if (channels) {
const [r, g, b] = channels.split(',').map((part) => part.trim());
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
return color;
}
interface MapContextValue {
mapRef: React.RefObject;
cameraRef: React.RefObject;
/** The style has loaded and the map is drawing. */
ready: boolean;
}
interface AccessibleFeatureGroup {
features: MapFeatureAccessibilityDescription[];
onPress?: (feature: unknown) => void;
}
interface InternalMapContextValue extends MapContextValue {
registerAccessibleFeatures(id: string, group: AccessibleFeatureGroup | null): void;
}
const MapContext = createContext(null);
/**
* The map's camera and view state, from inside it.
*
* For a control, an overlay, or anything that has to move the map in response
* to something that is not a gesture on it.
*/
export function useMap(): MapContextValue {
const context = useContext(MapContext);
if (!context) throw new Error('useMap must be used inside a