# @trackunit/react-map

Provider-agnostic, hook-first map library for React applications. Switch between Google Maps and Mapbox without touching your application code.

## Design principles

- **Provider behind an adapter.** Google Maps or Mapbox is passed in as adapter config. Swapping providers is a one-line change; your components, events, and tests are untouched.
- **Hook-first, TanStack-style.** State, actions, and the renderable component are co-located at the call site via `useMap(adapter)`. No context tree to set up; no provider-specific types leaking into your code.
- **Resolver props — you decide.** Where choices are genuinely the consumer's — which markers render as DOM vs symbol, what style a hovered shape gets — the library hands you a fully-described context object and takes a small decision back. It never makes the heuristic call behind your back.
- **Defaults, but always composable.** Every resolver ships a sensible default that is exported for composition. Use it as-is, wrap it, or replace it entirely.
- **Atomic components.** `MapMarker`, `ClusterMarker`, `MapMarkerIcon`, and `Panel` are usable stand-alone, even outside a map.
- **Strong types.** Discriminated unions, exhaustive switches, and typed resolver contracts catch mistakes at compile time.
- **No vendor leakage.** All provider-specific types and APIs belong inside adapters. Nothing outside an adapter may import from `mapbox-gl` or `@vis.gl/react-google-maps`.
- **Self-contained, portable layers.** A well-designed layer hook owns its own data loading, controls, and settings so it can be dropped into any map without surrounding setup. Concretely, layers should accept a pre-built `filters` object as a prop rather than fetching raw data — the hook owns the query internally and the caller owns the filter shape.

## Three layers of abstraction

The library is intentionally split into three tiers. **Reach for the highest-level tier that satisfies your needs, and only drop down a layer when you need more control.** Starting at a lower tier than necessary means reinventing abstractions the higher tiers already provide.

### Tier 1 — `useMap` and `MapApi`

The foundation. Full control over camera, events, controls, panels, annotations, and the adapter surface. Maximum flexibility; you invent everything yourself.

Use this when you are building something structurally novel or need to escape a constraint in a higher-level hook.

### Tier 2 — Layer hooks (`useMarkers`, `useShapes`, …)

The everyday workhorse. Layer hooks give a high-productivity path to common map features without giving anything away. They surface full library behaviour with sensible defaults you can override one decision at a time via resolver props.

The atomic building blocks these compose are exported for reuse too: the output components (`MapMarker`, `ClusterMarker`, `MapMarkerIcon`) and helper hooks (`useAdaptiveMarkerHelpers`, `useShapeLabelHelpers`, …) that surface the default resolver heuristics. This is where almost all map feature work should live.

### Tier 3 — Your domain layer hooks

The top tier is your own domain hooks — built on top of Tier 2 primitives. Highly opinionated hooks that own data fetching, display logic, controls, and interaction end-to-end. Closed by design — no customization knobs. If you need something similar but with different behaviour, drop to Tier 2 and compose from the atomic pieces.

Every finished, map-ready layer is a Tier 3 hook, so the tier you pick is really your starting point: use a ready-made domain hook as-is, or assemble your own from Tier 2 primitives.

**This library exports generic primitives only.** Domain hooks belong in the domain library that serves them — not in `@trackunit/react-map`. Trackunit's own asset and site layer hooks are reference implementations that will be published separately. If you build a domain layer that would be valuable beyond your own app, consider publishing it as its own package too.

## Installation

```bash
npm install @trackunit/react-map
```

Map providers live in separate adapter packages — install whichever one(s) you use alongside it:

```bash
# Google Maps
npm install @trackunit/react-map-adapter-google

# Mapbox
npm install @trackunit/react-map-adapter-mapbox
```

Always import providers from the adapter package, never from `@trackunit/react-map`:
- Google Maps: `@trackunit/react-map-adapter-google` (`googleMapsAdapter`, `GoogleApiProvider`, …)
- Mapbox: `@trackunit/react-map-adapter-mapbox` (`mapboxAdapter`, …)

## Usage

### Google Maps

