Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 96 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Built with [Nitro Modules](https://nitro.margelo.com/) for high-performance nati
- [Map providers](#map-providers)
- [Native POI press events](#native-poi-press-events)
- [Custom marker images](#custom-marker-images)
- [Marker collections](#marker-collections)
- [GeoJSON overlays](#geojson-overlays)
- [Google Maps setup](#google-maps-setup)
- [Marker entering animations](#marker-entering-animations)
Expand All @@ -51,6 +52,7 @@ Built with [Nitro Modules](https://nitro.margelo.com/) for high-performance nati
- **Unified map API** - One typed React API for Apple MapKit and Google Maps SDK.
- **Provider-aware props** - TypeScript narrows provider-specific props with `MapViewPropsForProvider<P>`.
- **Markers and overlays** - Markers with title/subtitle callouts and drag support, plus polylines, polygons, circles, and GeoJSON FeatureCollections.
- **Delta marker updates** - The marker dataset lives natively. `markers` and `<Marker>` compile to deltas, and `MarkerCollection` updates it directly: one packed batch per change, `updatePositions` for animated markers, nothing re-serialized for markers that did not change.
- **Native POI taps** - `onPoiPress` reports provider-owned places from Apple Maps and Google Maps without confusing them with app-owned markers.
- **Camera control** - Declarative region/camera props plus imperative camera helpers.
- **Marker clustering** - Native marker clustering for large point sets.
Expand Down Expand Up @@ -371,6 +373,65 @@ Platform notes:
| `opacity` | `opacity` |
| Custom RN child views | Not supported (use bitmap `image`) |

## Marker collections

The marker dataset is owned by native code and updated through deltas. `markers` and `<Marker>` children are compiled to those deltas for you: `MapView` remembers the last descriptor it sent for every id and a new array only ships the markers that changed, as one packed batch across JSI. For live or animated markers, or datasets that change often, own the collection and update it directly:

```tsx
import { useEffect } from 'react';
import { MapView, useMarkerCollection } from 'react-native-better-maps';

function Fleet({ vehicles }: { vehicles: Vehicle[] }) {
const markers = useMarkerCollection();

useEffect(() => {
markers.set(
vehicles.map((vehicle) => ({
id: vehicle.id,
coordinate: vehicle.position,
title: vehicle.name,
})),
);
}, [markers, vehicles]);

useEffect(() => {
// A 10 Hz position feed: one 24-byte record per moved vehicle, no strings.
const subscription = positionFeed.subscribe((updates) => {
markers.updatePositions(updates); // [{ id, coordinate }]
});
return () => subscription.unsubscribe();
}, [markers]);

return <MapView style={{ flex: 1 }} markerCollection={markers} clusteringEnabled />;
}
```

| Method | What crosses JSI |
| --------------------------- | --------------------------------------------------------------------------------------------------- |
| `set(markers)` | Upserts for new or changed markers and removals for missing ones. An unchanged marker costs one comparison. |
| `upsert(markers)` | Adds new markers and updates existing ones by id. |
| `remove(ids)` | Removals by id. |
| `updatePositions(updates)` | Coordinates only, for markers already in the collection. |
| `clear()` | One call. |
| `size`, `has(id)`, `ids()` | Nothing; answered from the JS-side copy. |

A collection outlives renders and can be shared by several maps. Batches are decoded on a native background thread and the map picks them up on its next refresh, so an update never blocks the UI thread on the size of the dataset. `markers` and `<Marker>` children are ignored while `markerCollection` is set.

### Cluster presses

`onClusterPress` receives `{ clusterId, count, coordinate }`. Member ids are fetched on demand, so a press on a 50,000-marker cluster does not ship 50,000 strings:

```tsx
<MapView
ref={mapRef}
clusteringEnabled
onClusterPress={async (event) => {
const ids = await mapRef.current?.getClusterMembers(event.clusterId);
console.log(`${event.count} markers`, ids);
}}
/>
```

## GeoJSON overlays

`<Geojson>` converts a GeoJSON object (or JSON string) into the existing marker, polyline, and polygon overlay pipeline. There is no native GeoJSON parser — conversion happens in JavaScript so overlay diffing stays shared.
Expand Down Expand Up @@ -517,6 +578,7 @@ On Google Maps providers, marker and cluster entering animations can reduce UI-t
Nitro compares view props by reference identity, so a prop rebuilt from unchanged data would still be re-serialized across JSI and re-applied to the native map. `MapView` guards against that on your behalf:

- Overlay arrays - whether they come from `<Marker />` children or the bulk `markers` / `polylines` / `polygons` / `circles` props - are compared field by field. Passing a freshly built array with identical content costs one comparison and nothing else.
- Markers go one step further: a changed array is compiled to a delta, so only the markers that differ from the previous array reach native. See [Marker collections](#marker-collections).
- `markerEnteringAnimation` and `clusterEnteringAnimation` are compared the same way, so an inline `{ preset: 'fade' }` object is fine.
- Event handlers are wrapped once per handler identity rather than once per render, and the internal `hybridRef` wrapper is created once per mount.

Expand Down Expand Up @@ -552,6 +614,7 @@ setMarkers((current) =>
| Compass | Supported | Supported | Supported |
| Scale control | Supported | Unsupported | Unsupported |
| Markers / overlays | Supported | Supported | Supported |
| Marker collections (deltas) | Supported | Supported | Supported |
| Custom marker images | Supported | Supported | Supported |
| Marker callouts / dragging | Supported | Supported | Supported |
| Overlay press events | Supported | Supported | Supported |
Expand All @@ -576,31 +639,40 @@ setMarkers((current) =>
| `Circle` | Circular area overlay |
| `Geojson` | GeoJSON FeatureCollection overlay |

### Classes and hooks

| Export | Description |
| --------------------- | ------------------------------------------------------------------------ |
| `MarkerCollection` | Native-owned marker dataset updated through `set` / `upsert` / `remove` / `updatePositions` |
| `useMarkerCollection` | Creates one `MarkerCollection` for the lifetime of a component |

### Types

| Type | Description |
| --------------------------- | ---------------------------------------------------- |
| `Coordinate` | `{ latitude, longitude }` |
| `Region` | Center + span |
| `Camera` | Position, zoom, heading, pitch |
| `MapType` | `'standard' \| 'satellite' \| 'hybrid' \| 'terrain'` |
| `MapProvider` | `'apple' \| 'google' \| 'openstreetmap' \| 'mapbox'` |
| `PoiPressEvent` | Provider-discriminated native POI press payload |
| `ApplePoiPressEvent` | Apple Maps POI payload with category |
| `GooglePoiPressEvent` | Google Maps POI payload with place ID |
| `ApplePoiCategory` | Known MapKit POI categories plus `unknown` |
| `MapViewRef` | Imperative handle for camera control |
| `MapViewProps` | Props for `MapView` |
| `MapViewPropsForProvider` | Provider-specific `MapView` props |
| `MarkerDescriptor` | Bulk marker descriptor |
| `MarkerProps` | Props for `Marker` |
| `MarkerImage` | Resolved marker image descriptor |
| `MarkerAnchor` | Anchor point on marker image (0..1) |
| `MarkerPoint` | Point offset in dp |
| `OverlayEnteringAnimation` | Marker / marker-cluster entering animation config |
| `PolylineProps` | Props for `Polyline` |
| `PolygonProps` | Props for `Polygon` |
| `CircleProps` | Props for `Circle` |
| Type | Description |
| -------------------------- | ---------------------------------------------------- |
| `ClusterPressEvent` | `{ clusterId, count, coordinate }` passed to `onClusterPress` |
| `MarkerPositionUpdate` | `{ id, coordinate }` accepted by `updatePositions` |
| `Coordinate` | `{ latitude, longitude }` |
| `Region` | Center + span |
| `Camera` | Position, zoom, heading, pitch |
| `MapType` | `'standard' \| 'satellite' \| 'hybrid' \| 'terrain'` |
| `MapProvider` | `'apple' \| 'google' \| 'openstreetmap' \| 'mapbox'` |
| `PoiPressEvent` | Provider-discriminated native POI press payload |
| `ApplePoiPressEvent` | Apple Maps POI payload with category |
| `GooglePoiPressEvent` | Google Maps POI payload with place ID |
| `ApplePoiCategory` | Known MapKit POI categories plus `unknown` |
| `MapViewRef` | Imperative handle for camera control and `getClusterMembers` |
| `MapViewProps` | Props for `MapView` |
| `MapViewPropsForProvider` | Provider-specific `MapView` props |
| `MarkerDescriptor` | Bulk marker descriptor |
| `MarkerProps` | Props for `Marker` |
| `MarkerImage` | Resolved marker image descriptor |
| `MarkerAnchor` | Anchor point on marker image (0..1) |
| `MarkerPoint` | Point offset in dp |
| `OverlayEnteringAnimation` | Marker / marker-cluster entering animation config |
| `PolylineProps` | Props for `Polyline` |
| `PolygonProps` | Props for `Polygon` |
| `CircleProps` | Props for `Circle` |
| `GeojsonProps` | Props for `Geojson` |
| `GeojsonFeature` | Feature passed to `Geojson` `onPress` |
| `GeojsonOverlayDescriptors` | Result of `geojsonToOverlayDescriptors` |
Expand Down Expand Up @@ -654,6 +726,7 @@ See [example/.env.example](example/.env.example) for the supported environment v
| Provider throws before rendering | Check the [supported platforms](#supported-platforms) table. `openstreetmap` and `mapbox` are reserved for future support but do not render yet. |
| Expo Go does not load native maps | Use a development build after `expo prebuild`; native Nitro modules are not available in Expo Go. |
| Marker animations affect gesture smoothness | For very large marker sets, prefer clustering, shorter durations, or disable marker/cluster entering animations. |
| `markers` or `<Marker>` do not render | They are ignored while `markerCollection` is set; put those markers into the collection instead. |

## Development

Expand Down
83 changes: 83 additions & 0 deletions docs/adr/0005-marker-collection-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# ADR 0005: Native marker store fed by delta batches

## Status

Accepted

## Context

Until now the whole marker dataset travelled as one Fabric prop, `markers: MarkerDescriptor[]`.
Any change to the array re-serialized every marker: about 27 JSI property reads per marker on
the JS thread, a `std::vector<MarkerDescriptor>` rebuild in the shadow tree, two more copies
into Swift on iOS, and a per-marker JNI object graph built on the UI thread on Android. Moving
one marker in a 10,000-marker dataset cost the same as sending all 10,000. The performance
audit (2026-09) rated this the single most valuable thing to fix: every other large-dataset
finding (main-thread stalls, four resident copies, impossible animated markers, O(k) cluster
press payloads) was downstream of the transport.

## Decision

Markers no longer cross the bridge as a prop.

- **Native store.** A `MarkerCollection` Nitro HybridObject owns a `MarkerStore`: flat
latitude/longitude/flag arrays, a version per marker, one descriptor per marker, and a grid
spatial index over integer handles. There is one native copy of the dataset.
- **Integer handles, assigned by JS.** The JS side keeps the last descriptor it sent for every
id, assigns dense handles and reuses freed ones. Native code never needs an id → handle map;
ids are only read back for events and `getClusterMembers`.
- **Packed batches.** Every update is one `applyBatch(ArrayBuffer, string[])` call. The buffer
holds fixed-size little-endian records (96 bytes per upsert, 4 per removal, 24 per position
update) and the string table carries each distinct string once. Removals are applied before
upserts so a freed handle can be reused in the same batch. The layout is documented in
`src/markers/markerBatch.ts` and mirrored by `MarkerBatchDecoder.swift` / `.kt`.
- **Decode off the JS thread.** `applyBatch` validates the header, copies the bytes and returns.
A store thread decodes them under the store lock, updates the arrays and the index in place,
rebuilds the index bounds only when a marker landed outside them, then notifies attached map
views on the main thread.
- **Handle-indexed pipeline.** The per-map pipeline queries the index for candidate handles,
clusters or thins them using the flat arrays, materializes descriptors only for the elements
that will be displayed, and diffs by `(handle, id)` against what is on screen. Cluster badges
keep member handles; their version is a hash of count, centroid and bounds, not a sort of
every member id.
- **The old API is sugar.** `markers` and `<Marker>` children compile to the same batches
through a collection `MapView` owns. `MarkerCollection` / `useMarkerCollection` and the
`markerCollection` prop expose the store directly, with `updatePositions` for animated and
live markers.
- **Lighter cluster presses.** `onClusterPress` receives `{ clusterId, count, coordinate }` and
`MapViewRef.getClusterMembers(clusterId)` resolves ids on demand.

## Consequences

- One-marker updates are O(Δ) on every thread and in every layer. The JS thread pays a
structural comparison per marker on the sugar path (`set` with a new array) and nothing per
unchanged marker on the collection path.
- `onClusterPress` changes signature. This is the one breaking change; the migration is a
`getClusterMembers` call.
- `MarkerDescriptor`, `MarkerImage`, `MarkerAnchor` and `MarkerPoint` are no longer generated
by nitrogen, because no spec references them. They are hand-written natively with the same
names and fields, so the rendering code did not change. A spec that references them again
would generate conflicting types.
- Handles are assigned by JS, so two collections cannot be merged natively; a map renders one
collection at a time.
- Removing a handle from an index cell is a linear scan of that cell. Cells are small for
ordinary datasets; a dataset whose bounds span the world but whose markers sit in one city
degrades to O(cell size) per removal. A quadtree can replace the grid without changing the
batch format.
- The C++ layer is still generated only. Moving the store, index and clustering into shared
C++ remains an option for a later phase if profiling shows Kotlin or Swift compute as the
limit; the batch format and the JS API would not change.

## Alternatives considered

- **HybridObject as a view prop vs. an attach method.** The prop was chosen: it survives
provider remounts through the map's stored state, is declarative, and Nitro supports
HybridObject-typed view props.
- **Strings inside the buffer.** UTF-8 in the `ArrayBuffer` would avoid a JSI string
conversion per string, but every consumer would need its own decoder and the win is
small, because strings are only sent for markers that changed.
- **Decoding on the JS thread.** Simpler ownership, but a 100,000-marker initial load would
stall the JS thread for the decode. Copying the bytes costs microseconds and moves the
decode to a thread nobody waits on.
- **Partial upsert records.** A field mask would shrink update batches, but fixed-size records
keep the decoders branch-free and the common update (`updatePositions`) already has its
own 24-byte record.
Loading