# Map

Interactive maps for `@djangocfg/ui-tools`, built on [MapLibre GL](https://maplibre.org/) via `react-map-gl`. Ships markers, clustered GeoJSON, popups, drawing/geocoder hooks, layer switching, and helpers for overlapping points — all behind a small Provider/Context surface so nothing leaks the raw `Map` instance unless you ask for it.

> **Bundle warning.** MapLibre GL is heavy (~800 KB gzipped). Prefer `LazyMapContainer` from `./lazy` unless the map is above the fold. All components are `'use client'` — never render them on the Next.js server.

## Quick start

```tsx
import { MapContainer, MapMarker } from '@djangocfg/ui-tools/map';

<div className="h-96 rounded-xl overflow-hidden">
  <MapContainer
    initialViewport={{ latitude: 25.08, longitude: 55.14, zoom: 13 }}
    mapStyle="light"
  >
    <MapMarker
      marker={{ id: 'home', latitude: 25.08, longitude: 55.14 }}
      color="#10b981"
      size={32}
    />
  </MapContainer>
</div>
```

The container provides its own `MapProvider`. If you need the map context outside `MapContainer` (for example to call `flyTo` on a sibling button), wrap your tree in `<MapProvider>` and use `<MapView>` inside it instead.

## Public exports

### Components

| Export | Notes |
|---|---|
| `MapContainer` | Self-contained map: `MapProvider` + `MapView` + reset / open-in-maps overlay buttons |
| `MapView` | Inner map only — must live inside an existing `<MapProvider>` |
| `MapProvider` | Context provider (viewport, ref, markers, hover state, isLoaded) |
| `MapMarker` | DOM marker with default pin, custom children, drag |
| `MapPopup` | MapLibre popup wrapper |
| `MapCluster` | GeoJSON source + cluster/point layers + click-to-zoom + optional render-prop popup |
| `MapSource` / `MapLayer` | Thin GeoJSON `Source` + `Layer` re-exports |
| `MapControls` | NavigationControl / Fullscreen / Geolocate / Scale toggles |
| `CustomOverlay` | DOM overlay that re-renders on `move` (uses `useControl` + portal) |
| `MapLegend` | Floating legend with circle / line / fill / symbol icons |
| `LayerSwitcher` | Floating checkbox panel that toggles layer visibility |

### Hooks

| Hook | Returns |
|---|---|
| `useMap()` | Full map context (alias for `useMapContext`) |
| `useMapContext()` | Same — throws if used outside `<MapProvider>` |
| `useMapControl()` | `flyTo` / `easeTo` / `fitBounds` / `zoomIn` / `zoomOut` / `resetView` / `getCenter` / `getZoom` / `getBounds` |
| `useMapViewport()` | `{ viewport, setViewport, animateTo, zoom, center, bounds }` |
| `useMarkers()` | `addMarker` / `removeMarker` / `updateMarker` / `clearMarkers` / `fitToMarkers` |
| `useMapEvents(handlers)` | Subscribe to `click` / `hover` / `move*` / `zoom*` / `load` with cleanup |
| `useMapLayers()` | `addLayer` / `removeLayer` / `setLayerVisibility` / `setLayerFilter` / `setLayerPaint` |
| `useSpiderfy(markers, opts?)` | Auto-offset overlapping markers; optional click-to-expand |
| `useControl(...)` | Re-exported from `react-map-gl/maplibre` for custom controls |

### Layer factories

`createClusterLayers`, `createPointLayer`, `createHeatmapLayer`, `createPolygonLayer`, `createPolygonOutlineLayer`, `createHighlightLayer`, `createLineLayer`, `createRouteLayers`, `createDashedLineLayer`, `createAnimatedLineLayer`.

### Geometry utils

`calculateBounds`, `calculateCenter`, `calculateDistance` (Haversine, km), `isPointInBounds`, `expandBounds`, `toGeoJSON`, `fromGeoJSON`, `createPoint`, `createPolygon`, `createLineString`, `createFeatureCollection`.

### Spiderfy utils

`offsetOverlappingMarkers`, `getSpiderfyPositions`, `groupOverlappingMarkers`, `hasOverlappingMarkers`, `getOverlapStats`.

### Styles

`MAP_STYLES`, `getMapStyle(key)`.

### Re-exports from `react-map-gl/maplibre`

`Source`, `Layer`, `Marker`, `Popup`, types `ViewState` and `MapRef`.

## `MapContainer` props

| Prop | Type | Default | Notes |
|---|---|---|---|
| `initialViewport` | `Partial<MapViewport>` | `{ longitude: 115.1889, latitude: -8.4095, zoom: 10 }` | Bali fallback. Provide your own. |
| `mapStyle` | `MapStyleKey \| string` | `'light'` | Built-in key or full style URL/object |
| `interactiveLayerIds` | `string[]` | — | Layer ids that emit `click` / `hover` events |
| `attributionControl` | `boolean` | `true` | |
| `reuseMaps` | `boolean` | `true` | Reuse the canvas across remounts (faster Storybook / route changes) |
| `cursor` | `string` | — | Cursor while not dragging |
| `style` | `CSSProperties` | — | Forwarded to inner `<Map>` (size, etc.) |
| `className` | `string` | — | Outer wrapper `<div>` |
| `openInMapsUrl` | `string` | — | Renders an "Open in Maps" pill in the bottom-right |
| `openInMapsLabel` | `string` | `'Open in Maps'` | |
| `showResetButton` | `boolean` | `false` | Shows a reset pill once the user has panned/zoomed away from `initialViewport` |
| `autoResetDelay` | `number` (ms) | `0` | After this many ms of inactivity, fly back to `initialViewport`. `0` disables. |

`MapView` accepts every prop above except `initialViewport` (which lives on the surrounding `MapProvider`).

## Markers

```tsx
<MapMarker
  marker={{ id: 'pin', longitude: 2.35, latitude: 48.85, data: { city: 'Paris' } }}
  color="#3b82f6"
  size={28}
  anchor="bottom"
  draggable
  onClick={(m) => console.log(m.id)}
  onDragEnd={(m, lngLat) => save(m.id, lngLat)}
/>
```

Pass `children` to replace the default SVG pin entirely:

```tsx
<MapMarker marker={pin}>
  <div className="size-8 rounded-full bg-primary text-white grid place-items-center">
    {pin.data?.count}
  </div>
</MapMarker>
```

`MarkerData` is intentionally minimal: `{ id, longitude, latitude, data? }`. Carry your domain payload in `data` — markers are generic over what's there.

## Popups

```tsx
<MapPopup
  longitude={loc.lng}
  latitude={loc.lat}
  onClose={() => setSelected(null)}
  anchor="bottom"
  offset={20}
>
  <div className="p-3">…</div>
</MapPopup>
```

For "click marker → fly to it → open popup" flow, use `useMapControl().flyTo` from a component nested inside `<MapProvider>` (see `Map.story.tsx` → `WithPopup`).

## Clustering

`MapCluster` mounts a clustered GeoJSON source with three layers (cluster bubbles, count labels, unclustered points), wires click + hover handlers, and optionally renders a popup via render prop.

```tsx
<MapCluster
  sourceId="properties"
  data={featureCollection}
  clusterRadius={60}
  clusterMaxZoom={14}
  colors={['#3b82f6', '#8b5cf6', '#ec4899']}
  onPointClick={(feature) => setSelected(feature)}
  renderPopup={(feature, close) => (
    <Card>
      <h3>{feature.properties?.name}</h3>
      <button onClick={close}>Close</button>
    </Card>
  )}
  popupAnchor="bottom"
  popupOffset={15}
/>
```

Click on a cluster zooms to its expansion zoom. Hover sets `feature-state.hover` so you can tint the bubble in your paint expressions.

### Overlapping points

When several features share a coordinate (e.g. multiple units in one building), pre-process the source so they're individually clickable when unclustered:

```tsx
import { offsetOverlappingMarkers } from '@djangocfg/ui-tools/map';

const data = useMemo(() => {
  const markers = features.map((f, i) => ({
    id: f.properties?.id ?? `p-${i}`,
    longitude: f.geometry.coordinates[0],
    latitude: f.geometry.coordinates[1],
    data: f.properties,
  }));
  const offset = offsetOverlappingMarkers(markers, { spiralRadius: 0.0003 });
  return {
    type: 'FeatureCollection' as const,
    features: offset.map((m) => ({
      type: 'Feature' as const,
      properties: m.data,
      geometry: { type: 'Point' as const, coordinates: [m.longitude, m.latitude] },
    })),
  };
}, [features]);
```

`offsetOverlappingMarkers` uses a Fermat spiral by default; pass `useJitter: true` for randomised offsets keyed on marker `id`. The `useSpiderfy` hook wraps this with React state and an optional click-to-expand mode.

## Layers

Drop a `MapSource` + `MapLayer` for raw GeoJSON, or use the layer factories for typed defaults:

```tsx
import {
  MapSource, MapLayer,
  createPolygonLayer, createPolygonOutlineLayer,
} from '@djangocfg/ui-tools/map';

<MapSource id="zones" data={polygonGeoJson}>
  <MapLayer {...createPolygonLayer({ fillColor: '#10b981', fillOpacity: 0.2 })} />
  <MapLayer {...createPolygonOutlineLayer({ strokeColor: '#10b981', strokeWidth: 2 })} />
</MapSource>
```

Available factories: `createClusterLayers`, `createPointLayer`, `createHeatmapLayer`, `createPolygonLayer`, `createPolygonOutlineLayer`, `createHighlightLayer`, `createLineLayer`, `createRouteLayers`, `createDashedLineLayer`, `createAnimatedLineLayer`.

## Map styles

Four built-in keys, all served from public CDNs (no API key needed except `satellite`, which uses MapTiler):

| Key | Source |
|---|---|
| `light` | CartoCDN Positron |
| `dark` | CartoCDN Dark Matter |
| `streets` | CartoCDN Voyager |
| `satellite` | MapTiler Satellite (replace with your own key in production) |

```tsx
import { MAP_STYLES, getMapStyle } from '@djangocfg/ui-tools/map';

<MapContainer mapStyle="dark" />
<MapContainer mapStyle="https://your-cdn.example/style.json" />
<MapContainer mapStyle={getMapStyle(userPref)} />
```

## Hooks

All hooks must be called inside `<MapProvider>` (which `MapContainer` provides automatically).

### `useMapControl()`

Imperative camera control. All animations have sensible defaults (`flyTo` 2 s, `easeTo` 0.5 s, `fitBounds` 1 s, padding 50 px).

```tsx
const { flyTo, fitBounds, zoomIn, getBounds } = useMapControl();
flyTo([lng, lat], 14, { duration: 1500 });
fitBounds([[w, s], [e, n]], { padding: 80 });
```

### `useMapViewport()`

Reactive viewport state plus an `animateTo` shortcut.

```tsx
const { viewport, animateTo, bounds } = useMapViewport();
animateTo({ latitude: 0, longitude: 0, zoom: 2 }, 800);
```

### `useMarkers()`

Manages markers held by `MapProvider` (separate from any `<MapMarker>` you render manually). Generates ids automatically.

```tsx
const { addMarker, fitToMarkers, clearMarkers } = useMarkers();
const id = addMarker({ longitude, latitude, data: { kind: 'pickup' } });
fitToMarkers(80);
```

### `useMapEvents(handlers)`

Attaches handlers to the underlying MapLibre map once it's loaded; cleans up on unmount or when handlers change.

```tsx
useMapEvents({
  onClick: (e) => console.log(e.lngLat, e.features),
  onMoveEnd: (vp) => persistViewport(vp),
  onZoomEnd: (z) => setZoom(z),
});
```

### `useMapLayers()`

Imperative layer mutation — for layers added at runtime via `addLayer`, or for tweaking declarative `<MapLayer>` instances by id.

```tsx
const { setLayerVisibility, setLayerFilter, setLayerPaint } = useMapLayers();
setLayerVisibility('zones-fill', visible);
setLayerFilter('zones-fill', ['==', ['get', 'status'], 'active']);
```

### `useSpiderfy(markers, options?)`

Higher-level than `offsetOverlappingMarkers`: returns processed markers, expansion state, and `expandGroup` / `collapseGroup` callbacks.

```tsx
const { markers, expandGroup, isExpanded } = useSpiderfy(rawMarkers, {
  spiralRadius: 0.0002,
  expandOnClick: true,
});
```

## Geometry utilities

```tsx
import {
  calculateBounds, calculateCenter, calculateDistance,
  expandBounds, isPointInBounds,
  toGeoJSON, fromGeoJSON,
  createPoint, createPolygon, createLineString, createFeatureCollection,
} from '@djangocfg/ui-tools/map';

const bounds = calculateBounds(points.map((p) => [p.lng, p.lat]));
const km = calculateDistance([fromLng, fromLat], [toLng, toLat]); // Haversine
const fc = toGeoJSON(items, (i) => [i.lng, i.lat], (i) => ({ name: i.name }));

useMapControl().fitBounds(expandBounds(bounds, 10), { padding: 50 });
```

## Floating UI

`MapLegend` and `LayerSwitcher` are absolutely-positioned panels (`top-left` / `top-right` / `bottom-left` / `bottom-right`). They use inline styles for portability — wrap them in your own component if you want shadcn theming.

```tsx
<MapLegend
  title="Activity"
  position="bottom-left"
  collapsible
  items={[
    { id: 'high', label: 'High', color: '#ef4444', type: 'circle' },
    { id: 'low',  label: 'Low',  color: '#3b82f6', type: 'circle' },
  ]}
/>

<LayerSwitcher
  position="top-right"
  showToggleAll
  layers={[
    { id: 'zones-fill', label: 'Zones', defaultVisible: true },
    { id: 'routes-line', label: 'Routes', defaultVisible: false },
  ]}
  onChange={(id, visible) => console.log(id, visible)}
/>
```

## DrawControl & GeocoderControl

These are documentation stubs — both rely on optional peer deps and are easy to wire by hand:

- `DrawControl` → `npm i @mapbox/mapbox-gl-draw`
- `GeocoderControl` → `npm i @maplibre/maplibre-gl-geocoder` + an HTTP geocoder (Nominatim, Photon, your backend)

See the JSDoc in `components/DrawControl.tsx` and `components/GeocoderControl.tsx` for ready-to-paste implementations using the re-exported `useControl` hook.

## Lazy loading

```tsx
import { LazyMapContainer } from '@djangocfg/ui-tools/map/lazy';

<LazyMapContainer
  initialViewport={{ latitude: 0, longitude: 0, zoom: 2 }}
  mapStyle="light"
/>
```

`LazyMapContainer` and `LazyMapView` defer the MapLibre GL chunk until the component renders. They show a built-in `MapLoadingFallback` (skeleton, `minHeight={400}`) while the chunk is in-flight. Prefer the lazy variants on routes where the map is below the fold or behind a tab.

## SSR

Every component is `'use client'` and depends on `window`. In Next.js App Router this is fine — they will only render on the client. In the Pages Router or any custom SSR setup, gate them with `dynamic(() => …, { ssr: false })` or use the lazy variants from `./lazy`.

## Stories

Run `pnpm playground` from `packages/ui-tools/`. Map stories in `Map.story.tsx`:

| Story | Demonstrates |
|---|---|
| `Interactive` | Style switcher + zoom + reset button + auto-reset |
| `PropertyCard` | Embedded compact map inside a card |
| `DarkStyle` | `mapStyle="dark"` |
| `MultipleMarkers` | World view with several pins |
| `WithAutoReset` | `autoResetDelay={3000}` |
| `WithPopup` | `useMapControl().flyTo` + `<MapPopup>` |
| `SpiderfyCluster` | `MapCluster` + `offsetOverlappingMarkers` |

## Related

- Public surface: `src/tools/Map/index.ts`
- Lazy preset: `src/tools/Map/lazy.tsx`
- Underlying lib: [`react-map-gl` (MapLibre adapter)](https://visgl.github.io/react-map-gl/) on top of [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/)