```tsx
import { useCameraState, useMap } from "@trackunit/react-map";
import { googleMapsAdapter } from "@trackunit/react-map-adapter-google";

const MyMap = () => {
  const [Map, api] = useMap(googleMapsAdapter({
    apiKey: process.env.GOOGLE_MAPS_API_KEY,
    theme: "light",
    language: "en",
  }));

  const { center, zoom } = useCameraState(api);

  return (
    <div className="relative h-full">
      <p>Center: {center[0]}, {center[1]}</p>
      <p>Zoom: {zoom}</p>
      <Map className="h-full w-full">
        {/* Children render inside the map */}
      </Map>
    </div>
  );
};
```

### Mapbox

```tsx
import { useCameraState, useMap } from "@trackunit/react-map";
import { mapboxAdapter } from "@trackunit/react-map-adapter-mapbox";

const MyMap = () => {
  const [Map, api] = useMap(mapboxAdapter({
    accessToken: process.env.MAPBOX_ACCESS_TOKEN,
    theme: "dark",
    language: "en",
  }));

  const { center, zoom } = useCameraState(api);

  return (
    <div className="relative h-full">
      <p>Center: {center[0]}, {center[1]}</p>
      <p>Zoom: {zoom}</p>
      <Map className="h-full w-full">
        {/* Same children work with both Google and Mapbox! */}
      </Map>
    </div>
  );
};
```

### IrisX apps

The same CSP setup applies to both adapters: import the adapter's CSP factory and spread its result into `cspHeader` in `iris-app-manifest.ts`.

- Google Maps: call `googleMapCspDirectives()` from `@trackunit/react-map-adapter-google`.
- Mapbox: call `mapboxMapCspDirectives()` from `@trackunit/react-map-adapter-mapbox`.

Mapbox apps must supply their own public access token. See the [`Google Maps adapter`](https://www.npmjs.com/package/@trackunit/react-map-adapter-google) and [`Mapbox adapter`](https://www.npmjs.com/package/@trackunit/react-map-adapter-mapbox) READMEs.

## API

### `useMap(adapter)`

Returns a tuple `[Map, api]`:

- `Map` - React component to render the map
- `api.state` - Map status (isReady, initializationFailed, appearance, rasterResolution)
- `useCameraState(api)` - Reactive camera state (center, zoom, bounds, isIdle — changes at up to 60fps)
- `api.actions.setCenter(position)` - Set map center (returns promise)
- `api.actions.setZoom(zoom)` - Set zoom level (returns promise)
- `api.actions.zoomBy(delta)` - Adjust zoom by delta (returns promise)
- `api.actions.fitBounds(bounds, options)` - Fit map to bounds (returns promise)
- `api.actions.panTo(position)` - Pan to position with animation (returns promise)
- `api.on(event, handler)` - Subscribe to map events (returns unsubscribe function)

### Coordinates

All coordinates use GeoJSON format: `[longitude, latitude]`

```tsx
const berlin: GeoJsonPosition = [13.405, 52.52]; // [lng, lat]
```

### Bounding Boxes

All bounding boxes use GeoJSON format: `[minLng, minLat, maxLng, maxLat]`

```tsx
const europeBounds: GeoJsonBbox = [-10, 35, 40, 70];
```

### Map Actions

All actions return promises that resolve when the map finishes moving:

```tsx
await api.actions.setCenter([13.405, 52.52]);
await api.actions.setZoom(12);
await api.actions.fitBounds([-10, 35, 40, 70], { padding: 50, maxZoom: 15 });
```

### Map State

`api.state` contains low-frequency MapStatus (readiness, initialization failure, appearance, raster resolution). For CameraState that changes during panning/zooming, use `useCameraState(api)`:

```tsx
// Initialization state — rarely changes
const { isReady, appearance } = api.state;

// Camera state — changes at up to 60fps during panning
const { center, zoom, bounds, isIdle } = useCameraState(api);

// Only re-renders when zoom changes (opt-in)
const { zoom } = useCameraState(api);
useEffect(() => {
  console.log("Zoom changed:", zoom);
}, [zoom]);
```

### Map Events

Subscribe to map events with type-safe handlers:

```tsx
useEffect(() => {
  const unsubscribe = api.on("click", (e) => {
    console.log("Clicked at:", e.position);
  });

  return unsubscribe;
}, [api.on]);
```

Available events:
- `idle` - Map finished moving
- `movestart` - Map started moving
- `moveend` - Map finished moving (includes final state)
- `click` - Map clicked (includes position and original event)

## Switching Providers

To switch providers, only change the adapter configuration:

```tsx
// Before: Google Maps
const [Map, api] = useMap(googleMapsAdapter({ apiKey: "..." }));

// After: Mapbox
const [Map, api] = useMap(mapboxAdapter({ accessToken: "..." }));

// Everything else stays the same!
```

## Configuration Options

### Google Maps Config

```tsx
type GoogleMapsConfig = {
  apiKey: string;              // Required
  theme?: "light" | "dark";    // Default: "light"
  language?: string;           // Map labels language
  initialViewport?: InitialViewport;  // Initial view: center+zoom or bounds
  restrictBounds?: GeoJsonBbox | null;  // Default: no restriction; null = no restriction
};
```

### Mapbox Config

```tsx
type MapboxConfig = {
  accessToken: string;         // Required
  theme?: "light" | "dark";    // Default: "light"
  language?: string;           // Map labels language
  initialViewport?: InitialViewport;  // Initial view: center+zoom or bounds
  restrictBounds?: GeoJsonBbox | null;  // Default: no restriction; null = no restriction
};
```

`restrictBounds` controls panning/zoom bounds. GeoJSON format: `[minLng, minLat, maxLng, maxLat]`.
- `undefined` (default): no restriction (infinite horizontal scroll)
- `null`: no restriction (infinite horizontal scroll)
- `GeoJsonBbox`: restrict to custom region (e.g. Europe: `[-10, 35, 40, 70]`)

Invalid bboxes are validated with `geoJsonBboxSchema`; on failure, a console warning is emitted and no restriction is applied (fallback to `null`).

`InitialViewport` is a discriminated union:
- `{ type: "center"; center: [lng, lat]; zoom?: number }` — center with optional zoom
- `{ type: "bounds"; bounds: GeoJsonBbox; padding?: number; maxZoom?: number }` — fit to bounds

## Accessibility

Maps include built-in accessibility features:

- ARIA labels and roles
- Keyboard navigation:
  - Arrow keys: Pan map (100px)
  - +/-: Zoom in/out (1 level)
  - Home: Reset to default view
  - Tab: Focus map
  - Escape: Deselect/close popups

## Architecture

`@trackunit/react-map` is built in independently-useful layers: provider **adapters**, the **`useMap`** foundation and `MapApi`, **layer hooks** (`useMarkers`, `useShapes`, …), and **atomic components** (`MapMarker`, `ClusterMarker`, …). Each tier is usable and testable on its own — see the design principles and three-tier model above.

Provider integrations ship as separate packages:
- `@trackunit/react-map-adapter-google` — Google Maps
- `@trackunit/react-map-adapter-mapbox` — Mapbox

## Adding New Providers

To add a new map provider:

1. Create adapter instance implementing `AdapterInstance<TConfig>`
2. Create type converters (GeoJSON ↔ provider types)
3. Create renderer component
4. Create adapter factory using `defineAdapter()`
5. Export from `index.ts`

Example structure:
```
src/adapters/leaflet/
├── leafletAdapter.ts
├── LeafletAdapterInstance.ts
├── LeafletRenderer.tsx
├── leafletTypeConverters.ts
└── constants.ts
```

For more info and a full guide on Iris App SDK Development, please visit our [Developer Hub](https://developers.trackunit.com/).

## Trackunit

This package was developed by Trackunit ApS.
Trackunit is the leading SaaS-based IoT solution for the construction industry, offering an ecosystem of hardware, fleet management software & telematics.

![The Trackunit logo](https://trackunit.com/wp-content/uploads/2022/03/top-logo.svg)
