diff --git a/CHANGELOG.md b/CHANGELOG.md index 09bd535..4d133d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Breaking changes + +**`onClusterPress` receives an event instead of the member ids** + +The callback used to be called with `(markerIds, coordinate)`, which shipped every +member id across JSI on each press. It now receives `{ clusterId, count, coordinate }`; +fetch the ids on demand when you need them: + +```tsx +// Before + showList(ids)} /> + +// After + { + const ids = await mapRef.current?.getClusterMembers(event.clusterId); + showList(ids ?? []); + }} +/> +``` + +### Added + +- `MarkerCollection` and `useMarkerCollection`: a native-owned marker dataset updated + through `set`, `upsert`, `remove` and `updatePositions`, passed to `MapView` with the + new `markerCollection` prop. Each call ships one packed batch that only carries what + changed. +- `MapViewRef.getClusterMembers(clusterId)`. +- `ClusterPressEvent` and `MarkerPositionUpdate` types. + +### Changed + +- The `markers` prop and `` children now compile to the same delta batches: a new + array only sends the markers that changed since the previous one, instead of + re-serializing the whole dataset on every change. Native code keeps one copy of the + dataset, addressed by integer handles, with a spatial index that is updated in place. +- Cluster badges keep member handles instead of id strings, so a cluster of 100,000 + markers no longer carries 100,000 strings through the render pipeline. + ## 1.1.0 ### Behavior changes diff --git a/README.md b/README.md index 37dfef3..6ac7e29 100644 --- a/README.md +++ b/README.md @@ -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) @@ -51,6 +52,8 @@ 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

`. - **Markers and overlays** - Markers with title/subtitle callouts and drag support, plus polylines, polygons, circles, and GeoJSON FeatureCollections. +- **Markers and overlays** - Markers with title/subtitle callouts and drag support, plus polylines, polygons, and circles. +- **Delta marker updates** - The marker dataset lives natively. `markers` and `` 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. @@ -371,6 +374,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 `` 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 ; +} +``` + +| 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 `` 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 + { + const ids = await mapRef.current?.getClusterMembers(event.clusterId); + console.log(`${event.count} markers`, ids); + }} +/> +``` + ## GeoJSON overlays `` 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. @@ -517,6 +579,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 `` 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. @@ -552,6 +615,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 | @@ -576,31 +640,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` | @@ -651,6 +724,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 `` do not render | They are ignored while `markerCollection` is set; put those markers into the collection instead. | ## Development diff --git a/docs/adr/0005-marker-collection-store.md b/docs/adr/0005-marker-collection-store.md new file mode 100644 index 0000000..ce9c8c4 --- /dev/null +++ b/docs/adr/0005-marker-collection-store.md @@ -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` 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 `` 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. diff --git a/docs/architecture.md b/docs/architecture.md index 37f38d8..a8d1e72 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,6 +15,7 @@ ├─────────────────────────────────────────────────┤ │ Nitro Layer │ │ MapView.nitro.ts (HybridView spec) │ +│ MarkerCollection.nitro.ts (HybridObject spec) │ │ nitro.json (autolinking) │ │ nitrogen/generated/ (codegen output) │ ├─────────────────────────────────────────────────┤ @@ -73,7 +74,7 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C | `onMapReady` | none | Fires once after the map finishes loading tiles. | | `Marker.onPress` / `onDragEnd` | none / `Coordinate` | Dispatched by overlay `id` from native to JS registry. | | Overlay `onPress` | none | Polyline/polygon/circle with `onPress` default to `tappable` on native. | -| `onClusterPress` | `string[]`, `Coordinate` | Fires when a marker cluster is tapped; IDs are member marker overlay ids. | +| `onClusterPress` | `ClusterPressEvent` | Fires when a marker cluster is tapped with `{ clusterId, count, coordinate }`. Member ids are fetched on demand through `MapViewRef.getClusterMembers(clusterId)`. | ### Advanced MapView props @@ -91,6 +92,8 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C | `showsCompass` / `showsScale` | Compass on both platforms. Scale is iOS-only (`showsScale` is a no-op on Android). | | `mapPadding` | Edge insets in density-independent pixels. Applied via `layoutMargins` (iOS) or `setPadding` (Android). | | `fitToCoordinates(coords, padding?, animated?)` | Imperative ref method; fits camera to a set of coordinates with optional padding. | +| `markerCollection` | A `MarkerCollection` owned by the app. Replaces `markers` and `` children; updated through `set`, `upsert`, `remove` and `updatePositions`. | +| `getClusterMembers(clusterId)` | Imperative ref method; resolves the marker ids inside a displayed cluster. | ### Platform gaps (Phase 8) @@ -104,6 +107,8 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C `Marker`, `Polyline`, `Polygon`, `Circle`, and `Geojson` are overlay components that compose inside `MapView`. Overlay props are collected on the JS side and serialized into descriptor structs passed to the native `HybridMapView` (data-driven architecture). `Geojson` is converted into marker, polyline, and polygon descriptors before that native pass; invalid GeoJSON is skipped with a development warning. +Markers take a different route, because their datasets are large and change often. The dataset lives in a native `MarkerStore` behind the `MarkerCollection` HybridObject. JS assigns every marker an integer handle, keeps the last descriptor it sent per id, and compiles `set` / `upsert` / `remove` / `updatePositions` into packed batches (`src/markers/markerBatch.ts`: a fixed-size record per upsert, four bytes per removal, 24 bytes per position update, plus a string table). The `markers` prop and `` children compile to the same batches through a collection `MapView` owns. Natively the store decodes batches on a background thread into flat coordinate arrays, flags, versions and one descriptor per handle, keeps a grid index over handles that is updated in place, and notifies every attached map view. The map's pipeline queries the index for the viewport, clusters or thins the candidate handles, materializes descriptors only for what will be displayed, and diffs by `(handle, id)` against what is on screen. See [ADR 0005](adr/0005-marker-collection-store.md). + Marker and marker-cluster entering animations follow the same descriptor model. The public API accepts `false`, `system`, or a serializable preset config; the React wrapper normalizes that into native descriptors. Native provider adapters execute the animation when a marker render element appears in the render diff. Updating animation config for an already retained marker does not restart the animation; the new config is used the next time that marker is added again. Google Maps SDKs are sensitive to marker animation churn. Large viewport refreshes can add many native marker instances on the main thread, so the Google provider limits how many markers animate per refresh and reveals the rest immediately. This keeps gestures responsive, but very large marker sets may still need clustering, disabled entering animations, or a future provider-specific animation strategy. @@ -113,13 +118,16 @@ Google Maps SDKs are sensitive to marker animation churn. Large viewport refresh ``` User interaction ↓ -React component tree () +React component tree (, or markerCollection) ↓ MapView collects overlay descriptors + props + ↓ ↓ +Nitro HybridView props MarkerCollection.applyBatch(ArrayBuffer, strings) +(shapes, camera, callbacks) one packed delta batch per change + ↓ ↓ +Native HybridMapView (Swift / Kotlin) ← MarkerStore (handles, flat arrays, grid index) ↓ -Nitro HybridView (JSI, zero-copy structs) - ↓ -Native HybridMapView (Swift / Kotlin) +Viewport pipeline: index query → cluster / LOD → diff by handle → SDK objects ↓ Platform map SDK renders ↓ diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 96071e1..a613196 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -22,32 +22,34 @@ that passed with nothing drawn. Thresholds scale with the display's refresh rate (`budget = 1000 / Hz`): -| Metric | Limit | -| ------------------------------------------ | ---------------------------------------------------------------- | -| p50, p95 | ≤ budget + 5 % (display-link jitter around the nominal interval) | -| p99 | ≤ 1.5 × budget | -| worst frame | ≤ 3 × budget (25 ms at 120 Hz, 50 ms at 60 Hz) | -| jank frames | ≤ 1 % | -| JS lag p95 (animated-marker scenario only) | ≤ budget | +| Metric | Limit | +| --------------------------------------------------------- | ---------------------------------------------------------------- | +| p50, p95 | ≤ budget + 5 % (display-link jitter around the nominal interval) | +| p99 | ≤ 1.5 × budget | +| worst frame | ≤ 3 × budget (25 ms at 120 Hz, 50 ms at 60 Hz) | +| jank frames | ≤ 1 % | +| JS lag p95 (scenarios that say "JS lag is checked" below) | ≤ budget | They are implemented in `benchmark/thresholds.ts` and unit-tested with `cd example && bun test`. ## Scenarios -| ID | Setup | Script | -| --- | ---------------------------------- | ------------------------------------------------------------------------- | -| A | empty map | 3 s idle, short pan | -| B | 100 markers | pan | -| C | 1,000 markers | pan | -| D | 10,000 markers | pan | -| E | 10,000 markers, clustering on | zoom sweep across five levels, then pan | -| F | 10,000 markers | ten-leg pan | -| G | 10,000 markers | zoom sweep | -| H | 10,000 markers | four heading changes | -| I | 1,000 markers | 100 of them move at 10 Hz for 5 s through prop updates; JS lag is checked | -| K | 5,000-point route and 200 polygons | five style changes, then pan | -| L | 10,000 markers | three pan legs, then 5 s idle | +| ID | Setup | Script | +| --- | ---------------------------------- | --------------------------------------------------------------------------------- | +| A | empty map | 3 s idle, short pan | +| B | 100 markers | pan | +| C | 1,000 markers | pan | +| D | 10,000 markers | pan | +| E | 10,000 markers, clustering on | zoom sweep across five levels, then pan | +| F | 10,000 markers | ten-leg pan | +| G | 10,000 markers | zoom sweep | +| H | 10,000 markers | four heading changes | +| I | 1,000 markers in a collection | 100 of them move at 10 Hz for 5 s through `updatePositions`; JS lag is checked | +| I2 | 1,000 markers | 100 of them move at 10 Hz for 5 s through new `markers` arrays; JS lag is checked | +| K | 5,000-point route and 200 polygons | five style changes, then pan | +| L | 10,000 markers | three pan legs, then 5 s idle | +| M | 10,000 markers in a collection | one marker is upserted every 100 ms for 3 s; JS lag is checked | Scenario J (live location) is not scripted: it needs location permission and a GPS feed. Use the simulator's location menu with the manual recorder. @@ -99,6 +101,14 @@ maestro test example/maestro/benchmark-pan.yaml # real-gesture pan on scen The flow selects scenario D, starts the manual recorder, performs four swipes and stops. Maestro has no pinch gesture, so zoom runs stay manual. +Use the flows on Android only. On iOS, Maestro waits for the summary by +polling the accessibility tree, and XCTest builds each snapshot on the app's +main thread, in time proportional to the number of annotation views on the +map. That polling shows up as dropped frames in every scenario with many +markers on screen and doubled the reported jank on a static map with one +marker changing per tick. Start "Run all" by hand on iOS (or through +`xcrun simctl`) and harvest the system log. + ### 120 Hz on iPhone `CADisplayLink` is capped at 60 Hz on iPhone unless the app opts in, so @@ -174,10 +184,80 @@ the 10k scenarios are the diff applies after each camera move. - K-shapes: p99 33.33 ms > 25.00 ms; worst frame 50.00 ms > 50.00 ms; jank 1.33% > 1% - L-idle-after-pan: p95 50.00 ms > budget 17.50 ms; p99 166.67 ms > 25.00 ms; worst frame 300.00 ms > 50.00 ms; jank 9.01% > 1% +### Marker store runs (not a device baseline) + +The same emulator and simulator after markers moved to the native store (ADR +0005). Numbers from a Mac that is also running the build tooling; treat them as +a before/after on identical hardware, not as device numbers. + +Android emulator, API 35, arm64, Google Maps provider, 60 Hz, **release build** +(the earlier Android table was a debug build served by Metro, so its JS-lag +column is not comparable; frame intervals are). Recorded 2026-09-08. The +emulator's JS-lag floor is about 20 ms even on the empty map, which is what the +two `(1)` failures on the collection scenarios are. + +| Scenario | Result | FPS | p50 | p95 | p99 | Worst | Jank | JS lag p95 | RSS Δ | +| --------------------- | -------- | --- | ------- | ------- | ------- | ------ | ----- | ---------- | ------ | +| A-empty-idle | fail (1) | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 67 ms | 0.6 % | 21.5 ms | -8 MB | +| B-markers-100 | fail (3) | 58 | 16.7 ms | 16.7 ms | 33.3 ms | 50 ms | 3.4 % | 34.1 ms | -5 MB | +| C-markers-1k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.3 % | 21.9 ms | -4 MB | +| D-markers-10k | fail (3) | 58 | 16.7 ms | 16.7 ms | 50.0 ms | 67 ms | 1.7 % | 26.4 ms | -2 MB | +| E-clustered-10k | fail (3) | 58 | 16.7 ms | 16.7 ms | 33.3 ms | 183 ms | 1.4 % | 23.1 ms | -21 MB | +| F-pan-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 22.9 ms | -10 MB | +| G-zoom-10k | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 33 ms | 1.3 % | 29.7 ms | +14 MB | +| H-rotate-10k | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 33 ms | 1.0 % | 24.1 ms | -18 MB | +| I-animated-collection | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.3 % | 18.9 ms | -50 MB | +| I2-animated-prop | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 20.2 ms | -1 MB | +| K-shapes | fail (3) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 50 ms | 1.4 % | 22.4 ms | +8 MB | +| L-idle-after-pan | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.2 % | 19.9 ms | -1 MB | +| M-one-of-10k | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.6 ms | +9 MB | + +- A-empty-idle: worst frame 66.67 ms > 50.00 ms +- B-markers-100: p99 33.33 ms > 25.00 ms; worst frame 50.00 ms > 50.00 ms; jank 3.37% > 1% +- D-markers-10k: p99 50.00 ms > 25.00 ms; worst frame 66.67 ms > 50.00 ms; jank 1.68% > 1% +- E-clustered-10k: p99 33.33 ms > 25.00 ms; worst frame 183.33 ms > 50.00 ms; jank 1.38% > 1% +- G-zoom-10k: p99 33.33 ms > 25.00 ms; jank 1.31% > 1% +- H-rotate-10k: p99 33.33 ms > 25.00 ms; jank 1.01% > 1% +- I-animated-collection: JS lag p95 18.86 ms > budget 17.50 ms +- I2-animated-prop: JS lag p95 20.19 ms > budget 17.50 ms +- K-shapes: p99 33.33 ms > 25.00 ms; worst frame 50.00 ms > 50.00 ms; jank 1.43% > 1% +- M-one-of-10k: JS lag p95 18.58 ms > budget 17.50 ms + +iPhone 17 Pro simulator, iOS 26.5, release build, MapKit provider, 60 Hz, +"Run all" started by hand (see the Maestro note above). Recorded 2026-09-08. +Against the phase-1 table on the same simulator: the clustered zoom sweep (E) +now passes with a worst frame of 33 ms instead of 34 ms and a p99 of one +frame instead of two, rotation (H) lost its 80 ms worst frame, and the two new +scenarios show what the store is for: moving 100 markers at 10 Hz (I) and +changing one marker of 10,000 (M) both keep every frame at 16.7 ms with a JS +lag around 1 ms and no measurable memory growth. The zoom sweep without +clustering (G) still drops frames at octave crossings, where MapKit creates +hundreds of `MKMarkerAnnotationView`s at once; that is the phase-3 work +(time-sliced apply, lighter annotation views), not the transport. + +| Scenario | Result | FPS | p50 | p95 | p99 | Worst | Jank | JS lag p95 | RSS Δ | +| --------------------- | -------- | --- | ------- | ------- | ------- | ----- | ----- | ---------- | ------ | +| A-empty-idle | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 45 ms | 0.6 % | 1.1 ms | +65 MB | +| B-markers-100 | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 45 ms | 0.3 % | 1.2 ms | +78 MB | +| C-markers-1k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 46 ms | 0.3 % | 1.1 ms | +66 MB | +| D-markers-10k | pass | 59 | 16.7 ms | 16.7 ms | 20.6 ms | 46 ms | 1.0 % | 1.1 ms | +66 MB | +| E-clustered-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.8 % | 1.1 ms | +90 MB | +| F-pan-10k | pass | 59 | 16.7 ms | 16.7 ms | 23.6 ms | 43 ms | 1.0 % | 1.1 ms | +89 MB | +| G-zoom-10k | fail (2) | 58 | 16.7 ms | 16.7 ms | 33.3 ms | 36 ms | 3.3 % | 1.1 ms | +99 MB | +| H-rotate-10k | fail (2) | 59 | 16.7 ms | 16.7 ms | 35.6 ms | 36 ms | 1.0 % | 1.1 ms | +50 MB | +| I-animated-collection | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 1.4 ms | -5 MB | +| I2-animated-prop | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 1.1 ms | -0 MB | +| K-shapes | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 49 ms | 0.3 % | 1.1 ms | +67 MB | +| L-idle-after-pan | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 46 ms | 0.4 % | 1.2 ms | +48 MB | +| M-one-of-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 1.1 ms | +1 MB | + +- G-zoom-10k: p99 33.33 ms > 25.00 ms; jank 3.33% > 1% +- H-rotate-10k: p99 35.56 ms > 25.00 ms; jank 1.01% > 1% + ## Profiling markers The library emits `os_signpost` intervals (iOS, subsystem `com.nitromaps`, category `MarkerPipeline`) and `android.os.Trace` sections (Android, prefix -`NitroMaps.`) around the marker fingerprint, the spatial index build, the -viewport compute and the diff apply. They show up in Instruments' Points of -Interest track and in Perfetto, and cost nothing when no tracer is attached. +`NitroMaps.`) around the marker batch apply, the viewport compute and the diff +apply. They show up in Instruments' Points of Interest track and in Perfetto, +and cost nothing when no tracer is attached. diff --git a/example/App.tsx b/example/App.tsx index 09f74a2..89f146d 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -39,6 +39,7 @@ import Animated, { } from 'react-native-reanimated'; import { MapView, + type ClusterPressEvent, type Coordinate, type EdgePadding, type MapProvider, @@ -499,7 +500,7 @@ type MapSceneProps = { mapPadding?: EdgePadding; animationOption: AnimationOption; onMapReady: () => void; - onClusterPress: (markerIds: string[], coordinate: Coordinate) => void; + onClusterPress: (event: ClusterPressEvent) => void; onMarkerPress: (id: string) => void; onMarkerDragEnd: (id: string, coordinate: Coordinate) => void; onOverlayPress: (label: string) => void; @@ -646,6 +647,7 @@ const StatusHeader = memo(function StatusHeader({ export default function App() { const insets = useSafeAreaInsets(); const mapRef = useRef(null); + const latestClusterRequest = useRef(0); const [scenarioIndex, setScenarioIndex] = useState(0); const [mapTypeIndex, setMapTypeIndex] = useState(0); const [providerIndex, setProviderIndex] = useState(0); @@ -784,14 +786,25 @@ export default function App() { setStatus(label); }, []); - const handleClusterPress = useCallback( - (markerIds: string[], coordinate: Coordinate) => { - setStatus( - `Cluster (${markerIds.length}) · ${coordinate.latitude.toFixed(4)}, ${coordinate.longitude.toFixed(4)}`, - ); - }, - [], - ); + const handleClusterPress = useCallback((event: ClusterPressEvent) => { + setStatus( + `Cluster (${event.count}) · ${event.coordinate.latitude.toFixed(4)}, ${event.coordinate.longitude.toFixed(4)}`, + ); + // Member ids are fetched on demand instead of travelling with every press; + // a lookup that resolves after a newer press is dropped. + const request = ++latestClusterRequest.current; + mapRef.current + ?.getClusterMembers(event.clusterId) + .then((ids) => { + if (request === latestClusterRequest.current && ids.length > 0) { + const preview = ids.slice(0, 3).join(', '); + setStatus( + `Cluster (${ids.length}) · ${preview}${ids.length > 3 ? ', …' : ''}`, + ); + } + }) + .catch(() => {}); + }, []); const handleMapReady = useCallback(() => { setMapReady(true); diff --git a/example/benchmark/BenchmarkApp.tsx b/example/benchmark/BenchmarkApp.tsx index 0c8295c..77ac4fa 100644 --- a/example/benchmark/BenchmarkApp.tsx +++ b/example/benchmark/BenchmarkApp.tsx @@ -67,8 +67,8 @@ export default function BenchmarkApp() { const [provider, setProvider] = useState(PROVIDERS[0]); const [manualActive, setManualActive] = useState(false); const [scenarioIndex, setScenarioIndex] = useState(0); - const [mapProps, setMapProps] = useState( - SCENARIOS[0].props, + const [mapProps, setMapProps] = useState(() => + SCENARIOS[0].props(), ); const [mapKey, setMapKey] = useState(0); const [results, setResults] = useState([]); @@ -101,7 +101,7 @@ export default function BenchmarkApp() { clearTimeout(timeout); resolve(); }; - setMapProps(next.props); + setMapProps(next.props()); setMapKey((key) => key + 1); }); }, []); @@ -213,7 +213,7 @@ export default function BenchmarkApp() { return; } setScenarioIndex(index); - setMapProps(SCENARIOS[index].props); + setMapProps(SCENARIOS[index].props()); setMapKey((key) => key + 1); }, [running], @@ -237,7 +237,8 @@ export default function BenchmarkApp() { const commonMapProps = { style: styles.map, region: mapProps.region, - markers: mapProps.markers, + markers: mapProps.markerCollection == null ? mapProps.markers : undefined, + markerCollection: mapProps.markerCollection, polylines: mapProps.polylines, polygons: mapProps.polygons, clusteringEnabled: mapProps.clusteringEnabled, diff --git a/example/benchmark/datasets.ts b/example/benchmark/datasets.ts index f47db2a..04d628a 100644 --- a/example/benchmark/datasets.ts +++ b/example/benchmark/datasets.ts @@ -1,6 +1,7 @@ import type { MapViewProps, MarkerDescriptor, + MarkerPositionUpdate, Region, } from 'react-native-better-maps'; import { generatePolandMarkers } from '../examples/advancedFeatures'; @@ -58,6 +59,38 @@ export function stepMarkers( ); } +/** + * The same motion as `stepMarkers`, as coordinate-only updates for a + * collection: `stepMarkers` adds one step to the previous array on every tick, + * so the position after `tick` ticks is the base plus the steps 1 through + * `tick` summed. + */ +export function stepPositions( + base: MarkerDescriptor[], + movingCount: number, + tick: number, +): MarkerPositionUpdate[] { + let dLat = 0; + let dLon = 0; + for (let step = 1; step <= tick; step += 1) { + const angle = step * 0.35; + dLat += Math.sin(angle) * 0.0006; + dLon += Math.cos(angle) * 0.0009; + } + const updates: MarkerPositionUpdate[] = []; + for (let index = 0; index < movingCount && index < base.length; index += 1) { + const marker = base[index]; + updates.push({ + id: marker.id, + coordinate: { + latitude: marker.coordinate.latitude + dLat, + longitude: marker.coordinate.longitude + dLon, + }, + }); + } + return updates; +} + /** A sinuous 5,000-point route from Gdańsk down to Kraków. */ export function longRoute( points = 5_000, diff --git a/example/benchmark/scenarios.ts b/example/benchmark/scenarios.ts index a0d9446..e53c668 100644 --- a/example/benchmark/scenarios.ts +++ b/example/benchmark/scenarios.ts @@ -1,8 +1,9 @@ -import type { - Camera, - MapViewRef, - MarkerDescriptor, - Region, +import { + MarkerCollection, + type Camera, + type MapViewRef, + type MarkerDescriptor, + type Region, } from 'react-native-better-maps'; import { POLAND_REGION, @@ -11,6 +12,7 @@ import { markers, polygonGrid, stepMarkers, + stepPositions, type PolygonDescriptor, type PolylineDescriptor, } from './datasets'; @@ -19,6 +21,7 @@ import { export interface BenchmarkMapProps { region: Region; markers?: MarkerDescriptor[]; + markerCollection?: MarkerCollection; polylines?: PolylineDescriptor[]; polygons?: PolygonDescriptor[]; clusteringEnabled?: boolean; @@ -36,7 +39,8 @@ export interface BenchmarkScenario { id: string; name: string; description: string; - props: BenchmarkMapProps; + /** Built at mount time so native collections are created lazily. */ + props(): BenchmarkMapProps; /** Extra settle time after `onMapReady` before recording starts. */ settleMs?: number; /** Also fail the scenario when the JS thread cannot keep up with the frame budget. */ @@ -44,6 +48,23 @@ export interface BenchmarkScenario { run(context: ScenarioContext): Promise; } +const collectionCache = new Map(); + +/** + * One `MarkerCollection` per dataset size, populated on first use. Scenarios + * that mutate it (I, M) do so cumulatively across runs, which is harmless for + * a benchmark and keeps the mount cost out of the recording. + */ +function collectionOf(count: number): MarkerCollection { + let collection = collectionCache.get(count); + if (collection == null) { + collection = new MarkerCollection(); + collection.set(markers(count)); + collectionCache.set(count, collection); + } + return collection; +} + function cameraAt(region: Region, overrides: Partial = {}): Camera { return { center: { latitude: region.latitude, longitude: region.longitude }, @@ -125,7 +146,7 @@ export const SCENARIOS: BenchmarkScenario[] = [ id: 'A-empty-idle', name: 'A · Empty map', description: 'No overlays. 3 s idle, then a short pan.', - props: { region: WARSAW_REGION }, + props: () => ({ region: WARSAW_REGION }), async run(context) { await context.sleep(3000); await pan(context, WARSAW_REGION, 3); @@ -135,21 +156,21 @@ export const SCENARIOS: BenchmarkScenario[] = [ id: 'B-markers-100', name: 'B · 100 markers', description: 'Pan with 100 markers.', - props: { region: WARSAW_REGION, markers: markers(100) }, + props: () => ({ region: WARSAW_REGION, markers: markers(100) }), run: (context) => pan(context, WARSAW_REGION), }, { id: 'C-markers-1k', name: 'C · 1,000 markers', description: 'Pan with 1,000 markers (viewport pipeline, no clustering).', - props: { region: WARSAW_REGION, markers: markers(1_000) }, + props: () => ({ region: WARSAW_REGION, markers: markers(1_000) }), run: (context) => pan(context, WARSAW_REGION), }, { id: 'D-markers-10k', name: 'D · 10,000 markers', description: 'Pan with 10,000 markers (viewport LOD, no clustering).', - props: { region: WARSAW_REGION, markers: markers(10_000) }, + props: () => ({ region: WARSAW_REGION, markers: markers(10_000) }), settleMs: 2500, run: (context) => pan(context, WARSAW_REGION), }, @@ -157,11 +178,11 @@ export const SCENARIOS: BenchmarkScenario[] = [ id: 'E-clustered-10k', name: 'E · 10,000 clustered', description: 'Zoom sweep across octaves, then a pan, with clustering on.', - props: { + props: () => ({ region: POLAND_REGION, markers: markers(10_000), clusteringEnabled: true, - }, + }), settleMs: 2500, async run(context) { await zoomSweep(context, POLAND_REGION); @@ -172,7 +193,7 @@ export const SCENARIOS: BenchmarkScenario[] = [ id: 'F-pan-10k', name: 'F · Long pan', description: 'Ten animated pan legs with 10,000 markers.', - props: { region: WARSAW_REGION, markers: markers(10_000) }, + props: () => ({ region: WARSAW_REGION, markers: markers(10_000) }), settleMs: 2500, run: (context) => pan(context, WARSAW_REGION, 10, 0.02, 500), }, @@ -180,7 +201,7 @@ export const SCENARIOS: BenchmarkScenario[] = [ id: 'G-zoom-10k', name: 'G · Zoom sweep', description: 'Zoom across five levels with 10,000 markers.', - props: { region: WARSAW_REGION, markers: markers(10_000) }, + props: () => ({ region: WARSAW_REGION, markers: markers(10_000) }), settleMs: 2500, run: (context) => zoomSweep(context, WARSAW_REGION), }, @@ -188,16 +209,35 @@ export const SCENARIOS: BenchmarkScenario[] = [ id: 'H-rotate-10k', name: 'H · Rotation', description: 'Four heading changes with 10,000 markers.', - props: { region: WARSAW_REGION, markers: markers(10_000) }, + props: () => ({ region: WARSAW_REGION, markers: markers(10_000) }), settleMs: 2500, run: (context) => rotate(context, WARSAW_REGION), }, { - id: 'I-animated-markers', - name: 'I · Animated markers', + id: 'I-animated-collection', + name: 'I · Animated markers (collection)', description: - '100 of 1,000 markers move at 10 Hz for 5 s through prop updates.', - props: { region: WARSAW_REGION, markers: markers(1_000) }, + '100 of 1,000 markers move at 10 Hz for 5 s through MarkerCollection.updatePositions.', + props: () => ({ + region: WARSAW_REGION, + markerCollection: collectionOf(1_000), + }), + checkJsLag: true, + async run(context) { + const collection = collectionOf(1_000); + const base = markers(1_000); + for (let tick = 1; tick <= 50; tick += 1) { + collection.updatePositions(stepPositions(base, 100, tick)); + await context.sleep(100); + } + }, + }, + { + id: 'I2-animated-prop', + name: 'I2 · Animated markers (prop)', + description: + '100 of 1,000 markers move at 10 Hz for 5 s through new `markers` arrays, compiled to deltas.', + props: () => ({ region: WARSAW_REGION, markers: markers(1_000) }), checkJsLag: true, async run(context) { let current = markers(1_000); @@ -213,11 +253,11 @@ export const SCENARIOS: BenchmarkScenario[] = [ name: 'K · Polylines and polygons', description: 'A 5,000-point route and 200 polygons; five style changes, then a pan.', - props: { + props: () => ({ region: WARSAW_REGION, polylines: [longRoute()], polygons: polygonGrid(), - }, + }), async run(context) { const colors = ['#FF9500', '#34C759', '#AF52DE', '#FF2D55', '#FF3B30']; for (const color of colors) { @@ -231,13 +271,43 @@ export const SCENARIOS: BenchmarkScenario[] = [ id: 'L-idle-after-pan', name: 'L · Idle after a pan', description: 'Three pan legs with 10,000 markers, then 5 s of nothing.', - props: { region: WARSAW_REGION, markers: markers(10_000) }, + props: () => ({ region: WARSAW_REGION, markers: markers(10_000) }), settleMs: 2500, async run(context) { await pan(context, WARSAW_REGION, 3); await context.sleep(5000); }, }, + { + id: 'M-one-of-10k', + name: 'M · One marker of 10,000', + description: + 'One marker of a 10,000-marker collection is upserted every 100 ms for 3 s.', + props: () => ({ + region: WARSAW_REGION, + markerCollection: collectionOf(10_000), + }), + settleMs: 2500, + checkJsLag: true, + async run(context) { + const collection = collectionOf(10_000); + const base = markers(10_000); + for (let tick = 1; tick <= 30; tick += 1) { + const marker = base[tick]; + collection.upsert([ + { + ...marker, + title: `Moved ${tick}`, + coordinate: { + latitude: marker.coordinate.latitude + 0.0004 * tick, + longitude: marker.coordinate.longitude, + }, + }, + ]); + await context.sleep(100); + } + }, + }, ]; export const SKIPPED_SCENARIOS = [ diff --git a/example/maestro/benchmark-run-all.yaml b/example/maestro/benchmark-run-all.yaml index 547335f..2bec575 100644 --- a/example/maestro/benchmark-run-all.yaml +++ b/example/maestro/benchmark-run-all.yaml @@ -12,9 +12,9 @@ appId: com.nitromaps.example timeout: 60000 - tapOn: id: 'benchmark-run-all' -# The summary reads "/11 passed" once every scenario has a result; the +# The summary reads "/13 passed" once every scenario has a result; the # last result row can sit below the fold of the results list. - extendedWaitUntil: visible: - text: '.*/11 passed' + text: '.*/13 passed' timeout: 300000 diff --git a/package/android/build.gradle b/package/android/build.gradle index 7a2038f..4d079f2 100644 --- a/package/android/build.gradle +++ b/package/android/build.gradle @@ -82,6 +82,12 @@ android { kotlin.srcDirs = ['src/main/kotlin'] } } + + testOptions { + // The marker store wraps its work in android.os.Trace sections, which + // the JVM stubs would otherwise throw on. + unitTests.returnDefaultValues = true + } } dependencies { diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt index df82727..1f17b1a 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt @@ -36,7 +36,6 @@ class GoogleMapProviderAdapter( private var isUserGesture = false private var hasFiredMapReady = false private val overlayController = MapOverlayController(null, context) - private var pendingMarkers: Array? = null private var pendingPolylines: Array? = null private var pendingPolygons: Array? = null private var pendingCircles: Array? = null @@ -201,22 +200,6 @@ class GoogleMapProviderAdapter( _clusteringEnabled = value updateOverlayViewportSize() overlayController.setClusteringEnabled(value == true) - googleMap?.let { map -> - if (value == true) { - map.setOnMarkerClickListener { marker -> - overlayController.onMarkerClick(marker) - } - } else { - map.setOnMarkerClickListener { marker -> - val id = marker.tag as? String - if (id != null) { - onMarkerPress?.invoke(id) - } - false - } - } - } - overlayController.setMarkers(_markers) } private var _mapPadding: EdgePadding? = null @@ -250,17 +233,13 @@ class GoogleMapProviderAdapter( override var onPoiPress: ((event: NativePoiPressEvent) -> Unit)? = null override var onLongPress: ((coordinate: Coordinate) -> Unit)? = null - private var _markers: Array? = null - override var markers: Array? - get() = _markers + private var _markerCollection: HybridMarkerCollection? = null + override var markerCollection: HybridMarkerCollection? + get() = _markerCollection set(value) { - _markers = value - if (googleMap != null) { - updateOverlayViewportSize() - overlayController.setMarkers(value) - } else { - pendingMarkers = value - } + _markerCollection = value + updateOverlayViewportSize() + overlayController.attachStore(value?.store) } private var _polylines: Array? = null @@ -310,7 +289,7 @@ class GoogleMapProviderAdapter( override var onPolygonPress: ((id: String) -> Unit)? = null override var onCirclePress: ((id: String) -> Unit)? = null - override var onClusterPress: ((markerIds: Array, coordinate: Coordinate) -> Unit)? = null + override var onClusterPress: ((event: NativeClusterPressEvent) -> Unit)? = null set(value) { field = value syncMarkerPressHandlers() @@ -383,6 +362,10 @@ class GoogleMapProviderAdapter( } } + override fun getClusterMembers(clusterId: String): Promise> { + return promiseOnMain { overlayController.clusterMembers(clusterId) } + } + override fun onHostResume() { isHostResumed = true syncLifecycleState() @@ -455,18 +438,8 @@ class GoogleMapProviderAdapter( map.setOnMapLoadedCallback { notifyMapReadyIfNeeded() } - if (_clusteringEnabled != true) { - map.setOnMarkerClickListener { marker -> - val id = marker.tag as? String - if (id != null) { - onMarkerPress?.invoke(id) - } - false - } - } else { - map.setOnMarkerClickListener { marker -> - overlayController.onMarkerClick(marker) - } + map.setOnMarkerClickListener { marker -> + overlayController.onMarkerClick(marker) } map.setOnMarkerDragListener( object : GoogleMap.OnMarkerDragListener { @@ -475,7 +448,7 @@ class GoogleMapProviderAdapter( override fun onMarkerDrag(marker: com.google.android.gms.maps.model.Marker) = Unit override fun onMarkerDragEnd(marker: com.google.android.gms.maps.model.Marker) { - val id = marker.tag as? String ?: return + val id = overlayController.markerId(marker) ?: return onMarkerDragEnd?.invoke( id, Coordinate( @@ -505,11 +478,10 @@ class GoogleMapProviderAdapter( } } - overlayController.setMarkers(pendingMarkers ?: _markers) + overlayController.reapplyMarkers() overlayController.updatePolylines(pendingPolylines ?: _polylines) overlayController.updatePolygons(pendingPolygons ?: _polygons) overlayController.updateCircles(pendingCircles ?: _circles) - pendingMarkers = null pendingPolylines = null pendingPolygons = null pendingCircles = null @@ -525,9 +497,7 @@ class GoogleMapProviderAdapter( private fun syncMarkerPressHandlers() { overlayController.setMarkerPressHandlers( onMarkerPress = onMarkerPress, - onClusterPress = onClusterPress?.let { callback -> - { ids, coordinate -> callback(ids.toTypedArray(), coordinate) } - }, + onClusterPress = onClusterPress, ) } @@ -774,6 +744,8 @@ class GoogleMapProviderAdapter( onCirclePress = null onClusterPress = null + _markerCollection = null + overlayController.attachStore(null) overlayController.clear() destroyMapView() } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt index 8eab2bf..7a0eafb 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt @@ -213,10 +213,10 @@ class HybridMapView(private val context: ThemedReactContext) : adapter?.onLongPress = value } - override var markers: Array? = null + override var markerCollection: HybridMarkerCollectionSpec? = null set(value) { field = value - adapter?.markers = value + adapter?.markerCollection = value as? HybridMarkerCollection } override var polylines: Array? = null @@ -267,7 +267,7 @@ class HybridMapView(private val context: ThemedReactContext) : adapter?.onCirclePress = value } - override var onClusterPress: ((markerIds: Array, coordinate: Coordinate) -> Unit)? = null + override var onClusterPress: ((event: NativeClusterPressEvent) -> Unit)? = null set(value) { field = value adapter?.onClusterPress = value @@ -305,6 +305,11 @@ class HybridMapView(private val context: ThemedReactContext) : return Promise.resolved(Unit) } + override fun getClusterMembers(clusterId: String): Promise> { + val mounted = adapter ?: return notMountedRejection() + return mounted.getClusterMembers(clusterId) + } + override fun onDropView() { releaseAdapter() } @@ -335,7 +340,7 @@ class HybridMapView(private val context: ThemedReactContext) : onPress = null onPoiPress = null onLongPress = null - markers = null + markerCollection = null polylines = null polygons = null circles = null @@ -415,7 +420,7 @@ class HybridMapView(private val context: ThemedReactContext) : adapter.onPress = onPress adapter.onPoiPress = onPoiPress adapter.onLongPress = onLongPress - adapter.markers = markers + adapter.markerCollection = markerCollection as? HybridMarkerCollection adapter.polylines = polylines adapter.polygons = polygons adapter.circles = circles diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMarkerCollection.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMarkerCollection.kt new file mode 100644 index 0000000..dd42bae --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMarkerCollection.kt @@ -0,0 +1,36 @@ +package com.margelo.nitro.nitromaps + +import androidx.annotation.Keep +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.core.ArrayBuffer + +/** + * Nitro `MarkerCollection`: the JS-facing handle of a [MarkerStore]. + * + * [applyBatch] runs on the JS thread. The buffer it receives is only valid for + * the duration of the call, so the bytes are validated and copied here and + * decoded later on the store thread; the JS thread never pays for the decode. + */ +@Keep +@DoNotStrip +class HybridMarkerCollection : HybridMarkerCollectionSpec() { + val store = MarkerStore() + + override val size: Double + get() = store.markerCount.toDouble() + + override val memorySize: Long + get() = store.estimatedBytes + + override fun applyBatch(batch: ArrayBuffer, strings: Array) { + val view = batch.getBuffer(false) + val bytes = ByteArray(view.remaining()) + view.get(bytes) + MarkerBatchDecoder.readHeader(MarkerBatchDecoder.wrap(bytes)) + store.enqueue(bytes, strings) + } + + override fun clear() { + store.enqueueClear() + } +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/IntList.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/IntList.kt new file mode 100644 index 0000000..ec9a395 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/IntList.kt @@ -0,0 +1,47 @@ +package com.margelo.nitro.nitromaps + +/** Growable list of unboxed ints for the marker index and cluster buckets. */ +internal class IntList(initialCapacity: Int = 4) { + private var values = IntArray(initialCapacity.coerceAtLeast(1)) + var size = 0 + private set + + operator fun get(index: Int): Int = values[index] + + fun add(value: Int) { + if (size == values.size) { + values = values.copyOf(values.size * 2) + } + values[size] = value + size += 1 + } + + fun addAll(other: IntList) { + val needed = size + other.size + if (needed > values.size) { + values = values.copyOf(maxOf(needed, values.size * 2)) + } + System.arraycopy(other.values, 0, values, size, other.size) + size = needed + } + + /** Removes the first occurrence of [value] by swapping in the last element. */ + fun removeValue(value: Int): Boolean { + for (index in 0 until size) { + if (values[index] == value) { + values[index] = values[size - 1] + size -= 1 + return true + } + } + return false + } + + fun clear() { + size = 0 + } + + fun isEmpty(): Boolean = size == 0 + + fun toIntArray(): IntArray = values.copyOf(size) +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt index e4270a1..f3e28c2 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt @@ -22,35 +22,26 @@ import java.util.concurrent.Executors class MapOverlayController( private var googleMap: GoogleMap?, private val context: ThemedReactContext, -) { - private val markers = HashMap() +) : MarkerStoreListener { + private val markers = HashMap() private val mainHandler = Handler(Looper.getMainLooper()) private val density: Float = context.resources.displayMetrics.density private val markerIconFactory = MarkerIconFactory(context, density) { markers } - private val markerVersions = HashMap() - private val clusterByKey = HashMap() + private val markerVersions = HashMap() + private val clustersById = HashMap() private val polylines = LinkedHashMap() private val polygons = LinkedHashMap() private val circles = LinkedHashMap() private val polylineVersions = HashMap() private val polygonVersions = HashMap() private val circleVersions = HashMap() - private val markerEnterAnimators = HashMap() + private val markerEnterAnimators = HashMap() private var clusteringEnabled = false private var onMarkerPress: ((String) -> Unit)? = null - private var onClusterPress: ((List, Coordinate) -> Unit)? = null - private var allMarkerDescriptors: Array = emptyArray() - private var markersFingerprint: Long = 0L - private var spatialIndex: MarkerSpatialIndex? = null + private var onClusterPress: ((NativeClusterPressEvent) -> Unit)? = null + private var store: MarkerStore? = null /** Invalidates in-flight refresh results (viewport diffs). */ private var refreshGeneration: Int = 0 - - /** - * Invalidates in-flight index builds. Kept apart from [refreshGeneration] so - * a burst of refreshes during a gesture cannot keep discarding the index - * build for a dataset that has not changed. - */ - private var datasetGeneration: Int = 0 private val refreshInbox = RefreshInbox() private var viewWidthPx: Int = 0 private var viewHeightPx: Int = 0 @@ -94,12 +85,43 @@ class MapOverlayController( fun setMarkerPressHandlers( onMarkerPress: ((String) -> Unit)?, - onClusterPress: ((List, Coordinate) -> Unit)?, + onClusterPress: ((NativeClusterPressEvent) -> Unit)?, ) { this.onMarkerPress = onMarkerPress this.onClusterPress = onClusterPress } + /** + * Renders markers from [next] and follows its changes until another store + * (or null) is attached. + */ + fun attachStore(next: MarkerStore?) { + if (store === next) { + return + } + store?.removeListener(this) + store = next + next?.addListener(this) + refreshGeneration += 1 + refreshInbox.discardPending() + reapplyMarkers() + } + + override fun onMarkerStoreChanged(store: MarkerStore) { + if (this.store === store) { + reapplyMarkers() + } + } + + /** Ids of the markers inside a displayed cluster; empty once it is gone. */ + fun clusterMembers(id: String): Array { + val cluster = clustersById[id] ?: return emptyArray() + return store?.ids(cluster.memberHandles) ?: emptyArray() + } + + /** The marker id behind a Google Maps marker, or null for cluster badges. */ + fun markerId(marker: Marker): String? = (marker.tag as? MarkerRenderKey.Single)?.id + fun clear() { markerEnterAnimators.values.toSet().forEach { it.cancel() } cancelIdleRefresh() @@ -111,51 +133,37 @@ class MapOverlayController( circles.values.forEach { it.remove() } markers.clear() markerVersions.clear() - clusterByKey.clear() + clustersById.clear() polylines.clear() polygons.clear() circles.clear() polylineVersions.clear() polygonVersions.clear() circleVersions.clear() - allMarkerDescriptors = emptyArray() - markersFingerprint = 0L - spatialIndex = null refreshGeneration += 1 - advanceDatasetGeneration() refreshInbox.discardPending() computeExecutor.shutdown() computeExecutor = Executors.newSingleThreadExecutor() } - fun setMarkers(descriptors: Array?) { - val next = descriptors ?: emptyArray() - val fingerprint = traceSection("NitroMaps.markersFingerprint") { next.markersFingerprint() } - if (fingerprint == markersFingerprint) { - return - } - - markersFingerprint = fingerprint - allMarkerDescriptors = next - spatialIndex = null - advanceDatasetGeneration() - reapplyMarkers() - } - /** * Whether markers are driven by the background viewport pipeline (clustering * or large LOD) rather than the synchronous small-dataset path. */ private fun usesViewportPipeline(): Boolean { - return clusteringEnabled || allMarkerDescriptors.size > ASYNC_THRESHOLD + return clusteringEnabled || (store?.markerCount ?: 0) > ASYNC_THRESHOLD } - private fun reapplyMarkers() { + /** + * Recomputes what is shown for the current dataset: synchronously for small + * unclustered datasets, through the viewport pipeline otherwise. + */ + fun reapplyMarkers() { googleMap ?: return if (usesViewportPipeline()) { - rebuildIndexAndRefresh() + refreshViewportMarkers() } else { - applyMarkersSync(allMarkerDescriptors) + applyMarkersSync() } } @@ -164,7 +172,7 @@ class MapOverlayController( maxAnimatedMarkers: Int = MAX_ANIMATED_MARKERS_PER_DIFF, ) { val map = googleMap ?: return - val index = spatialIndex ?: return + val store = store ?: return if (!usesViewportPipeline()) { return } @@ -176,7 +184,7 @@ class MapOverlayController( refreshGeneration += 1 val request = ViewportRefreshRequest( generation = refreshGeneration, - index = index, + store = store, bounds = bounds, latitudeSpan = bounds.northeast.latitude - bounds.southwest.latitude, clustering = clusteringEnabled, @@ -204,52 +212,78 @@ class MapOverlayController( } } - private fun rebuildIndexAndRefresh() { - val descriptors = allMarkerDescriptors - val builtForDataset = datasetGeneration - // Diffs computed against the previous index are stale from here on. - refreshGeneration += 1 - - computeExecutor.execute { - if (!refreshInbox.isCurrent(builtForDataset)) { - // A newer dataset superseded this build before it started. - return@execute - } - - val index = traceSection("NitroMaps.buildSpatialIndex") { MarkerSpatialIndex(descriptors) } - mainHandler.post { - if (builtForDataset != datasetGeneration) { - return@post - } - spatialIndex = index - refreshViewportMarkers() - } - } - } - private fun computeViewportDiff( request: ViewportRefreshRequest, ): MarkerRenderDiff = traceSection("NitroMaps.computeViewportDiff") { - val candidates = request.index.candidates(request.bounds) - val elements: List = if (request.clustering) { + // Only the index query holds the store lock. The geometry runs on the + // arrays as they were under it: a batch applied meanwhile replaces the + // store's arrays instead of writing into these, and the notification that + // follows every batch schedules the refresh that picks the new ones up. + // Holding the lock through the cluster pass would stall the main thread, + // which reads the store on every camera move. + val snapshot = request.store.read { access -> + ViewportSnapshot(access.index.candidates(request.bounds), access.latitudes, access.longitudes, access.flags) + } + val candidates = snapshot.candidates + val elements: List = if (request.clustering) { MarkerClusterEngine.clusters( candidates, + snapshot.latitudes, + snapshot.longitudes, + snapshot.flags, request.bounds, request.widthPx, request.heightPx, density, ) } else { - MarkerViewportFilter.displaySubset(candidates, request.bounds, request.latitudeSpan) - .map { ClusterElement.Single(it) } + MarkerViewportFilter + .displaySubset(candidates, snapshot.latitudes, snapshot.longitudes, request.bounds, request.latitudeSpan) + .map { MarkerClusterEngine.Element.Single(it) } } - computeMarkerRenderDiff(elements, request.displayedVersions) + val target = request.store.read { access -> materialize(elements, access) } + computeMarkerRenderDiff(target, request.displayedVersions) } - private fun advanceDatasetGeneration() { - datasetGeneration += 1 - refreshInbox.recordDataset(datasetGeneration) + /** What one viewport refresh takes from the store under its lock. */ + private class ViewportSnapshot( + val candidates: IntArray, + val latitudes: DoubleArray, + val longitudes: DoubleArray, + val flags: ByteArray, + ) + + /** + * Turns handles into render elements with their descriptors and versions. + * A handle removed between the query and this call is dropped. + */ + private fun materialize( + elements: List, + access: MarkerStoreAccess, + ): List { + val result = ArrayList(elements.size) + for (element in elements) { + when (element) { + is MarkerClusterEngine.Element.Single -> { + if (!access.isAlive(element.handle)) continue + val descriptor = access.descriptors[element.handle] ?: continue + result.add(ClusterElement.Single(element.handle, descriptor, access.versions[element.handle])) + } + is MarkerClusterEngine.Element.Cluster -> { + result.add( + ClusterElement.Cluster( + id = element.id, + position = element.position, + count = element.count, + memberHandles = element.memberHandles, + bounds = element.bounds, + ), + ) + } + } + } + return result } private fun applyDiff( @@ -263,13 +297,15 @@ class MapOverlayController( cancelEnteringAnimation(key) markers.remove(key)?.remove() markerVersions.remove(key) - clusterByKey.remove(key) + if (key is MarkerRenderKey.Cluster) { + clustersById.remove(key.id) + } } var remainingAnimationBudget = maxAnimatedMarkers.coerceAtLeast(0) val addedMarkers = ArrayList(minOf(diff.added.size, remainingAnimationBudget)) for (element in diff.added) { - val key = element.diffKey + val key = element.key when (element) { is ClusterElement.Single -> { val animation = enteringAnimation(element) @@ -281,7 +317,7 @@ class MapOverlayController( options.alpha(0f) } map.addMarker(options)?.also { marker -> - marker.tag = element.descriptor.id + marker.tag = key markers[key] = marker markerIconFactory.applyVisualProps(element.descriptor, marker, key) markerVersions[key] = element.renderVersion @@ -308,7 +344,7 @@ class MapOverlayController( marker.tag = key markers[key] = marker markerVersions[key] = element.renderVersion - clusterByKey[key] = element + clustersById[element.id] = element if (shouldAnimate) { addedMarkers.add(AddedMarker(key, marker, animation, targetAlpha = 1f)) remainingAnimationBudget -= 1 @@ -319,12 +355,11 @@ class MapOverlayController( } for (element in diff.retained) { - val key = element.diffKey + val key = element.key val marker = markers[key] ?: continue cancelEnteringAnimation(key) when (element) { is ClusterElement.Single -> { - marker.tag = element.descriptor.id marker.position = LatLng( element.descriptor.coordinate.latitude, element.descriptor.coordinate.longitude, @@ -333,13 +368,12 @@ class MapOverlayController( marker.snippet = element.descriptor.subtitle marker.isDraggable = element.descriptor.draggable == true markerIconFactory.applyVisualProps(element.descriptor, marker, key) - clusterByKey.remove(key) } is ClusterElement.Cluster -> { marker.alpha = 1f marker.position = element.position marker.setIcon(iconFactory.icon(element.count)) - clusterByKey[key] = element + clustersById[element.id] = element } } markerVersions[key] = element.renderVersion @@ -407,7 +441,7 @@ class MapOverlayController( } } - private fun cancelEnteringAnimation(key: String) { + private fun cancelEnteringAnimation(key: MarkerRenderKey) { markerEnterAnimators.remove(key)?.cancel() } @@ -429,54 +463,17 @@ class MapOverlayController( } } - private fun applyMarkersSync(descriptors: Array) { - val map = googleMap ?: return + /** Small unclustered datasets: one full diff on the UI thread, no viewport query. */ + private fun applyMarkersSync() { + googleMap ?: return refreshGeneration += 1 + refreshInbox.discardPending() cancelIdleRefresh() cancelLiveRefresh() - markerVersions.clear() - clusterByKey.clear() - reconcile( - current = markers, - next = descriptors.associate { ("s:" + it.id) to it }, - remove = { marker -> - (marker.tag as? String)?.let { cancelEnteringAnimation(it) } - marker.remove() - }, - add = { descriptor -> - val element = ClusterElement.Single(descriptor) - val key = "s:" + descriptor.id - val animation = enteringAnimation(element) - val options = descriptor.toMarkerOptions() - if (OverlayEnteringAnimationResolver.shouldRun(animation)) { - options.alpha(0f) - } - map.addMarker(options)?.also { marker -> - marker.tag = descriptor.id - markers[key] = marker - markerIconFactory.applyVisualProps(descriptor, marker, key) - markerVersions[key] = element.renderVersion - val targetAlpha = descriptor.opacity?.toFloat() ?: 1f - animateEntering(listOf(AddedMarker(key, marker, animation, targetAlpha))) - } - }, - update = { marker, descriptor -> - val element = ClusterElement.Single(descriptor) - val key = "s:" + descriptor.id - (marker.tag as? String)?.let { cancelEnteringAnimation(it) } - marker.tag = descriptor.id - marker.position = LatLng( - descriptor.coordinate.latitude, - descriptor.coordinate.longitude, - ) - marker.title = descriptor.title - marker.snippet = descriptor.subtitle - marker.isDraggable = descriptor.draggable == true - markerIconFactory.applyVisualProps(descriptor, marker, key) - markerVersions[key] = element.renderVersion - marker - }, - ) + val target = store?.read { access -> + materialize(access.aliveHandles().map { MarkerClusterEngine.Element.Single(it) }, access) + } ?: emptyList() + applyDiff(computeMarkerRenderDiff(target, markerVersions)) } fun onCameraIdle() { @@ -545,25 +542,32 @@ class MapOverlayController( lastLiveRefreshMs = 0L } + /** Routes a Google Maps marker tap to the marker or cluster callback. */ fun onMarkerClick(marker: Marker): Boolean { - val key = marker.tag as? String ?: return false - val cluster = clusterByKey[key] - if (cluster != null) { - onClusterPress?.invoke( - cluster.memberIds, - Coordinate( - latitude = cluster.position.latitude, - longitude = cluster.position.longitude, - ), - ) - googleMap?.animateCamera( - CameraUpdateFactory.newLatLngBounds(cluster.bounds, (72 * density).toInt()), - ) - return true + return when (val key = marker.tag as? MarkerRenderKey) { + is MarkerRenderKey.Cluster -> { + val cluster = clustersById[key.id] ?: return false + onClusterPress?.invoke( + NativeClusterPressEvent( + clusterId = cluster.id, + count = cluster.count.toDouble(), + coordinate = Coordinate( + latitude = cluster.position.latitude, + longitude = cluster.position.longitude, + ), + ), + ) + googleMap?.animateCamera( + CameraUpdateFactory.newLatLngBounds(cluster.bounds, (72 * density).toInt()), + ) + true + } + is MarkerRenderKey.Single -> { + onMarkerPress?.invoke(key.id) + false + } + null -> false } - - onMarkerPress?.invoke(key) - return false } fun updatePolylines(descriptors: Array?) { @@ -618,9 +622,8 @@ class MapOverlayController( } /** - * Like [reconcile], but keeps a render version per id: an unchanged - * descriptor is skipped and a changed one is updated in place instead of - * being removed and re-added. + * Keeps a render version per id: an unchanged descriptor is skipped and a + * changed one is updated in place instead of being removed and re-added. */ private fun reconcileShapes( current: MutableMap, @@ -651,45 +654,19 @@ class MapOverlayController( } } - private fun reconcile( - current: MutableMap, - next: Map, - remove: (T) -> Unit, - add: (Descriptor) -> T?, - update: (T, Descriptor) -> T, - ) { - val nextIds = next.keys - val existingIds = current.keys - - for (removedId in existingIds - nextIds) { - current.remove(removedId)?.let(remove) - } - - for ((id, descriptor) in next) { - val existing = current[id] - if (existing == null) { - add(descriptor)?.let { created -> - current[id] = created - } - } else { - current[id] = update(existing, descriptor) - } - } - } - /** * One viewport query, cluster or filter pass, and diff, computed off the UI - * thread against an immutable spatial index. + * thread against the store. */ private data class ViewportRefreshRequest( val generation: Int, - val index: MarkerSpatialIndex, + val store: MarkerStore, val bounds: LatLngBounds, val latitudeSpan: Double, val clustering: Boolean, val widthPx: Int, val heightPx: Int, - val displayedVersions: Map, + val displayedVersions: Map, val animateEntering: Boolean, val maxAnimatedMarkers: Int, ) @@ -699,24 +676,12 @@ class MapOverlayController( * compute executor (consumer). At most one compute task is queued at a time; * a request posted while one is queued replaces the pending request instead * of adding another task, so a long gesture cannot build a backlog of stale - * work. The latest dataset generation is mirrored here so a queued index - * build can bail out before computing. + * work. */ private class RefreshInbox { private val lock = Any() private var pending: ViewportRefreshRequest? = null private var isComputeQueued = false - private var latestDatasetGeneration = 0 - - fun recordDataset(generation: Int) { - synchronized(lock) { - latestDatasetGeneration = generation - } - } - - fun isCurrent(datasetGeneration: Int): Boolean { - return synchronized(lock) { datasetGeneration == latestDatasetGeneration } - } /** Returns true when the caller must enqueue a compute task. */ fun post(request: ViewportRefreshRequest): Boolean { @@ -766,7 +731,7 @@ class MapOverlayController( } private data class AddedMarker( - val key: String, + val key: MarkerRenderKey, val marker: Marker, val animation: ResolvedOverlayEnteringAnimation, val targetAlpha: Float, diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt index 06f7e14..915794e 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt @@ -31,7 +31,7 @@ interface MapProviderAdapter { var onPoiPress: ((event: NativePoiPressEvent) -> Unit)? var onLongPress: ((coordinate: Coordinate) -> Unit)? - var markers: Array? + var markerCollection: HybridMarkerCollection? var polylines: Array? var polygons: Array? var circles: Array? @@ -41,13 +41,14 @@ interface MapProviderAdapter { var onPolylinePress: ((id: String) -> Unit)? var onPolygonPress: ((id: String) -> Unit)? var onCirclePress: ((id: String) -> Unit)? - var onClusterPress: ((markerIds: Array, coordinate: Coordinate) -> Unit)? + var onClusterPress: ((event: NativeClusterPressEvent) -> Unit)? fun fetchCamera(): Promise fun applyCamera(camera: Camera) fun animateCamera(camera: Camera, duration: Double?) fun getVisibleRegion(): Promise fun fitToCoordinates(coordinates: Array, padding: EdgePadding?, animated: Boolean?) + fun getClusterMembers(clusterId: String): Promise> /** * Destroys the underlying native map and unregisters everything the adapter owns. diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerBatchDecoder.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerBatchDecoder.kt new file mode 100644 index 0000000..ec3c987 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerBatchDecoder.kt @@ -0,0 +1,215 @@ +package com.margelo.nitro.nitromaps + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Layout of the packed batches `MarkerCollection` sends from JS. The format is + * documented in `src/markers/markerBatch.ts`; keep both sides in sync. + */ +internal object MarkerBatchLayout { + const val MAGIC = 0x4E4D4B31 + const val HEADER_BYTES = 16 + const val UPSERT_BYTES = 96 + const val REMOVE_BYTES = 4 + const val POSITION_BYTES = 24 + const val NO_STRING = -1 + + const val HAS_ANCHOR = 1 shl 0 + const val HAS_CENTER_OFFSET = 1 shl 1 + const val DRAGGABLE = 1 shl 16 + const val CLUSTERABLE = 1 shl 17 + const val FLAT = 1 shl 18 + + object Upsert { + const val HANDLE = 0 + const val FLAGS = 4 + const val ID = 8 + const val TITLE = 12 + const val SUBTITLE = 16 + const val IMAGE_URI = 20 + const val LATITUDE = 24 + const val LONGITUDE = 32 + const val IMAGE_WIDTH = 40 + const val IMAGE_HEIGHT = 44 + const val IMAGE_SCALE = 48 + const val ANCHOR_X = 52 + const val ANCHOR_Y = 56 + const val CENTER_OFFSET_X = 60 + const val CENTER_OFFSET_Y = 64 + const val ROTATION = 68 + const val OPACITY = 72 + const val ANIMATION_DURATION = 76 + const val ANIMATION_DELAY = 80 + const val ANIMATION_KIND = 84 + const val ANIMATION_REDUCE_MOTION = 85 + const val MARKER_COLOR = 88 + const val Z_INDEX = 92 + } +} + +internal class MarkerBatchHeader( + val upsertCount: Int, + val removeCount: Int, + val positionCount: Int, +) { + /** In `Long`: the counts are untrusted and their products overflow `Int`. */ + val totalBytes: Long + get() = MarkerBatchLayout.HEADER_BYTES.toLong() + + upsertCount.toLong() * MarkerBatchLayout.UPSERT_BYTES + + removeCount.toLong() * MarkerBatchLayout.REMOVE_BYTES + + positionCount.toLong() * MarkerBatchLayout.POSITION_BYTES +} + +class MalformedMarkerBatchException(message: String) : IllegalArgumentException(message) + +internal object MarkerBatchDecoder { + /** Wraps a copy of a batch for decoding. */ + fun wrap(bytes: ByteArray): ByteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + + /** + * Validates the magic and the total length. Cheap enough to run on the JS + * thread before the bytes are copied off it. + */ + fun readHeader(buffer: ByteBuffer): MarkerBatchHeader { + if (buffer.limit() < MarkerBatchLayout.HEADER_BYTES) { + throw MalformedMarkerBatchException("Marker batch is shorter than its header") + } + if (buffer.getInt(0) != MarkerBatchLayout.MAGIC) { + throw MalformedMarkerBatchException("Not a marker batch") + } + + val header = MarkerBatchHeader( + upsertCount = buffer.getInt(4), + removeCount = buffer.getInt(8), + positionCount = buffer.getInt(12), + ) + if (header.upsertCount < 0 || header.removeCount < 0 || header.positionCount < 0 || + header.totalBytes != buffer.limit().toLong() + ) { + throw MalformedMarkerBatchException( + "Marker batch is ${buffer.limit()} bytes, expected ${header.totalBytes}", + ) + } + return header + } + + /** + * Walks every record: removals first, then upserts, then positions, so a + * handle freed in this batch can be reused by an upsert in the same batch. + */ + fun decode( + buffer: ByteBuffer, + strings: Array, + onRemove: (Int) -> Unit, + onUpsert: (Int, MarkerDescriptor) -> Unit, + onPosition: (Int, Double, Double) -> Unit, + ) { + val header = readHeader(buffer) + val upsertsStart = MarkerBatchLayout.HEADER_BYTES + val removesStart = upsertsStart + header.upsertCount * MarkerBatchLayout.UPSERT_BYTES + val positionsStart = removesStart + header.removeCount * MarkerBatchLayout.REMOVE_BYTES + + for (index in 0 until header.removeCount) { + onRemove(buffer.getInt(removesStart + index * MarkerBatchLayout.REMOVE_BYTES)) + } + + for (index in 0 until header.upsertCount) { + val base = upsertsStart + index * MarkerBatchLayout.UPSERT_BYTES + val descriptor = descriptorAt(base, buffer, strings) ?: continue + onUpsert(buffer.getInt(base + MarkerBatchLayout.Upsert.HANDLE), descriptor) + } + + for (index in 0 until header.positionCount) { + val base = positionsStart + index * MarkerBatchLayout.POSITION_BYTES + onPosition(buffer.getInt(base), buffer.getDouble(base + 8), buffer.getDouble(base + 16)) + } + } + + private fun descriptorAt(base: Int, buffer: ByteBuffer, strings: Array): MarkerDescriptor? { + val id = stringAt(strings, buffer.getInt(base + MarkerBatchLayout.Upsert.ID)) ?: return null + val flags = buffer.getInt(base + MarkerBatchLayout.Upsert.FLAGS) + + val imageUri = stringAt(strings, buffer.getInt(base + MarkerBatchLayout.Upsert.IMAGE_URI)) + val image = imageUri?.let { + MarkerImage( + uri = it, + width = buffer.optionalFloat(base + MarkerBatchLayout.Upsert.IMAGE_WIDTH), + height = buffer.optionalFloat(base + MarkerBatchLayout.Upsert.IMAGE_HEIGHT), + scale = buffer.optionalFloat(base + MarkerBatchLayout.Upsert.IMAGE_SCALE), + ) + } + + val anchor = if (flags and MarkerBatchLayout.HAS_ANCHOR != 0) { + MarkerAnchor( + x = buffer.getFloat(base + MarkerBatchLayout.Upsert.ANCHOR_X).toDouble(), + y = buffer.getFloat(base + MarkerBatchLayout.Upsert.ANCHOR_Y).toDouble(), + ) + } else { + null + } + + val centerOffset = if (flags and MarkerBatchLayout.HAS_CENTER_OFFSET != 0) { + MarkerPoint( + x = buffer.getFloat(base + MarkerBatchLayout.Upsert.CENTER_OFFSET_X).toDouble(), + y = buffer.getFloat(base + MarkerBatchLayout.Upsert.CENTER_OFFSET_Y).toDouble(), + ) + } else { + null + } + + val enteringAnimation = animationKind(buffer.get(base + MarkerBatchLayout.Upsert.ANIMATION_KIND))?.let { kind -> + OverlayEnteringAnimationDescriptor( + kind = kind, + duration = buffer.optionalFloat(base + MarkerBatchLayout.Upsert.ANIMATION_DURATION), + delay = buffer.optionalFloat(base + MarkerBatchLayout.Upsert.ANIMATION_DELAY), + reduceMotion = reduceMotion(buffer.get(base + MarkerBatchLayout.Upsert.ANIMATION_REDUCE_MOTION)), + ) + } + + return MarkerDescriptor( + id = id, + coordinate = Coordinate( + latitude = buffer.getDouble(base + MarkerBatchLayout.Upsert.LATITUDE), + longitude = buffer.getDouble(base + MarkerBatchLayout.Upsert.LONGITUDE), + ), + title = stringAt(strings, buffer.getInt(base + MarkerBatchLayout.Upsert.TITLE)), + subtitle = stringAt(strings, buffer.getInt(base + MarkerBatchLayout.Upsert.SUBTITLE)), + draggable = if (flags and MarkerBatchLayout.DRAGGABLE != 0) true else null, + clusterable = if (flags and MarkerBatchLayout.CLUSTERABLE != 0) null else false, + image = image, + markerColor = stringAt(strings, buffer.getInt(base + MarkerBatchLayout.Upsert.MARKER_COLOR)), + anchor = anchor, + centerOffset = centerOffset, + rotation = buffer.optionalFloat(base + MarkerBatchLayout.Upsert.ROTATION), + flat = if (flags and MarkerBatchLayout.FLAT != 0) true else null, + opacity = buffer.optionalFloat(base + MarkerBatchLayout.Upsert.OPACITY), + zIndex = buffer.optionalFloat(base + MarkerBatchLayout.Upsert.Z_INDEX), + enteringAnimation = enteringAnimation, + ) + } + + private fun stringAt(strings: Array, index: Int): String? { + return if (index < 0 || index >= strings.size) null else strings[index] + } + + private fun animationKind(code: Byte): OverlayEnteringAnimationKind? = when (code.toInt()) { + 1 -> OverlayEnteringAnimationKind.NONE + 2 -> OverlayEnteringAnimationKind.SYSTEM + 3 -> OverlayEnteringAnimationKind.FADE + 4 -> OverlayEnteringAnimationKind.FADE_SCALE + else -> null + } + + private fun reduceMotion(code: Byte): OverlayEnteringAnimationReduceMotion? = when (code.toInt()) { + 1 -> OverlayEnteringAnimationReduceMotion.SYSTEM + 2 -> OverlayEnteringAnimationReduceMotion.NEVER + else -> null + } + + /** `NaN` marks an absent optional float. */ + private fun ByteBuffer.optionalFloat(offset: Int): Double? { + val value = getFloat(offset) + return if (value.isNaN()) null else value.toDouble() + } +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt index 71f4c56..b1fef30 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt @@ -7,31 +7,49 @@ import kotlin.math.log2 import kotlin.math.pow import kotlin.math.roundToInt -/** A single display element: an individual marker or a cluster badge. */ +/** + * Identity of a displayed element across refreshes. + * + * A single carries its id as well as its handle: JS reuses a freed handle for + * the next new marker, and a new marker must not be mistaken for an update of + * the one that used to own the handle. + */ +internal sealed class MarkerRenderKey { + data class Single(val handle: Int, val id: String) : MarkerRenderKey() + + data class Cluster(val id: String) : MarkerRenderKey() +} + +/** + * A display element with everything the renderer needs, materialized from the + * store for the elements that will actually be shown. + */ internal sealed interface ClusterElement { - val diffKey: String + val key: MarkerRenderKey val renderVersion: Long - data class Single(val descriptor: MarkerDescriptor) : ClusterElement { - override val diffKey: String get() = "s:" + descriptor.id - override val renderVersion: Long = descriptor.displayedIdentityVersion() + class Single( + val handle: Int, + val descriptor: MarkerDescriptor, + override val renderVersion: Long, + ) : ClusterElement { + override val key: MarkerRenderKey = MarkerRenderKey.Single(handle, descriptor.id) } - data class Cluster( - val key: String, + class Cluster( + val id: String, val position: LatLng, val count: Int, - val memberIds: List, + val memberHandles: IntArray, val bounds: LatLngBounds, ) : ClusterElement { - override val diffKey: String get() = "c:$key" + override val key: MarkerRenderKey = MarkerRenderKey.Cluster(id) override val renderVersion: Long = renderSignature( "cluster", - key, + id, position.latitude, position.longitude, count, - memberIds.sorted(), bounds.southwest.latitude, bounds.southwest.longitude, bounds.northeast.latitude, @@ -43,11 +61,25 @@ internal sealed interface ClusterElement { /** * Grid-based marker clustering computed in geographic space. * - * Pure function over descriptor data (no map projection), so it is safe to call - * from a background thread. Output is bounded by the number of grid cells that - * fit on screen, keeping per-frame Google Maps work small and constant. + * Runs over store handles and the store's flat coordinate arrays (no map + * projection, no descriptor copies), so it is safe to call from a background + * thread. Output is bounded by the number of grid cells that fit on screen, + * keeping per-frame Google Maps work small and constant. */ internal object MarkerClusterEngine { + /** A display element before its descriptor is looked up. */ + sealed interface Element { + class Single(val handle: Int) : Element + + class Cluster( + val id: String, + val position: LatLng, + val count: Int, + val memberHandles: IntArray, + val bounds: LatLngBounds, + ) : Element + } + private const val CELL_DP = 64.0 private fun wrapsLongitude(sw: LatLng, ne: LatLng): Boolean { @@ -93,27 +125,30 @@ internal object MarkerClusterEngine { } fun clusters( - candidates: List, + candidates: IntArray, + latitudes: DoubleArray, + longitudes: DoubleArray, + flags: ByteArray, bounds: LatLngBounds, viewWidthPx: Int, viewHeightPx: Int, density: Float, - ): List { + ): List { if (candidates.isEmpty()) { return emptyList() } - val singles = ArrayList() - val clusterableCandidates = ArrayList() - for (descriptor in candidates) { - if (descriptor.clusterable == false) { - singles.add(ClusterElement.Single(descriptor)) + val singles = ArrayList() + val clusterable = IntList(candidates.size) + for (handle in candidates) { + if (flags[handle].toInt() and MarkerStore.FLAG_CLUSTERABLE == 0) { + singles.add(Element.Single(handle)) } else { - clusterableCandidates.add(descriptor) + clusterable.add(handle) } } - if (clusterableCandidates.isEmpty()) { + if (clusterable.isEmpty()) { return singles } @@ -129,19 +164,15 @@ internal object MarkerClusterEngine { val cellLat = quantize((ne.latitude - sw.latitude) / rows) val cellLon = quantize(longitudeSpan(sw, ne) / cols) - val buckets = HashMap() - for (descriptor in clusterableCandidates) { - val lat = descriptor.coordinate.latitude - val lon = if (wraps) { - normalizeLongitude(descriptor.coordinate.longitude, sw.longitude) - } else { - descriptor.coordinate.longitude - } + val buckets = HashMap() + for (index in 0 until clusterable.size) { + val handle = clusterable[index] + val lat = latitudes[handle] + val lon = if (wraps) normalizeLongitude(longitudes[handle], sw.longitude) else longitudes[handle] val row = floor(lat / cellLat).toInt() val col = floor(lon / cellLon).toInt() - val key = "$row:$col" - val bucket = buckets.getOrPut(key) { Bucket() } - bucket.key = key + val key = (row.toLong() shl 32) or (col.toLong() and 0xFFFF_FFFFL) + val bucket = buckets.getOrPut(key) { Bucket(row, col) } bucket.count += 1 bucket.sumLat += lat bucket.sumLon += lon @@ -149,10 +180,7 @@ internal object MarkerClusterEngine { bucket.maxLat = maxOf(bucket.maxLat, lat) bucket.minLon = minOf(bucket.minLon, lon) bucket.maxLon = maxOf(bucket.maxLon, lon) - if (bucket.first == null) { - bucket.first = descriptor - } - bucket.memberIds.add(descriptor.id) + bucket.memberHandles.add(handle) } val merged = mergeOverlapping( @@ -164,19 +192,18 @@ internal object MarkerClusterEngine { density, ) - val result = ArrayList(merged.size + singles.size) + val result = ArrayList(merged.size + singles.size) result.addAll(singles) for (bucket in merged) { - val first = bucket.first - if (bucket.count == 1 && first != null) { - result.add(ClusterElement.Single(first)) + if (bucket.count == 1) { + result.add(Element.Single(bucket.memberHandles[0])) } else { result.add( - ClusterElement.Cluster( - key = bucket.key, + Element.Cluster( + id = bucket.id, position = LatLng(bucket.sumLat / bucket.count, bucket.sumLon / bucket.count), count = bucket.count, - memberIds = bucket.memberIds, + memberHandles = bucket.memberHandles.toIntArray(), bounds = LatLngBounds( LatLng(bucket.minLat, wrapTo180(bucket.minLon)), LatLng(bucket.maxLat, wrapTo180(bucket.maxLon)), @@ -198,7 +225,7 @@ internal object MarkerClusterEngine { * Merges buckets whose badges would overlap on screen, so a zoomed-out view * collapses neighbouring cells into one badge instead of stacking them. Uses * union-find on screen-space centroid distance; groups are seeded by the - * largest bucket so the resulting cluster key is stable. + * largest bucket so the resulting cluster id is stable. */ private fun mergeOverlapping( buckets: ArrayList, @@ -281,8 +308,7 @@ internal object MarkerClusterEngine { return order.mapNotNull { groups[it] } } - private class Bucket { - var key = "" + private class Bucket(val row: Int, val column: Int) { var count = 0 var sumLat = 0.0 var sumLon = 0.0 @@ -290,10 +316,13 @@ internal object MarkerClusterEngine { var maxLat = -Double.MAX_VALUE var minLon = Double.MAX_VALUE var maxLon = -Double.MAX_VALUE - var first: MarkerDescriptor? = null - val memberIds = ArrayList() + val memberHandles = IntList() + + /** Stable identity: the grid cell, which is anchored to geography. */ + val id: String + get() = "$row:$column" - /** Folds another bucket's members in; keeps own key/first (seed = dominant). */ + /** Folds another bucket's members in; keeps own cell (seed = dominant). */ fun absorb(other: Bucket) { count += other.count sumLat += other.sumLat @@ -302,7 +331,7 @@ internal object MarkerClusterEngine { maxLat = maxOf(maxLat, other.maxLat) minLon = minOf(minLon, other.minLon) maxLon = maxOf(maxLon, other.maxLon) - memberIds.addAll(other.memberIds) + memberHandles.addAll(other.memberHandles) } } } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt deleted file mode 100644 index 84ca771..0000000 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.margelo.nitro.nitromaps - -/** Displayed-marker identity. Omits `enteringAnimation`; keep in sync with the Swift hasher. */ -internal fun MarkerDescriptor.displayedIdentityVersion(): Long = - renderSignature( - id, - coordinate.latitude, - coordinate.longitude, - title, - subtitle, - draggable, - clusterable, - image?.uri, - image?.width, - image?.height, - image?.scale, - markerColor, - anchor?.x, - anchor?.y, - centerOffset?.x, - centerOffset?.y, - rotation, - flat, - opacity, - zIndex, - ) diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+Fingerprint.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+Fingerprint.kt deleted file mode 100644 index f7f56f0..0000000 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+Fingerprint.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.margelo.nitro.nitromaps - -internal fun MarkerDescriptor.fingerprint(): Long = - renderSignature( - displayedIdentityVersion(), - enteringAnimation?.kind, - enteringAnimation?.duration, - enteringAnimation?.delay, - enteringAnimation?.reduceMotion, - ) - -internal fun Array?.markersFingerprint(): Long { - if (this.isNullOrEmpty()) { - return 0L - } - - var hash = size.toLong() - for (descriptor in this) { - hash = 31L * hash + descriptor.fingerprint() - } - return hash -} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor.kt new file mode 100644 index 0000000..f39e670 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor.kt @@ -0,0 +1,50 @@ +package com.margelo.nitro.nitromaps + +/** + * Marker data as the native store holds it. + * + * Nitrogen used to generate these classes from the `markers` view prop. Markers + * now reach native code as packed batches (see [MarkerBatchDecoder]), so no + * spec references them and they live here instead. Field names and types match + * the TypeScript `MarkerDescriptor`. Should a Nitro spec reference + * `MarkerDescriptor` again, nitrogen would generate a conflicting class and + * this file has to go. + */ +data class MarkerImage( + val uri: String, + val width: Double?, + val height: Double?, + val scale: Double?, +) + +/** Anchor point on the marker image (0..1). */ +data class MarkerAnchor( + val x: Double, + val y: Double, +) + +/** Point offset in density-independent pixels. */ +data class MarkerPoint( + val x: Double, + val y: Double, +) + +data class MarkerDescriptor( + val id: String, + val coordinate: Coordinate, + val title: String?, + val subtitle: String?, + val draggable: Boolean?, + val clusterable: Boolean?, + val image: MarkerImage?, + /** Tint of the default pin when there is no image. */ + val markerColor: String?, + val anchor: MarkerAnchor?, + val centerOffset: MarkerPoint?, + val rotation: Double?, + val flat: Boolean?, + val opacity: Double?, + /** Drawing order relative to other overlays. */ + val zIndex: Double?, + val enteringAnimation: OverlayEnteringAnimationDescriptor?, +) diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt index a73d14b..c4d717a 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt @@ -26,7 +26,7 @@ import java.util.concurrent.Executors internal class MarkerIconFactory( private val context: Context, private val density: Float, - private val markerRegistry: () -> Map, + private val markerRegistry: () -> Map, ) { private val cache = object : LruCache(iconCacheBytes()) { override fun sizeOf(key: String, value: CachedIcon): Int = value.byteCount @@ -47,7 +47,7 @@ internal class MarkerIconFactory( fun applyVisualProps( descriptor: MarkerDescriptor, marker: Marker, - key: String, + key: MarkerRenderKey, ) { applyAnchor(descriptor, marker) marker.rotation = descriptor.rotation?.toFloat() ?: 0f @@ -74,7 +74,7 @@ internal class MarkerIconFactory( marker.setAnchor(anchorX, anchorY) } - private fun isMarkerCurrent(key: String, marker: Marker): Boolean = + private fun isMarkerCurrent(key: MarkerRenderKey, marker: Marker): Boolean = markerRegistry()[key] === marker private fun applyIcon( diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerRenderDiff.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerRenderDiff.kt index 5eb4a94..e9b9b70 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerRenderDiff.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerRenderDiff.kt @@ -1,21 +1,21 @@ package com.margelo.nitro.nitromaps internal data class MarkerRenderDiff( - val removedKeys: Set, + val removedKeys: Set, val added: List, val retained: List, ) internal fun computeMarkerRenderDiff( target: List, - displayed: Map, + displayed: Map, ): MarkerRenderDiff { - val nextKeys = HashSet(target.size) + val nextKeys = HashSet(target.size) val added = ArrayList() val retained = ArrayList() for (element in target) { - val key = element.diffKey + val key = element.key if (!nextKeys.add(key)) { continue } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerSpatialIndex.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerSpatialIndex.kt index 0c53335..9911c5e 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerSpatialIndex.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerSpatialIndex.kt @@ -3,62 +3,145 @@ package com.margelo.nitro.nitromaps import com.google.android.gms.maps.model.LatLngBounds /** - * Uniform grid spatial index over a marker dataset. + * Uniform grid spatial index over marker handles. * - * Built once per dataset so viewport queries cost O(cells in view + markers in - * those cells) instead of O(all markers). Immutable after construction, so - * instances are safe to query from a background thread. + * Cells hold handles, not descriptors, and the grid is updated in place as + * markers are inserted, moved and removed, so a moving marker costs one cell + * swap instead of a rebuild. The grid bounds are computed over the dataset + * with a margin; a marker that lands outside them flags a rebuild, which the + * store runs once at the end of the batch that caused it. + * + * Not thread-safe on its own: the owning [MarkerStore] serializes access. */ -internal class MarkerSpatialIndex( - markers: Array, - cellsPerSide: Int = 96, -) { - val count: Int = markers.size +internal class MarkerSpatialIndex(cellsPerSide: Int = 96) { private val side: Int = maxOf(1, cellsPerSide) - private val minLat: Double - private val minLon: Double - private val latStep: Double - private val lonStep: Double - private val cells: Array> + private var minLat = 0.0 + private var maxLat = 0.0 + private var minLon = 0.0 + private var maxLon = 0.0 + private var latStep = 1.0 + private var lonStep = 1.0 + private var hasBounds = false + private var needsRebuild = false + private val cells: Array = Array(side * side) { IntList() } + /** Cell index per handle, -1 when the handle is not indexed. */ + private var cellOf = IntArray(0) + var count = 0 + private set + + fun insert(handle: Int, latitude: Double, longitude: Double) { + ensureCapacity(handle) + if (cellOf[handle] >= 0) { + move(handle, latitude, longitude) + return + } + + count += 1 + if (!hasBounds || !contains(latitude, longitude)) { + needsRebuild = true + } + val cell = clampedCellIndex(latitude, longitude) + cells[cell].add(handle) + cellOf[handle] = cell + } + + fun move(handle: Int, latitude: Double, longitude: Double) { + if (handle >= cellOf.size || cellOf[handle] < 0) { + insert(handle, latitude, longitude) + return + } + + if (!contains(latitude, longitude)) { + needsRebuild = true + } + val current = cellOf[handle] + val next = clampedCellIndex(latitude, longitude) + if (next == current) { + return + } + cells[current].removeValue(handle) + cells[next].add(handle) + cellOf[handle] = next + } + + fun remove(handle: Int) { + if (handle >= cellOf.size || cellOf[handle] < 0) { + return + } + cells[cellOf[handle]].removeValue(handle) + cellOf[handle] = -1 + count -= 1 + } + + fun removeAll() { + cells.forEach { it.clear() } + cellOf = IntArray(0) + count = 0 + hasBounds = false + needsRebuild = false + } + + /** + * Recomputes the grid over every live marker if one fell outside the current + * bounds. Called once per applied batch, before any query. + */ + fun rebuildIfNeeded(latitudes: DoubleArray, longitudes: DoubleArray, flags: ByteArray) { + if (!needsRebuild) { + return + } + needsRebuild = false - init { var minLatV = Double.MAX_VALUE var maxLatV = -Double.MAX_VALUE var minLonV = Double.MAX_VALUE var maxLonV = -Double.MAX_VALUE - - for (marker in markers) { - val lat = marker.coordinate.latitude - val lon = marker.coordinate.longitude - if (lat < minLatV) minLatV = lat - if (lat > maxLatV) maxLatV = lat - if (lon < minLonV) minLonV = lon - if (lon > maxLonV) maxLonV = lon + var alive = 0 + for (handle in flags.indices) { + if (flags[handle].toInt() and MarkerStore.FLAG_ALIVE == 0) continue + alive += 1 + if (latitudes[handle] < minLatV) minLatV = latitudes[handle] + if (latitudes[handle] > maxLatV) maxLatV = latitudes[handle] + if (longitudes[handle] < minLonV) minLonV = longitudes[handle] + if (longitudes[handle] > maxLonV) maxLonV = longitudes[handle] } - if (markers.isEmpty()) { - minLatV = 0.0 - maxLatV = 0.0 - minLonV = 0.0 - maxLonV = 0.0 + cells.forEach { it.clear() } + if (alive == 0) { + hasBounds = false + cellOf.fill(-1) + count = 0 + return } - minLat = minLatV - minLon = minLonV - latStep = maxOf(1e-9, (maxLatV - minLatV) / side) - lonStep = maxOf(1e-9, (maxLonV - minLonV) / side) - cells = Array(side * side) { mutableListOf() } - - for (marker in markers) { - val index = cellIndex(marker.coordinate.latitude, marker.coordinate.longitude) - cells[index].add(marker) + // A margin keeps ordinary movement inside the grid; only a marker that + // leaves the dataset's neighbourhood triggers the next rebuild. + val latPad = maxOf((maxLatV - minLatV) * 0.15, 1e-6) + val lonPad = maxOf((maxLonV - minLonV) * 0.15, 1e-6) + minLat = minLatV - latPad + maxLat = maxLatV + latPad + minLon = minLonV - lonPad + maxLon = maxLonV + lonPad + latStep = maxOf(1e-9, (maxLat - minLat) / side) + lonStep = maxOf(1e-9, (maxLon - minLon) / side) + hasBounds = true + + ensureCapacity(flags.size - 1) + for (handle in flags.indices) { + if (flags[handle].toInt() and MarkerStore.FLAG_ALIVE != 0) { + val cell = clampedCellIndex(latitudes[handle], longitudes[handle]) + cells[cell].add(handle) + cellOf[handle] = cell + } else { + cellOf[handle] = -1 + } } + count = alive } - /** Markers whose grid cells overlap the padded bounds. */ - fun candidates(bounds: LatLngBounds, padding: Double = 0.2): List { - if (count == 0) { - return emptyList() + /** Handles whose grid cells overlap the padded bounds. */ + fun candidates(bounds: LatLngBounds, padding: Double = 0.2): IntArray { + if (count == 0 || !hasBounds) { + return IntArray(0) } val latSpan = bounds.northeast.latitude - bounds.southwest.latitude @@ -69,13 +152,21 @@ internal class MarkerSpatialIndex( } val latPad = latSpan * padding val lonPad = lonSpan * padding + val minLatQ = bounds.southwest.latitude - latPad + val maxLatQ = bounds.northeast.latitude + latPad + if (maxLatQ < minLat || minLatQ > maxLat) { + return IntArray(0) + } - val rowStart = clampedRow(bounds.southwest.latitude - latPad) - val rowEnd = clampedRow(bounds.northeast.latitude + latPad) val minLonQ = bounds.southwest.longitude - lonPad val maxLonQ = bounds.northeast.longitude + lonPad + if (!overlapsLongitude(minLonQ, maxLonQ)) { + return IntArray(0) + } - val result = ArrayList() + val rowStart = clampedRow(minLatQ) + val rowEnd = clampedRow(maxLatQ) + val result = IntList(64) val columns = longitudeColumns(minLonQ, maxLonQ) var row = rowStart while (row <= rowEnd) { @@ -85,7 +176,38 @@ internal class MarkerSpatialIndex( } row += 1 } - return result + return result.toIntArray() + } + + private fun ensureCapacity(handle: Int) { + if (handle < cellOf.size) { + return + } + val previous = cellOf.size + cellOf = cellOf.copyOf(maxOf(handle + 1, previous * 2, 64)) + cellOf.fill(-1, previous, cellOf.size) + } + + /** + * Whether a query's longitude range, which may cross the antimeridian, meets + * the grid's. Without this a query east or west of the dataset would clamp to + * the outermost column and return everything in it. + */ + private fun overlapsLongitude(minLonQ: Double, maxLonQ: Double): Boolean { + if (maxLonQ - minLonQ >= 360.0) { + return true + } + val wrappedMin = wrapLongitude(minLonQ) + val wrappedMax = wrapLongitude(maxLonQ) + return if (wrappedMin <= wrappedMax) { + wrappedMax >= minLon && wrappedMin <= maxLon + } else { + maxLon >= wrappedMin || minLon <= wrappedMax + } + } + + private fun contains(latitude: Double, longitude: Double): Boolean { + return latitude >= minLat && latitude <= maxLat && longitude >= minLon && longitude <= maxLon } private fun longitudeColumns(minLon: Double, maxLon: Double): List { @@ -96,9 +218,7 @@ internal class MarkerSpatialIndex( val wrappedMin = wrapLongitude(minLon) val wrappedMax = wrapLongitude(maxLon) if (wrappedMin <= wrappedMax && maxLon <= 180.0 && minLon >= -180.0) { - val colStart = clampedColumn(wrappedMin) - val colEnd = clampedColumn(wrappedMax) - return (colStart..colEnd).toList() + return (clampedColumn(wrappedMin)..clampedColumn(wrappedMax)).toList() } val firstRange = clampedColumn(wrappedMin) until side @@ -113,7 +233,7 @@ internal class MarkerSpatialIndex( return wrapped } - private fun cellIndex(lat: Double, lon: Double): Int { + private fun clampedCellIndex(lat: Double, lon: Double): Int { return clampedRow(lat) * side + clampedColumn(lon) } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerStore.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerStore.kt new file mode 100644 index 0000000..e4ff9cd --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerStore.kt @@ -0,0 +1,263 @@ +package com.margelo.nitro.nitromaps + +import android.os.Handler +import android.os.Looper +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +internal interface MarkerStoreListener { + /** Delivered on the main thread after a batch has been applied. */ + fun onMarkerStoreChanged(store: MarkerStore) +} + +/** + * Read access to the store's arrays. The coordinate and flag arrays are a + * consistent snapshot that stays valid after [MarkerStore.read] returns: a + * batch never writes into them, it replaces them with copies. Handles from the + * index are valid indices into them. Descriptors, versions and the index are + * only valid inside [MarkerStore.read]. + */ +internal class MarkerStoreAccess( + val latitudes: DoubleArray, + val longitudes: DoubleArray, + val flags: ByteArray, + val descriptors: Array, + val versions: LongArray, + val index: MarkerSpatialIndex, + val count: Int, +) { + fun isAlive(handle: Int): Boolean = + handle >= 0 && handle < flags.size && flags[handle].toInt() and MarkerStore.FLAG_ALIVE != 0 + + fun isClusterable(handle: Int): Boolean = + flags[handle].toInt() and MarkerStore.FLAG_CLUSTERABLE != 0 + + fun aliveHandles(): IntArray { + val handles = IntList(count.coerceAtLeast(1)) + for (handle in flags.indices) { + if (flags[handle].toInt() and MarkerStore.FLAG_ALIVE != 0) { + handles.add(handle) + } + } + return handles.toIntArray() + } +} + +/** + * The one native copy of a marker dataset, addressed by the integer handles JS + * assigns. + * + * Batches are decoded on a shared background thread in the order they arrive; + * readers (the map pipelines, on their compute threads or the UI thread) hold + * the lock for the duration of a query. Coordinates and flags are kept as flat + * arrays for the viewport and cluster loops; the full descriptor is only + * touched for the elements that end up on screen. + */ +class MarkerStore { + private val lock = Any() + private val listeners = CopyOnWriteArrayList>() + private val mainHandler by lazy { Handler(Looper.getMainLooper()) } + private val index = MarkerSpatialIndex() + private var latitudes = DoubleArray(0) + private var longitudes = DoubleArray(0) + private var flags = ByteArray(0) + private var descriptors = arrayOfNulls(0) + private var versions = LongArray(0) + @Volatile private var count = 0 + private var nextVersion = 1L + /** At most one listener notification is posted at a time. */ + private val notificationPending = AtomicBoolean(false) + + /** Lock-free: the map controllers read it on the main thread while a query runs. */ + val markerCount: Int + get() = count + + /** Rough resident size, reported to the JS garbage collector. */ + val estimatedBytes: Long + get() = synchronized(lock) { + flags.size.toLong() * (8 + 8 + 8 + 1) + count.toLong() * 400 + } + + // Listeners (main thread) + + internal fun addListener(listener: MarkerStoreListener) { + listeners.add(WeakReference(listener)) + } + + internal fun removeListener(listener: MarkerStoreListener) { + listeners.removeAll { it.get() === listener || it.get() == null } + } + + // Writes + + /** Applies an owned copy of a batch on the store thread, after every batch enqueued before it. */ + fun enqueue(bytes: ByteArray, strings: Array) { + executor.execute { + apply(bytes, strings) + notifyListeners() + } + } + + fun enqueueClear() { + executor.execute { + synchronized(lock) { removeAllLocked() } + notifyListeners() + } + } + + // Reads + + internal fun read(block: (MarkerStoreAccess) -> T): T = synchronized(lock) { + block(MarkerStoreAccess(latitudes, longitudes, flags, descriptors, versions, index, count)) + } + + internal fun ids(handles: IntArray): Array = read { access -> + val ids = ArrayList(handles.size) + for (handle in handles) { + if (handle >= 0 && handle < access.descriptors.size) { + access.descriptors[handle]?.let { ids.add(it.id) } + } + } + ids.toTypedArray() + } + + // Batch application + + /** Synchronous variant of [enqueue] for tests. */ + internal fun applyNow(bytes: ByteArray, strings: Array) = apply(bytes, strings) + + private fun apply(bytes: ByteArray, strings: Array) = traceSection("NitroMaps.applyMarkerBatch") { + synchronized(lock) { + // Copy on write, once per batch: readers keep the arrays they took under + // the lock and run their geometry on a consistent state while this batch + // mutates the copies. Three arrays of 17 bytes per handle, only when the + // dataset changes. + latitudes = latitudes.copyOf() + longitudes = longitudes.copyOf() + flags = flags.copyOf() + try { + MarkerBatchDecoder.decode( + MarkerBatchDecoder.wrap(bytes), + strings, + onRemove = { handle -> removeLocked(handle) }, + onUpsert = { handle, descriptor -> upsertLocked(handle, descriptor) }, + onPosition = { handle, latitude, longitude -> moveLocked(handle, latitude, longitude) }, + ) + } catch (error: RuntimeException) { + // The header was validated on the JS thread and the bytes are our own + // copy; anything that still fails here is a corrupt batch, which is + // dropped rather than taking the store thread with it. + return@synchronized + } + index.rebuildIfNeeded(latitudes, longitudes, flags) + } + } + + private fun upsertLocked(handle: Int, descriptor: MarkerDescriptor) { + // JS hands out handles densely, so a valid batch never asks for more than + // a bounded step past the current arrays; a corrupt one is dropped here + // instead of growing five arrays to whatever it says. + if (handle < 0 || handle >= MAX_HANDLE || handle > flags.size + MAX_HANDLE_STEP) { + return + } + ensureCapacityLocked(handle) + + val latitude = descriptor.coordinate.latitude + val longitude = descriptor.coordinate.longitude + if (flags[handle].toInt() and FLAG_ALIVE != 0) { + index.move(handle, latitude, longitude) + } else { + index.insert(handle, latitude, longitude) + count += 1 + } + + latitudes[handle] = latitude + longitudes[handle] = longitude + flags[handle] = (FLAG_ALIVE or (if (descriptor.clusterable == false) 0 else FLAG_CLUSTERABLE)).toByte() + descriptors[handle] = descriptor + versions[handle] = nextVersion + nextVersion += 1 + } + + private fun removeLocked(handle: Int) { + if (handle < 0 || handle >= flags.size || flags[handle].toInt() and FLAG_ALIVE == 0) { + return + } + index.remove(handle) + flags[handle] = 0 + descriptors[handle] = null + count -= 1 + } + + private fun moveLocked(handle: Int, latitude: Double, longitude: Double) { + if (handle < 0 || handle >= flags.size || flags[handle].toInt() and FLAG_ALIVE == 0) { + return + } + index.move(handle, latitude, longitude) + latitudes[handle] = latitude + longitudes[handle] = longitude + descriptors[handle] = descriptors[handle]?.copy(coordinate = Coordinate(latitude, longitude)) + versions[handle] = nextVersion + nextVersion += 1 + } + + private fun removeAllLocked() { + latitudes = DoubleArray(0) + longitudes = DoubleArray(0) + flags = ByteArray(0) + descriptors = arrayOfNulls(0) + versions = LongArray(0) + index.removeAll() + count = 0 + } + + private fun ensureCapacityLocked(handle: Int) { + if (handle < flags.size) { + return + } + val target = maxOf(handle + 1, flags.size * 2, 64) + latitudes = latitudes.copyOf(target) + longitudes = longitudes.copyOf(target) + flags = flags.copyOf(target) + descriptors = descriptors.copyOf(target) + versions = versions.copyOf(target) + } + + /** + * Delivers one notification per burst of batches: a stream of position + * updates does not queue one full diff per batch on the main thread. + */ + private fun notifyListeners() { + if (listeners.isEmpty() || !notificationPending.compareAndSet(false, true)) { + return + } + mainHandler.post { + notificationPending.set(false) + for (reference in listeners) { + reference.get()?.onMarkerStoreChanged(this) + } + } + } + + companion object { + const val FLAG_ALIVE: Int = 1 shl 0 + const val FLAG_CLUSTERABLE: Int = 1 shl 1 + + /** + * Handles at or above this are refused. Five dense arrays of this length + * are about 140 MB, the most a corrupt batch can make the store allocate. + */ + private const val MAX_HANDLE = 1 shl 22 + + /** How far past the current arrays one upsert may reach. */ + private const val MAX_HANDLE_STEP = 1 shl 16 + + /** One thread applies every collection's batches, in order per collection. */ + private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "NitroMaps.markerStore").apply { isDaemon = true } + } + } +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt index 036f881..9dd10bd 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt @@ -1,6 +1,5 @@ package com.margelo.nitro.nitromaps -import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import kotlin.math.ceil import kotlin.math.max @@ -12,55 +11,94 @@ internal object MarkerViewportFilter { * * The caller (spatial index) has already restricted [candidates] to cells * near the bounds, so this runs over a small set and is safe to call off the - * main thread. + * UI thread. Coordinates come from the store's flat arrays. */ fun displaySubset( - candidates: List, + candidates: IntArray, + latitudes: DoubleArray, + longitudes: DoubleArray, bounds: LatLngBounds, latitudeSpan: Double, - ): List { + ): IntArray { val maxCount = maxMarkersForZoom(latitudeSpan) - val paddedBounds = bounds.expandBy(0.2) + val latSpan = bounds.northeast.latitude - bounds.southwest.latitude + // Bounds that cross the antimeridian have northeast west of southwest. + val lngSpan = longitudeSpan(bounds) + val minLat = bounds.southwest.latitude - latSpan * 0.2 + val maxLat = bounds.northeast.latitude + latSpan * 0.2 + val minLon = wrapLongitude(bounds.southwest.longitude - lngSpan * 0.2) + val maxLon = wrapLongitude(bounds.northeast.longitude + lngSpan * 0.2) + val allLongitudes = lngSpan * 1.4 >= 360.0 - val visible = candidates.filter { descriptor -> - paddedBounds.contains( - LatLng(descriptor.coordinate.latitude, descriptor.coordinate.longitude), - ) + val visible = IntList(candidates.size.coerceAtLeast(1)) + for (handle in candidates) { + val lat = latitudes[handle] + val lon = longitudes[handle] + val lonInside = allLongitudes || + (if (minLon <= maxLon) lon in minLon..maxLon else lon >= minLon || lon <= maxLon) + if (lat >= minLat && lat <= maxLat && lonInside) { + visible.add(handle) + } } if (visible.size <= maxCount) { - return visible + return visible.toIntArray() } - return spatialSubsample(visible, maxCount, bounds).toList() + return spatialSubsample(visible, latitudes, longitudes, maxCount, bounds) } private fun spatialSubsample( - markers: List, + handles: IntList, + latitudes: DoubleArray, + longitudes: DoubleArray, maxCount: Int, bounds: LatLngBounds, - ): Array { + ): IntArray { val columns = ceil(sqrt(maxCount.toDouble())).toInt() val rows = ceil(maxCount.toDouble() / columns).toInt() val latMin = bounds.southwest.latitude val latMax = bounds.northeast.latitude val lonMin = bounds.southwest.longitude - val lonMax = bounds.northeast.longitude + val wraps = bounds.northeast.longitude < lonMin val latStep = max(1e-9, (latMax - latMin) / rows) - val lonStep = max(1e-9, (lonMax - lonMin) / columns) + val lonStep = max(1e-9, longitudeSpan(bounds) / columns) - val buckets = LinkedHashMap>() + val buckets = LinkedHashMap() + for (index in 0 until handles.size) { + val handle = handles[index] + val row = minOf(rows - 1, maxOf(0, ((latitudes[handle] - latMin) / latStep).toInt())) + // East of the antimeridian the offset from the western edge goes through 180. + var lonOffset = longitudes[handle] - lonMin + if (wraps && lonOffset < 0) { + lonOffset += 360.0 + } + val column = minOf(columns - 1, maxOf(0, (lonOffset / lonStep).toInt())) + buckets.getOrPut(row * columns + column) { IntList() }.add(handle) + } - for (marker in markers) { - val row = minOf(rows - 1, maxOf(0, ((marker.coordinate.latitude - latMin) / latStep).toInt())) - val column = minOf(columns - 1, maxOf(0, ((marker.coordinate.longitude - lonMin) / lonStep).toInt())) - val key = "$row-$column" - buckets.getOrPut(key) { mutableListOf() }.add(marker) + val result = IntArray(buckets.size) + var position = 0 + for (cell in buckets.values) { + result[position] = cell[cell.size / 2] + position += 1 } + return result + } + + /** Width of the bounds in degrees, going the short way round the antimeridian. */ + private fun longitudeSpan(bounds: LatLngBounds): Double { + val raw = bounds.northeast.longitude - bounds.southwest.longitude + return if (raw < 0) raw + 360.0 else raw + } - return buckets.values.map { cell -> cell[cell.size / 2] }.toTypedArray() + private fun wrapLongitude(lon: Double): Double { + var wrapped = lon + while (wrapped > 180.0) wrapped -= 360.0 + while (wrapped < -180.0) wrapped += 360.0 + return wrapped } private fun maxMarkersForZoom(latitudeSpan: Double): Int { @@ -71,16 +109,4 @@ internal object MarkerViewportFilter { else -> 200 } } - - private fun LatLngBounds.expandBy(fraction: Double): LatLngBounds { - val latSpan = northeast.latitude - southwest.latitude - val lngSpan = northeast.longitude - southwest.longitude - val latPad = latSpan * fraction - val lngPad = lngSpan * fraction - - return LatLngBounds( - LatLng(southwest.latitude - latPad, southwest.longitude - lngPad), - LatLng(northeast.latitude + latPad, northeast.longitude + lngPad), - ) - } } diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerBatchDecoderTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerBatchDecoderTest.kt new file mode 100644 index 0000000..a64d5c5 --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerBatchDecoderTest.kt @@ -0,0 +1,187 @@ +package com.margelo.nitro.nitromaps + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkerBatchDecoderTest { + @Test + fun `decodes the marker colour and z-index`() { + val builder = MarkerBatchBuilder() + .upsert(handle = 1, id = "tinted", latitude = 1.0, longitude = 2.0, markerColor = "#FF9500", zIndex = 3f) + .upsert(handle = 2, id = "plain", latitude = 1.0, longitude = 2.0) + val decoded = ArrayList() + MarkerBatchDecoder.decode( + MarkerBatchDecoder.wrap(builder.bytes()), + builder.strings(), + onRemove = {}, + onUpsert = { _, descriptor -> decoded.add(descriptor) }, + onPosition = { _, _, _ -> }, + ) + + assertEquals("#FF9500", decoded[0].markerColor) + assertEquals(3.0, decoded[0].zIndex) + assertNull(decoded[1].markerColor) + assertNull(decoded[1].zIndex) + } + + @Test + fun `a header whose byte count overflows Int is rejected`() { + // 44739243 * 96 wraps to 32 in Int arithmetic, which would make a 48-byte + // batch pass the length check with 44.7 million declared upserts. + val bytes = ByteArray(48) + java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN) + .putInt(0, MarkerBatchLayout.MAGIC) + .putInt(4, 44_739_243) + .putInt(8, 0) + .putInt(12, 0) + + assertThrows(MalformedMarkerBatchException::class.java) { + MarkerBatchDecoder.readHeader(MarkerBatchDecoder.wrap(bytes)) + } + } + + private fun decodeAll(builder: MarkerBatchBuilder): List { + val events = ArrayList() + MarkerBatchDecoder.decode( + MarkerBatchDecoder.wrap(builder.bytes()), + builder.strings(), + onRemove = { events.add("remove $it") }, + onUpsert = { handle, descriptor -> events.add("upsert $handle ${descriptor.id}") }, + onPosition = { handle, lat, lon -> events.add("position $handle $lat $lon") }, + ) + return events + } + + @Test + fun `decodes every field of a full record`() { + val builder = MarkerBatchBuilder().upsert( + handle = 7, + id = "full", + latitude = 52.2297, + longitude = 21.0122, + title = "Warsaw", + subtitle = "Capital", + imageUri = "https://example.com/pin.png", + imageWidth = 32f, + imageHeight = 40f, + imageScale = 2f, + anchor = 0.5f to 1f, + centerOffset = 4f to -8f, + rotation = 45f, + opacity = 0.75f, + draggable = true, + clusterable = false, + flat = true, + animationKind = 4, + animationDuration = 180f, + animationDelay = 20f, + animationReduceMotion = 2, + ) + + var decoded: MarkerDescriptor? = null + MarkerBatchDecoder.decode( + MarkerBatchDecoder.wrap(builder.bytes()), + builder.strings(), + onRemove = {}, + onUpsert = { _, descriptor -> decoded = descriptor }, + onPosition = { _, _, _ -> }, + ) + + val descriptor = requireNotNull(decoded) + assertEquals("full", descriptor.id) + assertEquals(52.2297, descriptor.coordinate.latitude, 0.0) + assertEquals(21.0122, descriptor.coordinate.longitude, 0.0) + assertEquals("Warsaw", descriptor.title) + assertEquals("Capital", descriptor.subtitle) + assertEquals(true, descriptor.draggable) + assertEquals(false, descriptor.clusterable) + assertEquals(MarkerImage("https://example.com/pin.png", 32.0, 40.0, 2.0), descriptor.image) + assertEquals(MarkerAnchor(0.5, 1.0), descriptor.anchor) + assertEquals(MarkerPoint(4.0, -8.0), descriptor.centerOffset) + assertEquals(45.0, requireNotNull(descriptor.rotation), 0.0) + assertEquals(true, descriptor.flat) + assertEquals(0.75, requireNotNull(descriptor.opacity), 1e-6) + assertEquals( + OverlayEnteringAnimationDescriptor( + OverlayEnteringAnimationKind.FADE_SCALE, + 180.0, + 20.0, + OverlayEnteringAnimationReduceMotion.NEVER, + ), + descriptor.enteringAnimation, + ) + } + + @Test + fun `keeps absent optionals absent`() { + val builder = MarkerBatchBuilder().upsert(handle = 0, id = "minimal", latitude = 1.0, longitude = 2.0) + + var decoded: MarkerDescriptor? = null + MarkerBatchDecoder.decode( + MarkerBatchDecoder.wrap(builder.bytes()), + builder.strings(), + onRemove = {}, + onUpsert = { _, descriptor -> decoded = descriptor }, + onPosition = { _, _, _ -> }, + ) + + val descriptor = requireNotNull(decoded) + assertNull(descriptor.title) + assertNull(descriptor.subtitle) + assertNull(descriptor.image) + assertNull(descriptor.anchor) + assertNull(descriptor.centerOffset) + assertNull(descriptor.rotation) + assertNull(descriptor.opacity) + assertNull(descriptor.enteringAnimation) + assertNull(descriptor.draggable) + assertNull(descriptor.clusterable) + assertNull(descriptor.flat) + } + + @Test + fun `applies removals before upserts and positions last`() { + val builder = MarkerBatchBuilder() + .upsert(handle = 3, id = "c", latitude = 0.0, longitude = 0.0) + .position(handle = 5, latitude = 1.5, longitude = 2.5) + .remove(3) + .upsert(handle = 4, id = "d", latitude = 0.0, longitude = 0.0) + + assertEquals( + listOf("remove 3", "upsert 3 c", "upsert 4 d", "position 5 1.5 2.5"), + decodeAll(builder), + ) + } + + @Test + fun `skips records whose id is out of the string table`() { + val builder = MarkerBatchBuilder().upsert(handle = 0, id = "only", latitude = 0.0, longitude = 0.0) + val events = ArrayList() + MarkerBatchDecoder.decode( + MarkerBatchDecoder.wrap(builder.bytes()), + emptyArray(), + onRemove = {}, + onUpsert = { handle, _ -> events.add("upsert $handle") }, + onPosition = { _, _, _ -> }, + ) + assertTrue(events.isEmpty()) + } + + @Test + fun `rejects a wrong magic and a wrong length`() { + val bytes = MarkerBatchBuilder().upsert(handle = 0, id = "a", latitude = 0.0, longitude = 0.0).bytes() + bytes[0] = 0 + assertThrows(MalformedMarkerBatchException::class.java) { + MarkerBatchDecoder.readHeader(MarkerBatchDecoder.wrap(bytes)) + } + + val truncated = MarkerBatchBuilder().upsert(handle = 0, id = "a", latitude = 0.0, longitude = 0.0) + .bytes().copyOf(MarkerBatchLayout.HEADER_BYTES + 10) + assertThrows(MalformedMarkerBatchException::class.java) { + MarkerBatchDecoder.readHeader(MarkerBatchDecoder.wrap(truncated)) + } + } +} diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerBatchFixture.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerBatchFixture.kt new file mode 100644 index 0000000..8fd0c86 --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerBatchFixture.kt @@ -0,0 +1,161 @@ +package com.margelo.nitro.nitromaps + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** Builds packed batches the way `markerBatch.ts` does, for decoder and store tests. */ +internal class MarkerBatchBuilder { + private class Upsert( + val handle: Int, + val id: String, + val latitude: Double, + val longitude: Double, + val title: String?, + val subtitle: String?, + val imageUri: String?, + val imageWidth: Float, + val imageHeight: Float, + val imageScale: Float, + val anchor: Pair?, + val centerOffset: Pair?, + val rotation: Float, + val opacity: Float, + val draggable: Boolean, + val clusterable: Boolean, + val flat: Boolean, + val animationKind: Int, + val animationDuration: Float, + val animationDelay: Float, + val animationReduceMotion: Int, + val markerColor: String?, + val zIndex: Float, + ) + + private val upserts = ArrayList() + private val removes = ArrayList() + private val positions = ArrayList>() + private val strings = ArrayList() + private val stringIndices = HashMap() + + fun upsert( + handle: Int, + id: String, + latitude: Double, + longitude: Double, + title: String? = null, + subtitle: String? = null, + imageUri: String? = null, + imageWidth: Float = Float.NaN, + imageHeight: Float = Float.NaN, + imageScale: Float = Float.NaN, + anchor: Pair? = null, + centerOffset: Pair? = null, + rotation: Float = Float.NaN, + opacity: Float = Float.NaN, + draggable: Boolean = false, + clusterable: Boolean = true, + flat: Boolean = false, + animationKind: Int = 0, + animationDuration: Float = Float.NaN, + animationDelay: Float = Float.NaN, + animationReduceMotion: Int = 0, + markerColor: String? = null, + zIndex: Float = Float.NaN, + ): MarkerBatchBuilder { + upserts.add( + Upsert( + handle, id, latitude, longitude, title, subtitle, imageUri, imageWidth, imageHeight, imageScale, + anchor, centerOffset, rotation, opacity, draggable, clusterable, flat, + animationKind, animationDuration, animationDelay, animationReduceMotion, + markerColor, zIndex, + ), + ) + return this + } + + fun remove(handle: Int): MarkerBatchBuilder { + removes.add(handle) + return this + } + + fun position(handle: Int, latitude: Double, longitude: Double): MarkerBatchBuilder { + positions.add(Triple(handle, latitude, longitude)) + return this + } + + fun strings(): Array = strings.toTypedArray() + + fun bytes(): ByteArray { + val total = MarkerBatchLayout.HEADER_BYTES + + upserts.size * MarkerBatchLayout.UPSERT_BYTES + + removes.size * MarkerBatchLayout.REMOVE_BYTES + + positions.size * MarkerBatchLayout.POSITION_BYTES + val buffer = ByteBuffer.allocate(total).order(ByteOrder.LITTLE_ENDIAN) + buffer.putInt(0, MarkerBatchLayout.MAGIC) + buffer.putInt(4, upserts.size) + buffer.putInt(8, removes.size) + buffer.putInt(12, positions.size) + + var offset = MarkerBatchLayout.HEADER_BYTES + for (upsert in upserts) { + writeUpsert(buffer, offset, upsert) + offset += MarkerBatchLayout.UPSERT_BYTES + } + for (handle in removes) { + buffer.putInt(offset, handle) + offset += MarkerBatchLayout.REMOVE_BYTES + } + for ((handle, latitude, longitude) in positions) { + buffer.putInt(offset, handle) + buffer.putInt(offset + 4, 0) + buffer.putDouble(offset + 8, latitude) + buffer.putDouble(offset + 16, longitude) + offset += MarkerBatchLayout.POSITION_BYTES + } + return buffer.array() + } + + private fun writeUpsert(buffer: ByteBuffer, base: Int, upsert: Upsert) { + var flags = 0 + if (upsert.anchor != null) flags = flags or MarkerBatchLayout.HAS_ANCHOR + if (upsert.centerOffset != null) flags = flags or MarkerBatchLayout.HAS_CENTER_OFFSET + if (upsert.draggable) flags = flags or MarkerBatchLayout.DRAGGABLE + if (upsert.clusterable) flags = flags or MarkerBatchLayout.CLUSTERABLE + if (upsert.flat) flags = flags or MarkerBatchLayout.FLAT + + val field = MarkerBatchLayout.Upsert + buffer.putInt(base + field.HANDLE, upsert.handle) + buffer.putInt(base + field.FLAGS, flags) + buffer.putInt(base + field.ID, intern(upsert.id)) + buffer.putInt(base + field.TITLE, internOptional(upsert.title)) + buffer.putInt(base + field.SUBTITLE, internOptional(upsert.subtitle)) + buffer.putInt(base + field.IMAGE_URI, internOptional(upsert.imageUri)) + buffer.putDouble(base + field.LATITUDE, upsert.latitude) + buffer.putDouble(base + field.LONGITUDE, upsert.longitude) + buffer.putFloat(base + field.IMAGE_WIDTH, upsert.imageWidth) + buffer.putFloat(base + field.IMAGE_HEIGHT, upsert.imageHeight) + buffer.putFloat(base + field.IMAGE_SCALE, upsert.imageScale) + buffer.putFloat(base + field.ANCHOR_X, upsert.anchor?.first ?: 0f) + buffer.putFloat(base + field.ANCHOR_Y, upsert.anchor?.second ?: 0f) + buffer.putFloat(base + field.CENTER_OFFSET_X, upsert.centerOffset?.first ?: 0f) + buffer.putFloat(base + field.CENTER_OFFSET_Y, upsert.centerOffset?.second ?: 0f) + buffer.putFloat(base + field.ROTATION, upsert.rotation) + buffer.putFloat(base + field.OPACITY, upsert.opacity) + buffer.putFloat(base + field.ANIMATION_DURATION, upsert.animationDuration) + buffer.putFloat(base + field.ANIMATION_DELAY, upsert.animationDelay) + buffer.put(base + field.ANIMATION_KIND, upsert.animationKind.toByte()) + buffer.put(base + field.ANIMATION_REDUCE_MOTION, upsert.animationReduceMotion.toByte()) + buffer.putInt(base + field.MARKER_COLOR, internOptional(upsert.markerColor)) + buffer.putFloat(base + field.Z_INDEX, upsert.zIndex) + } + + private fun intern(value: String): Int { + return stringIndices.getOrPut(value) { + strings.add(value) + strings.size - 1 + } + } + + private fun internOptional(value: String?): Int = + if (value == null) MarkerBatchLayout.NO_STRING else intern(value) +} diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt deleted file mode 100644 index ac15b88..0000000 --- a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.margelo.nitro.nitromaps - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class MarkerDisplayedIdentityTest { - @Test - fun `visual field changes update displayed identity`() { - val pairs = listOf( - "image" to ( - marker(image = MarkerImage("asset:/pin.png", 32.0, 32.0, 2.0)) to - marker(image = MarkerImage("asset:/pin-alt.png", 32.0, 32.0, 2.0)) - ), - "rotation" to (marker(rotation = 0.0) to marker(rotation = 45.0)), - "opacity" to (marker(opacity = 1.0) to marker(opacity = 0.4)), - "markerColor" to (marker(markerColor = "#FF0000") to marker(markerColor = "#00FF00")), - "zIndex" to (marker(zIndex = 1.0) to marker(zIndex = 2.0)), - "anchor" to ( - marker(anchor = MarkerAnchor(0.5, 1.0)) to marker(anchor = MarkerAnchor(0.5, 0.5)) - ), - "centerOffset" to ( - marker(centerOffset = MarkerPoint(0.0, 0.0)) to - marker(centerOffset = MarkerPoint(4.0, -8.0)) - ), - "flat" to (marker(flat = false) to marker(flat = true)), - ) - - for ((field, pair) in pairs) { - val (before, after) = pair - assertNotEquals(field, before.displayedIdentityVersion(), after.displayedIdentityVersion()) - } - } - - @Test - fun `absent fields are distinguishable from zero valued ones`() { - assertNotEquals( - "opacity", - marker(opacity = null).displayedIdentityVersion(), - marker(opacity = 0.0).displayedIdentityVersion(), - ) - assertNotEquals( - "anchor", - marker(anchor = null).displayedIdentityVersion(), - marker(anchor = MarkerAnchor(0.0, 0.0)).displayedIdentityVersion(), - ) - assertNotEquals( - "opacity fingerprint", - arrayOf(marker(opacity = null)).markersFingerprint(), - arrayOf(marker(opacity = 0.0)).markersFingerprint(), - ) - assertNotEquals( - "zIndex", - marker(zIndex = null).displayedIdentityVersion(), - marker(zIndex = 0.0).displayedIdentityVersion(), - ) - } - - @Test - fun `entering animation change does not update displayed identity`() { - val before = marker( - enteringAnimation = OverlayEnteringAnimationDescriptor( - OverlayEnteringAnimationKind.FADE, - 200.0, - 0.0, - OverlayEnteringAnimationReduceMotion.SYSTEM, - ), - ) - val after = marker( - enteringAnimation = OverlayEnteringAnimationDescriptor( - OverlayEnteringAnimationKind.NONE, - 400.0, - 50.0, - OverlayEnteringAnimationReduceMotion.NEVER, - ), - ) - - assertEquals(before.displayedIdentityVersion(), after.displayedIdentityVersion()) - assertNotEquals(before.fingerprint(), after.fingerprint()) - } - - @Test - fun `displayed identity change reaches retained list`() { - val displayed = ClusterElement.Single(marker(opacity = 1.0)) - val next = ClusterElement.Single(marker(opacity = 0.2)) - val diff = computeMarkerRenderDiff( - listOf(next), - mapOf(displayed.diffKey to displayed.renderVersion), - ) - - assertEquals(listOf(next), diff.retained) - } - - @Test - fun `entering animation change does not reach retained list`() { - val displayed = ClusterElement.Single( - marker( - enteringAnimation = OverlayEnteringAnimationDescriptor( - OverlayEnteringAnimationKind.FADE, - 200.0, - null, - null, - ), - ), - ) - val next = ClusterElement.Single( - marker( - enteringAnimation = OverlayEnteringAnimationDescriptor( - OverlayEnteringAnimationKind.NONE, - 400.0, - null, - null, - ), - ), - ) - val diff = computeMarkerRenderDiff( - listOf(next), - mapOf(displayed.diffKey to displayed.renderVersion), - ) - - assertTrue(diff.retained.isEmpty()) - assertTrue(diff.added.isEmpty()) - assertTrue(diff.removedKeys.isEmpty()) - } -} diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerRenderDiffTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerRenderDiffTest.kt index 916e577..cec4054 100644 --- a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerRenderDiffTest.kt +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerRenderDiffTest.kt @@ -1,41 +1,60 @@ package com.margelo.nitro.nitromaps +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test class MarkerRenderDiffTest { + private fun single(id: String, handle: Int, version: Long = 1L) = + ClusterElement.Single(handle, marker(id = id), version) + + private fun cluster( + count: Int, + latitude: Double = 52.0, + memberHandles: IntArray = intArrayOf(1, 2, 3), + ) = ClusterElement.Cluster( + id = "3:4", + position = LatLng(latitude, 21.0), + count = count, + memberHandles = memberHandles, + bounds = LatLngBounds(LatLng(51.0, 20.0), LatLng(53.0, 22.0)), + ) + @Test fun `new keys are added`() { - val first = ClusterElement.Single(marker(id = "a")) - val second = ClusterElement.Single(marker(id = "b")) + val first = single("a", 0) + val second = single("b", 1) val diff = computeMarkerRenderDiff(listOf(first, second), emptyMap()) - assertEquals(emptySet(), diff.removedKeys) + assertEquals(emptySet(), diff.removedKeys) assertEquals(listOf(first, second), diff.added) assertTrue(diff.retained.isEmpty()) } @Test fun `missing keys are removed`() { - val kept = ClusterElement.Single(marker(id = "a")) + val kept = single("a", 0) + val gone = MarkerRenderKey.Single(1, "gone") val diff = computeMarkerRenderDiff( listOf(kept), - mapOf("s:a" to kept.renderVersion, "s:gone" to 9L), + mapOf(kept.key to kept.renderVersion, gone to 9L), ) - assertEquals(setOf("s:gone"), diff.removedKeys) + assertEquals(setOf(gone), diff.removedKeys) assertTrue(diff.added.isEmpty()) assertTrue(diff.retained.isEmpty()) } @Test fun `version change marks retained`() { - val displayed = ClusterElement.Single(marker(id = "a", opacity = 1.0)) - val next = ClusterElement.Single(marker(id = "a", opacity = 0.2)) + val displayed = single("a", 0, version = 1L) + val next = single("a", 0, version = 2L) val diff = computeMarkerRenderDiff( listOf(next), - mapOf(displayed.diffKey to displayed.renderVersion), + mapOf(displayed.key to displayed.renderVersion), ) assertTrue(diff.removedKeys.isEmpty()) @@ -45,10 +64,10 @@ class MarkerRenderDiffTest { @Test fun `unchanged version is skipped`() { - val element = ClusterElement.Single(marker(id = "a")) + val element = single("a", 0) val diff = computeMarkerRenderDiff( listOf(element), - mapOf(element.diffKey to element.renderVersion), + mapOf(element.key to element.renderVersion), ) assertTrue(diff.removedKeys.isEmpty()) @@ -58,10 +77,31 @@ class MarkerRenderDiffTest { @Test fun `duplicate keys keep the first element`() { - val first = ClusterElement.Single(marker(id = "a", opacity = 1.0)) - val duplicate = ClusterElement.Single(marker(id = "a", opacity = 0.1)) + val first = single("a", 0, version = 1L) + val duplicate = single("a", 0, version = 2L) val diff = computeMarkerRenderDiff(listOf(first, duplicate), emptyMap()) assertEquals(listOf(first), diff.added) } + + @Test + fun `a reused handle with a new id is a different element`() { + val previous = single("a", 0, version = 1L) + val next = single("b", 0, version = 2L) + val diff = computeMarkerRenderDiff(listOf(next), mapOf(previous.key to previous.renderVersion)) + + assertEquals(setOf(previous.key), diff.removedKeys) + assertEquals(listOf(next), diff.added) + assertTrue(diff.retained.isEmpty()) + } + + @Test + fun `cluster version follows count and position, not members`() { + val base = cluster(count = 3) + assertEquals(base.renderVersion, cluster(count = 3).renderVersion) + assertNotEquals(base.renderVersion, cluster(count = 4).renderVersion) + assertNotEquals(base.renderVersion, cluster(count = 3, latitude = 52.5).renderVersion) + assertEquals(base.renderVersion, cluster(count = 3, memberHandles = intArrayOf(7, 8, 9)).renderVersion) + assertEquals(MarkerRenderKey.Cluster("3:4"), base.key) + } } diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerSpatialIndexTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerSpatialIndexTest.kt new file mode 100644 index 0000000..578a7f2 --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerSpatialIndexTest.kt @@ -0,0 +1,116 @@ +package com.margelo.nitro.nitromaps + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class MarkerSpatialIndexTest { + private val alive = MarkerStore.FLAG_ALIVE.toByte() + + private fun bounds(minLat: Double, minLon: Double, maxLat: Double, maxLon: Double) = + LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon)) + + @Test + fun `queries find handles by cell after a rebuild`() { + val index = MarkerSpatialIndex(cellsPerSide = 8) + val latitudes = doubleArrayOf(52.0, 50.0, 54.0) + val longitudes = doubleArrayOf(21.0, 19.0, 18.0) + val flags = ByteArray(3) { alive } + for (handle in 0 until 3) { + index.insert(handle, latitudes[handle], longitudes[handle]) + } + index.rebuildIfNeeded(latitudes, longitudes, flags) + + assertEquals(3, index.count) + assertArrayEquals(intArrayOf(0), index.candidates(bounds(51.9, 20.9, 52.1, 21.1), padding = 0.0).sortedArray()) + assertArrayEquals(intArrayOf(0, 1, 2), index.candidates(bounds(49.0, 17.0, 55.0, 22.0)).sortedArray()) + assertArrayEquals(intArrayOf(), index.candidates(bounds(10.0, 10.0, 11.0, 11.0))) + } + + @Test + fun `moving inside the bounds needs no rebuild`() { + val index = MarkerSpatialIndex(cellsPerSide = 8) + val latitudes = doubleArrayOf(52.0, 50.0) + val longitudes = doubleArrayOf(21.0, 19.0) + val flags = ByteArray(2) { alive } + index.insert(0, 52.0, 21.0) + index.insert(1, 50.0, 19.0) + index.rebuildIfNeeded(latitudes, longitudes, flags) + + index.move(0, 50.1, 19.1) + // A rebuild with the stale arrays would put handle 0 back at 52.0/21.0; + // an in-place move must not have flagged one. + index.rebuildIfNeeded(latitudes, longitudes, flags) + assertArrayEquals(intArrayOf(0, 1), index.candidates(bounds(49.9, 18.9, 50.2, 19.2), padding = 0.0).sortedArray()) + assertArrayEquals(intArrayOf(), index.candidates(bounds(51.9, 20.9, 52.1, 21.1), padding = 0.0)) + } + + @Test + fun `queries beside the dataset in longitude find nothing`() { + val index = MarkerSpatialIndex(cellsPerSide = 8) + val latitudes = doubleArrayOf(52.0, 50.0) + val longitudes = doubleArrayOf(21.0, 19.0) + val flags = ByteArray(2) { alive } + index.insert(0, 52.0, 21.0) + index.insert(1, 50.0, 19.0) + index.rebuildIfNeeded(latitudes, longitudes, flags) + + assertArrayEquals(intArrayOf(), index.candidates(bounds(49.0, 100.0, 53.0, 110.0), padding = 0.0)) + assertArrayEquals(intArrayOf(), index.candidates(bounds(49.0, -60.0, 53.0, -50.0), padding = 0.0)) + assertArrayEquals(intArrayOf(0, 1), index.candidates(bounds(49.0, 18.0, 53.0, 22.0), padding = 0.0).sortedArray()) + } + + @Test + fun `removeAll empties the index`() { + val index = MarkerSpatialIndex(cellsPerSide = 8) + val latitudes = doubleArrayOf(52.0, 50.0) + val longitudes = doubleArrayOf(21.0, 19.0) + val flags = ByteArray(2) { alive } + index.insert(0, 52.0, 21.0) + index.insert(1, 50.0, 19.0) + index.rebuildIfNeeded(latitudes, longitudes, flags) + + index.removeAll() + + assertEquals(0, index.count) + assertArrayEquals(intArrayOf(), index.candidates(bounds(49.0, 18.0, 53.0, 22.0))) + } + + @Test + fun `leaving the bounds is fixed by the next rebuild`() { + val index = MarkerSpatialIndex(cellsPerSide = 8) + val latitudes = doubleArrayOf(52.0, 50.0) + val longitudes = doubleArrayOf(21.0, 19.0) + val flags = ByteArray(2) { alive } + index.insert(0, 52.0, 21.0) + index.insert(1, 50.0, 19.0) + index.rebuildIfNeeded(latitudes, longitudes, flags) + + latitudes[0] = 10.0 + longitudes[0] = 10.0 + index.move(0, 10.0, 10.0) + index.rebuildIfNeeded(latitudes, longitudes, flags) + + assertArrayEquals(intArrayOf(0), index.candidates(bounds(9.9, 9.9, 10.1, 10.1), padding = 0.0)) + assertArrayEquals(intArrayOf(1), index.candidates(bounds(49.9, 18.9, 50.1, 19.1), padding = 0.0)) + } + + @Test + fun `removing a handle drops it from queries`() { + val index = MarkerSpatialIndex(cellsPerSide = 8) + val latitudes = doubleArrayOf(52.0, 52.0) + val longitudes = doubleArrayOf(21.0, 21.0) + val flags = ByteArray(2) { alive } + index.insert(0, 52.0, 21.0) + index.insert(1, 52.0, 21.0) + index.rebuildIfNeeded(latitudes, longitudes, flags) + + index.remove(0) + index.remove(0) + + assertEquals(1, index.count) + assertArrayEquals(intArrayOf(1), index.candidates(bounds(51.0, 20.0, 53.0, 22.0))) + } +} diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerStoreTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerStoreTest.kt new file mode 100644 index 0000000..ecfdc18 --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerStoreTest.kt @@ -0,0 +1,141 @@ +package com.margelo.nitro.nitromaps + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkerStoreTest { + private fun MarkerStore.apply(builder: MarkerBatchBuilder) = applyNow(builder.bytes(), builder.strings()) + + private val warsaw = LatLngBounds(LatLng(52.1, 20.9), LatLng(52.4, 21.2)) + + @Test + fun `upserts populate the arrays and the index`() { + val store = MarkerStore() + store.apply( + MarkerBatchBuilder() + .upsert(handle = 0, id = "a", latitude = 52.23, longitude = 21.01, title = "A") + .upsert(handle = 1, id = "b", latitude = 50.06, longitude = 19.94), + ) + + assertEquals(2, store.markerCount) + store.read { access -> + assertEquals(2, access.count) + assertEquals("A", access.descriptors[0]?.title) + assertEquals(52.23, access.latitudes[0], 0.0) + assertEquals(19.94, access.longitudes[1], 0.0) + assertTrue(access.isAlive(0)) + assertTrue(access.isClusterable(1)) + assertArrayEquals(intArrayOf(0), access.index.candidates(warsaw)) + assertArrayEquals(intArrayOf(0, 1), access.aliveHandles()) + } + assertArrayEquals(arrayOf("b", "a"), store.ids(intArrayOf(1, 0, 9))) + } + + @Test + fun `arrays taken from a read stay consistent while a batch is applied`() { + val store = MarkerStore() + store.apply(MarkerBatchBuilder().upsert(handle = 0, id = "a", latitude = 52.23, longitude = 21.01)) + val (latitudes, longitudes, flags) = store.read { access -> + Triple(access.latitudes, access.longitudes, access.flags) + } + + store.apply( + MarkerBatchBuilder() + .position(handle = 0, latitude = 50.06, longitude = 19.94) + .upsert(handle = 1, id = "b", latitude = 54.35, longitude = 18.65), + ) + + // The snapshot still says what it said; the store has moved on. + assertEquals(52.23, latitudes[0], 0.0) + assertEquals(21.01, longitudes[0], 0.0) + assertEquals(MarkerStore.FLAG_ALIVE or MarkerStore.FLAG_CLUSTERABLE, flags[0].toInt()) + store.read { access -> + assertEquals(50.06, access.latitudes[0], 0.0) + assertEquals(19.94, access.longitudes[0], 0.0) + assertTrue(access.isAlive(1)) + } + } + + @Test + fun `positions move markers in the index and bump their version`() { + val store = MarkerStore() + store.apply(MarkerBatchBuilder().upsert(handle = 0, id = "a", latitude = 50.06, longitude = 19.94)) + val versionBefore = store.read { it.versions[0] } + + store.apply(MarkerBatchBuilder().position(handle = 0, latitude = 52.23, longitude = 21.01)) + + store.read { access -> + assertEquals(Coordinate(52.23, 21.01), access.descriptors[0]?.coordinate) + assertEquals(52.23, access.latitudes[0], 0.0) + assertNotEquals(versionBefore, access.versions[0]) + assertArrayEquals(intArrayOf(0), access.index.candidates(warsaw)) + } + } + + @Test + fun `removals free the handle and a reuse in the same batch survives`() { + val store = MarkerStore() + store.apply( + MarkerBatchBuilder() + .upsert(handle = 0, id = "a", latitude = 52.23, longitude = 21.01) + .upsert(handle = 1, id = "b", latitude = 52.24, longitude = 21.02), + ) + store.apply( + MarkerBatchBuilder() + .upsert(handle = 0, id = "c", latitude = 52.25, longitude = 21.03) + .remove(0), + ) + + assertEquals(2, store.markerCount) + store.read { access -> + assertEquals("c", access.descriptors[0]?.id) + assertEquals("b", access.descriptors[1]?.id) + } + + store.apply(MarkerBatchBuilder().remove(1).remove(42)) + assertEquals(1, store.markerCount) + store.read { access -> + assertNull(access.descriptors[1]) + assertArrayEquals(intArrayOf(0), access.aliveHandles()) + assertArrayEquals(intArrayOf(0), access.index.candidates(warsaw)) + } + } + + @Test + fun `positions for unknown handles are ignored`() { + val store = MarkerStore() + store.apply(MarkerBatchBuilder().position(handle = 3, latitude = 1.0, longitude = 2.0)) + assertEquals(0, store.markerCount) + } + + @Test + fun `clusterable flag follows the descriptor`() { + val store = MarkerStore() + store.apply( + MarkerBatchBuilder() + .upsert(handle = 0, id = "a", latitude = 0.0, longitude = 0.0, clusterable = false) + .upsert(handle = 1, id = "b", latitude = 0.0, longitude = 0.0), + ) + store.read { access -> + assertTrue(!access.isClusterable(0)) + assertTrue(access.isClusterable(1)) + } + } + + @Test + fun `handles far apart grow the arrays sparsely`() { + val store = MarkerStore() + store.apply(MarkerBatchBuilder().upsert(handle = 1000, id = "far", latitude = 1.0, longitude = 1.0)) + assertEquals(1, store.markerCount) + store.read { access -> + assertTrue(access.flags.size > 1000) + assertArrayEquals(intArrayOf(1000), access.aliveHandles()) + } + } +} diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerViewportFilterTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerViewportFilterTest.kt new file mode 100644 index 0000000..abef562 --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerViewportFilterTest.kt @@ -0,0 +1,64 @@ +package com.margelo.nitro.nitromaps + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class MarkerViewportFilterTest { + private fun bounds(minLat: Double, minLon: Double, maxLat: Double, maxLon: Double) = + LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon)) + + @Test + fun `keeps markers inside the padded bounds`() { + val latitudes = doubleArrayOf(52.0, 52.0, 52.0) + // The padding is a fifth of the span: 0.1 degrees here, so 21.05 is in and 21.15 is out. + val longitudes = doubleArrayOf(21.0, 21.05, 21.15) + val visible = MarkerViewportFilter.displaySubset( + candidates = intArrayOf(0, 1, 2), + latitudes = latitudes, + longitudes = longitudes, + bounds = bounds(51.5, 20.5, 52.5, 21.0), + latitudeSpan = 1.0, + ) + + assertArrayEquals(intArrayOf(0, 1), visible.sortedArray()) + } + + @Test + fun `bounds across the antimeridian keep markers on both sides`() { + val latitudes = doubleArrayOf(0.0, 0.0, 0.0) + val longitudes = doubleArrayOf(175.0, -175.0, 0.0) + val visible = MarkerViewportFilter.displaySubset( + candidates = intArrayOf(0, 1, 2), + latitudes = latitudes, + longitudes = longitudes, + bounds = bounds(-5.0, 170.0, 5.0, -170.0), + latitudeSpan = 10.0, + ) + + assertArrayEquals(intArrayOf(0, 1), visible.sortedArray()) + } + + @Test + fun `subsampling across the antimeridian spreads over the columns`() { + // 3,000 markers in a 20-degree window centred on the antimeridian; the + // country-level cap is 200, so the subsample keeps about one per cell. + val count = 3_000 + val latitudes = DoubleArray(count) { -4.0 + 8.0 * (it % 60) / 60.0 } + val longitudes = DoubleArray(count) { 170.0 + 20.0 * (it / 60) / 50.0 }.map { if (it > 180.0) it - 360.0 else it }.toDoubleArray() + val visible = MarkerViewportFilter.displaySubset( + candidates = IntArray(count) { it }, + latitudes = latitudes, + longitudes = longitudes, + bounds = bounds(-5.0, 170.0, 5.0, -170.0), + latitudeSpan = 10.0, + ) + + val east = visible.count { longitudes[it] > 0 } + val west = visible.count { longitudes[it] < 0 } + assertEquals(true, visible.size in 150..200) + assertEquals(true, east > 50 && west > 50) + } +} diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index fb73d5b..3123b4d 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -164,9 +164,9 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } var onLongPress: ((Coordinate) -> Void)? - var markers: [MarkerDescriptor]? { + var markerCollection: HybridMarkerCollection? { didSet { - overlayController.setMarkers(markers) + overlayController.attach(store: markerCollection?.store) } } @@ -193,7 +193,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { var onPolylinePress: ((String) -> Void)? var onPolygonPress: ((String) -> Void)? var onCirclePress: ((String) -> Void)? - var onClusterPress: (([String], Coordinate) -> Void)? + var onClusterPress: ((NativeClusterPressEvent) -> Void)? func fetchCamera() throws -> Promise { Promise.resolved(withResult: view.camera.toCamera()) @@ -242,6 +242,10 @@ final class AppleMapProviderAdapter: MapProviderAdapter { ) } + func getClusterMembers(clusterId: String) throws -> Promise<[String]> { + Promise.resolved(withResult: overlayController.clusterMembers(id: clusterId)) + } + func applyRegion(_ region: Region, animated: Bool = false) { let targetRegion = region.toMKCoordinateRegion() guard !view.region.approximatelyEquals(targetRegion) else { @@ -339,7 +343,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } isMapReady = true - overlayController.setMarkers(markers) + overlayController.reapplyMarkers() deliverMapReadyIfPossible() } @@ -418,7 +422,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { onPolygonPress = nil onCirclePress = nil onClusterPress = nil - markers = nil + markerCollection = nil polylines = nil polygons = nil circles = nil diff --git a/package/ios/GoogleMapOverlayController.swift b/package/ios/GoogleMapOverlayController.swift index f3000c8..5404d82 100644 --- a/package/ios/GoogleMapOverlayController.swift +++ b/package/ios/GoogleMapOverlayController.swift @@ -20,7 +20,7 @@ final class GoogleMapOverlayController { private enum MarkerPayload { case marker(String) - case cluster(memberIds: [String], region: MKCoordinateRegion) + case cluster(id: String, count: Int, memberHandles: [Int32], region: MKCoordinateRegion) } private struct MarkerAnimationBatch { @@ -29,8 +29,8 @@ final class GoogleMapOverlayController { } private weak var mapView: GMSMapView? - private var markers: [String: GMSMarker] = [:] - private var markerVersions: [String: Int] = [:] + private var markers: [MarkerRenderKey: GMSMarker] = [:] + private var markerVersions: [MarkerRenderKey: Int] = [:] private var polylines: [String: GMSPolyline] = [:] private var polygons: [String: GMSPolygon] = [:] private var circles: [String: GMSCircle] = [:] @@ -46,7 +46,7 @@ final class GoogleMapOverlayController { var onPolylinePress: ((String) -> Void)? var onPolygonPress: ((String) -> Void)? var onCirclePress: ((String) -> Void)? - var onClusterPress: (([String], Coordinate) -> Void)? + var onClusterPress: ((NativeClusterPressEvent) -> Void)? var animateToClusterRegion: ((MKCoordinateRegion) -> Void)? var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? @@ -61,6 +61,7 @@ final class GoogleMapOverlayController { } func reset() { + markerPipeline.store?.removeListener(self) markerPipeline.reset() clearMarkers() clearShapes() @@ -73,13 +74,28 @@ final class GoogleMapOverlayController { reapplyMarkers() } - func setMarkers(_ descriptors: [MarkerDescriptor]?) { - guard markerPipeline.setMarkers(descriptors) else { + /// Renders markers from `store` and follows its changes until another store + /// (or nil) is attached. + func attach(store: MarkerStore?) { + guard markerPipeline.store !== store else { return } + markerPipeline.store?.removeListener(self) + markerPipeline.attach(store: store) + store?.addListener(self) reapplyMarkers() } + /// Ids of the markers inside a displayed cluster; empty once it is gone. + func clusterMembers(id: String) -> [String] { + guard let store = markerPipeline.store, + case let .cluster(_, _, memberHandles, _)? = markers[.cluster(id: id)]?.userData as? MarkerPayload + else { + return [] + } + return store.ids(for: memberHandles) + } + func refreshViewportMarkers( animateEntering: Bool = true, animationBudget: Int = maximumAnimatedMarkersPerDiff @@ -131,11 +147,12 @@ final class GoogleMapOverlayController { case let .marker(id): onMarkerPress?(id) return marker.title == nil && marker.snippet == nil - case let .cluster(memberIds, region): - onClusterPress?( - memberIds, - Coordinate(latitude: marker.position.latitude, longitude: marker.position.longitude) - ) + case let .cluster(id, count, _, region): + onClusterPress?(NativeClusterPressEvent( + clusterId: id, + count: Double(count), + coordinate: Coordinate(latitude: marker.position.latitude, longitude: marker.position.longitude) + )) animateToClusterRegion?(region) return true case .none: @@ -281,7 +298,7 @@ final class GoogleMapOverlayController { } private func enteringAnimation( - for element: MarkerClusterEngine.Element + for element: MarkerRenderElement ) -> ResolvedOverlayEnteringAnimation { switch element { case let .single(descriptor): @@ -294,7 +311,7 @@ final class GoogleMapOverlayController { } } - private func updateMarker(_ marker: GMSMarker, with element: MarkerClusterEngine.Element) { + private func updateMarker(_ marker: GMSMarker, with element: MarkerRenderElement) { switch element { case let .single(descriptor): marker.position = descriptor.coordinate.toCLLocationCoordinate2D() @@ -304,7 +321,7 @@ final class GoogleMapOverlayController { marker.zIndex = Self.nativeZIndex(descriptor.zIndex) marker.userData = MarkerPayload.marker(descriptor.id) visualApplier.apply(descriptor, to: marker) - case let .cluster(_, coordinate, count, memberIds, region): + case let .cluster(id, coordinate, count, memberHandles, region): marker.position = coordinate marker.title = nil marker.snippet = nil @@ -315,7 +332,12 @@ final class GoogleMapOverlayController { marker.icon = icon } marker.groundAnchor = CGPoint(x: 0.5, y: 0.5) - marker.userData = MarkerPayload.cluster(memberIds: memberIds, region: region) + marker.userData = MarkerPayload.cluster( + id: id, + count: count, + memberHandles: memberHandles, + region: region + ) } } @@ -490,6 +512,15 @@ final class GoogleMapOverlayController { } } +extension GoogleMapOverlayController: MarkerStoreListener { + func markerStoreDidChange(_ store: MarkerStore) { + guard markerPipeline.store === store else { + return + } + reapplyMarkers() + } +} + private protocol IdentifiedOverlayDescriptor { var id: String { get } func renderVersion() -> ShapeRenderVersion diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift index 8b00701..a68b409 100644 --- a/package/ios/GoogleMapProviderAdapter.swift +++ b/package/ios/GoogleMapProviderAdapter.swift @@ -182,9 +182,9 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { var onPoiPress: ((NativePoiPressEvent) -> Void)? var onLongPress: ((Coordinate) -> Void)? - var markers: [MarkerDescriptor]? { + var markerCollection: HybridMarkerCollection? { didSet { - overlayController.setMarkers(markers) + overlayController.attach(store: markerCollection?.store) } } @@ -221,7 +221,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { var onCirclePress: ((String) -> Void)? { didSet { overlayController.onCirclePress = onCirclePress } } - var onClusterPress: (([String], Coordinate) -> Void)? { + var onClusterPress: ((NativeClusterPressEvent) -> Void)? { didSet { overlayController.onClusterPress = onClusterPress } } @@ -259,6 +259,10 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { applyCameraUpdate(update, animated: animated ?? true, duration: nil) } + func getClusterMembers(clusterId: String) throws -> Promise<[String]> { + Promise.resolved(withResult: overlayController.clusterMembers(id: clusterId)) + } + func prepareForRecycle() { isUserRegionChange = false isUserGestureMoving = false @@ -281,7 +285,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { onPolygonPress = nil onCirclePress = nil onClusterPress = nil - markers = nil + markerCollection = nil polylines = nil polygons = nil circles = nil diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift index 05dca26..a4357f6 100644 --- a/package/ios/HybridMapView.swift +++ b/package/ios/HybridMapView.swift @@ -196,9 +196,13 @@ final class HybridMapView: HybridMapViewSpec { set { setBackedOnMain(newValue, store: \.onLongPress) { $0.onLongPress = $1 } } } - var markers: [MarkerDescriptor]? { - get { getBacked(\.markers) } - set { setBackedOnMain(newValue, store: \.markers) { $0.markers = $1 } } + var markerCollection: (any HybridMarkerCollectionSpec)? { + get { getBacked(\.markerCollection) } + set { + setBackedOnMain(newValue, store: \.markerCollection) { + $0.markerCollection = $1 as? HybridMarkerCollection + } + } } var polylines: [PolylineDescriptor]? { @@ -243,7 +247,7 @@ final class HybridMapView: HybridMapViewSpec { set { setBackedOnMain(newValue, store: \.onCirclePress) { $0.onCirclePress = $1 } } } - var onClusterPress: (([String], Coordinate) -> Void)? { + var onClusterPress: ((NativeClusterPressEvent) -> Void)? { get { getBacked(\.onClusterPress) } set { setBackedOnMain(newValue, store: \.onClusterPress) { $0.onClusterPress = $1 } } } @@ -280,6 +284,10 @@ final class HybridMapView: HybridMapViewSpec { } } + func getClusterMembers(clusterId: String) throws -> Promise<[String]> { + promiseOnMain { try $0.getClusterMembers(clusterId: clusterId) } + } + func afterUpdate() { runOnMain { [weak self] in self?.activateLifecycle() diff --git a/package/ios/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift index 67891a2..4e16451 100644 --- a/package/ios/HybridMapViewDelegate.swift +++ b/package/ios/HybridMapViewDelegate.swift @@ -166,10 +166,11 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni if let cluster = view.annotation as? MapClusterAnnotation { let coordinate = cluster.coordinate - parent?.onClusterPress?( - cluster.memberIds, - Coordinate(latitude: coordinate.latitude, longitude: coordinate.longitude) - ) + parent?.onClusterPress?(NativeClusterPressEvent( + clusterId: cluster.id, + count: Double(cluster.count), + coordinate: Coordinate(latitude: coordinate.latitude, longitude: coordinate.longitude) + )) parent?.animateToClusterRegion(cluster.region) mapView.deselectAnnotation(cluster, animated: false) return diff --git a/package/ios/HybridMarkerCollection.swift b/package/ios/HybridMarkerCollection.swift new file mode 100644 index 0000000..a1fdabd --- /dev/null +++ b/package/ios/HybridMarkerCollection.swift @@ -0,0 +1,34 @@ +import Foundation +import NitroModules + +/// Nitro `MarkerCollection`: the JS-facing handle of a `MarkerStore`. +/// +/// `applyBatch` runs on the JS thread. The buffer it receives is only valid +/// for the duration of the call, so the bytes are validated and copied here +/// and decoded later on the store's own queue; the JS thread never pays for +/// the decode. +final class HybridMarkerCollection: HybridMarkerCollectionSpec { + let store = MarkerStore() + + var size: Double { + Double(store.markerCount) + } + + var memorySize: Int { + store.estimatedBytes + } + + func applyBatch(batch: ArrayBuffer, strings: [String]) throws { + let raw = UnsafeRawBufferPointer(start: batch.data, count: batch.size) + do { + _ = try MarkerBatchDecoder.readHeader(raw) + } catch let error as MalformedMarkerBatchError { + throw RuntimeError.error(withMessage: error.description) + } + store.enqueue(batch: [UInt8](raw), strings: strings) + } + + func clear() throws { + store.enqueueClear() + } +} diff --git a/package/ios/MapClusterAnnotation.swift b/package/ios/MapClusterAnnotation.swift index 2bee883..4504f4b 100644 --- a/package/ios/MapClusterAnnotation.swift +++ b/package/ios/MapClusterAnnotation.swift @@ -3,11 +3,13 @@ import MapKit /// Annotation representing a computed cluster of markers. /// /// Clusters are produced by `MarkerClusterEngine` on a background queue, so the -/// map view only ever receives a small, bounded number of annotations. +/// map view only ever receives a small, bounded number of annotations. Members +/// are kept as store handles; their ids are resolved on demand by +/// `getClusterMembers`. final class MapClusterAnnotation: NSObject, MKAnnotation { var id: String var count: Int - var memberIds: [String] + var memberHandles: [Int32] /// Region that frames this cluster's members, used for tap-to-zoom. var region: MKCoordinateRegion let enteringAnimation: ResolvedOverlayEnteringAnimation @@ -18,14 +20,14 @@ final class MapClusterAnnotation: NSObject, MKAnnotation { id: String, coordinate: CLLocationCoordinate2D, count: Int, - memberIds: [String], + memberHandles: [Int32], region: MKCoordinateRegion, enteringAnimation: ResolvedOverlayEnteringAnimation ) { self.id = id self.coordinate = coordinate self.count = count - self.memberIds = memberIds + self.memberHandles = memberHandles self.region = region self.enteringAnimation = enteringAnimation } @@ -34,13 +36,13 @@ final class MapClusterAnnotation: NSObject, MKAnnotation { id: String, coordinate: CLLocationCoordinate2D, count: Int, - memberIds: [String], + memberHandles: [Int32], region: MKCoordinateRegion ) { self.id = id self.coordinate = coordinate self.count = count - self.memberIds = memberIds + self.memberHandles = memberHandles self.region = region } } diff --git a/package/ios/MapOverlayController.swift b/package/ios/MapOverlayController.swift index c9dd83d..90b351f 100644 --- a/package/ios/MapOverlayController.swift +++ b/package/ios/MapOverlayController.swift @@ -19,9 +19,9 @@ final class MapOverlayController { } private weak var mapView: MKMapView? - /// All currently shown annotations (singles and clusters), keyed by diff key. - private var displayedAnnotations: [String: MKAnnotation] = [:] - private var displayedAnnotationVersions: [String: Int] = [:] + /// All currently shown annotations (singles and clusters), keyed by render key. + private var displayedAnnotations: [MarkerRenderKey: MKAnnotation] = [:] + private var displayedAnnotationVersions: [MarkerRenderKey: Int] = [:] private let markerPipeline = MarkerRenderPipeline() private var shapeOverlays: [String: MKOverlay] = [:] private var shapeVersions: [String: ShapeRenderVersion] = [:] @@ -46,6 +46,7 @@ final class MapOverlayController { } func reset() { + markerPipeline.store?.removeListener(self) markerPipeline.reset() guard let mapView else { return @@ -60,14 +61,28 @@ final class MapOverlayController { overlayStyles.removeAll() } - func setMarkers(_ descriptors: [MarkerDescriptor]?) { - guard markerPipeline.setMarkers(descriptors) else { + /// Renders markers from `store` and follows its changes until another store + /// (or nil) is attached. + func attach(store: MarkerStore?) { + guard markerPipeline.store !== store else { return } + markerPipeline.store?.removeListener(self) + markerPipeline.attach(store: store) + store?.addListener(self) reapplyMarkers() } - private func reapplyMarkers() { + /// Ids of the markers inside a displayed cluster; empty once it is gone. + func clusterMembers(id: String) -> [String] { + guard let cluster = displayedAnnotations[.cluster(id: id)] as? MapClusterAnnotation, + let store = markerPipeline.store else { + return [] + } + return store.ids(for: cluster.memberHandles) + } + + func reapplyMarkers() { guard let mapView else { return } @@ -158,13 +173,13 @@ final class MapOverlayController { refreshMarkerView(for: marker) } } - case let .cluster(key, coordinate, count, memberIds, region): + case let .cluster(id, coordinate, count, memberHandles, region): if let cluster = existing as? MapClusterAnnotation { cluster.update( - id: key, + id: id, coordinate: coordinate, count: count, - memberIds: memberIds, + memberHandles: memberHandles, region: region ) if let view = mapView.view(for: cluster) as? NitroClusterAnnotationView { @@ -392,3 +407,12 @@ final class MapOverlayController { } } } + +extension MapOverlayController: MarkerStoreListener { + func markerStoreDidChange(_ store: MarkerStore) { + guard markerPipeline.store === store else { + return + } + reapplyMarkers() + } +} diff --git a/package/ios/MapProviderAdapter.swift b/package/ios/MapProviderAdapter.swift index ff5d220..d68aa31 100644 --- a/package/ios/MapProviderAdapter.swift +++ b/package/ios/MapProviderAdapter.swift @@ -29,7 +29,7 @@ protocol MapProviderAdapter: AnyObject { var onPoiPress: ((NativePoiPressEvent) -> Void)? { get set } var onLongPress: ((Coordinate) -> Void)? { get set } - var markers: [MarkerDescriptor]? { get set } + var markerCollection: HybridMarkerCollection? { get set } var polylines: [PolylineDescriptor]? { get set } var polygons: [PolygonDescriptor]? { get set } var circles: [CircleDescriptor]? { get set } @@ -39,13 +39,14 @@ protocol MapProviderAdapter: AnyObject { var onPolylinePress: ((String) -> Void)? { get set } var onPolygonPress: ((String) -> Void)? { get set } var onCirclePress: ((String) -> Void)? { get set } - var onClusterPress: (([String], Coordinate) -> Void)? { get set } + var onClusterPress: ((NativeClusterPressEvent) -> Void)? { get set } func fetchCamera() throws -> Promise func applyCamera(camera: Camera) throws func animateCamera(camera: Camera, duration: Double?) throws func getVisibleRegion() throws -> Promise func fitToCoordinates(coordinates: [Coordinate], padding: EdgePadding?, animated: Bool?) throws + func getClusterMembers(clusterId: String) throws -> Promise<[String]> func prepareForRecycle() } @@ -78,7 +79,7 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter { var onPoiPress: ((NativePoiPressEvent) -> Void)? var onLongPress: ((Coordinate) -> Void)? - var markers: [MarkerDescriptor]? + var markerCollection: HybridMarkerCollection? var polylines: [PolylineDescriptor]? var polygons: [PolygonDescriptor]? var circles: [CircleDescriptor]? @@ -88,7 +89,7 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter { var onPolylinePress: ((String) -> Void)? var onPolygonPress: ((String) -> Void)? var onCirclePress: ((String) -> Void)? - var onClusterPress: (([String], Coordinate) -> Void)? + var onClusterPress: ((NativeClusterPressEvent) -> Void)? init(error: Error) { self.error = error @@ -138,5 +139,9 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter { throw error } + func getClusterMembers(clusterId: String) throws -> Promise<[String]> { + Promise.rejected(withError: error) + } + func prepareForRecycle() {} } diff --git a/package/ios/MapViewState.swift b/package/ios/MapViewState.swift index 0793c9f..53ee19e 100644 --- a/package/ios/MapViewState.swift +++ b/package/ios/MapViewState.swift @@ -25,7 +25,7 @@ struct MapViewState { var onPress: ((Coordinate) -> Void)? var onPoiPress: ((NativePoiPressEvent) -> Void)? var onLongPress: ((Coordinate) -> Void)? - var markers: [MarkerDescriptor]? + var markerCollection: (any HybridMarkerCollectionSpec)? var polylines: [PolylineDescriptor]? var polygons: [PolygonDescriptor]? var circles: [CircleDescriptor]? @@ -34,7 +34,7 @@ struct MapViewState { var onPolylinePress: ((String) -> Void)? var onPolygonPress: ((String) -> Void)? var onCirclePress: ((String) -> Void)? - var onClusterPress: (([String], Coordinate) -> Void)? + var onClusterPress: ((NativeClusterPressEvent) -> Void)? func apply(to adapter: MapProviderAdapter) { adapter.mapType = mapType @@ -60,7 +60,7 @@ struct MapViewState { adapter.onPress = onPress adapter.onPoiPress = onPoiPress adapter.onLongPress = onLongPress - adapter.markers = markers + adapter.markerCollection = markerCollection as? HybridMarkerCollection adapter.polylines = polylines adapter.polygons = polygons adapter.circles = circles diff --git a/package/ios/MarkerBatchDecoder.swift b/package/ios/MarkerBatchDecoder.swift new file mode 100644 index 0000000..5996fba --- /dev/null +++ b/package/ios/MarkerBatchDecoder.swift @@ -0,0 +1,245 @@ +import Foundation + +/// Layout of the packed batches `MarkerCollection` sends from JS. The format is +/// documented in `src/markers/markerBatch.ts`; keep both sides in sync. +enum MarkerBatchLayout { + static let magic: UInt32 = 0x4E4D_4B31 + static let headerBytes = 16 + static let upsertBytes = 96 + static let removeBytes = 4 + static let positionBytes = 24 + static let noString: Int32 = -1 + + static let hasAnchor: UInt32 = 1 << 0 + static let hasCenterOffset: UInt32 = 1 << 1 + static let draggable: UInt32 = 1 << 16 + static let clusterable: UInt32 = 1 << 17 + static let flat: UInt32 = 1 << 18 + + enum Upsert { + static let handle = 0 + static let flags = 4 + static let id = 8 + static let title = 12 + static let subtitle = 16 + static let imageUri = 20 + static let latitude = 24 + static let longitude = 32 + static let imageWidth = 40 + static let imageHeight = 44 + static let imageScale = 48 + static let anchorX = 52 + static let anchorY = 56 + static let centerOffsetX = 60 + static let centerOffsetY = 64 + static let rotation = 68 + static let opacity = 72 + static let animationDuration = 76 + static let animationDelay = 80 + static let animationKind = 84 + static let animationReduceMotion = 85 + static let markerColor = 88 + static let zIndex = 92 + } +} + +struct MarkerBatchHeader { + let upsertCount: Int + let removeCount: Int + let positionCount: Int + + var totalBytes: Int { + MarkerBatchLayout.headerBytes + + upsertCount * MarkerBatchLayout.upsertBytes + + removeCount * MarkerBatchLayout.removeBytes + + positionCount * MarkerBatchLayout.positionBytes + } +} + +struct MalformedMarkerBatchError: Error, CustomStringConvertible { + let description: String +} + +enum MarkerBatchDecoder { + /// Validates the magic and the total length. Cheap enough to run on the JS + /// thread before the bytes are copied off it. + static func readHeader(_ bytes: UnsafeRawBufferPointer) throws -> MarkerBatchHeader { + guard bytes.count >= MarkerBatchLayout.headerBytes else { + throw MalformedMarkerBatchError(description: "Marker batch is shorter than its header") + } + guard bytes.uint32(at: 0) == MarkerBatchLayout.magic else { + throw MalformedMarkerBatchError(description: "Not a marker batch") + } + + let header = MarkerBatchHeader( + upsertCount: Int(bytes.uint32(at: 4)), + removeCount: Int(bytes.uint32(at: 8)), + positionCount: Int(bytes.uint32(at: 12)) + ) + guard header.totalBytes == bytes.count else { + throw MalformedMarkerBatchError( + description: "Marker batch is \(bytes.count) bytes, expected \(header.totalBytes)" + ) + } + return header + } + + /// Walks every record: removals first, then upserts, then positions, so a + /// handle freed in this batch can be reused by an upsert in the same batch. + static func decode( + _ bytes: UnsafeRawBufferPointer, + strings: [String], + onRemove: (Int) -> Void, + onUpsert: (Int, MarkerDescriptor) -> Void, + onPosition: (Int, Double, Double) -> Void + ) throws { + let header = try readHeader(bytes) + let upsertsStart = MarkerBatchLayout.headerBytes + let removesStart = upsertsStart + header.upsertCount * MarkerBatchLayout.upsertBytes + let positionsStart = removesStart + header.removeCount * MarkerBatchLayout.removeBytes + + for index in 0.. MarkerDescriptor? { + typealias Field = MarkerBatchLayout.Upsert + guard let id = string(strings, bytes.int32(at: base + Field.id)) else { + return nil + } + + let flags = bytes.uint32(at: base + Field.flags) + var image: MarkerImage? + if let uri = string(strings, bytes.int32(at: base + Field.imageUri)) { + image = MarkerImage( + uri: uri, + width: bytes.optionalFloat(at: base + Field.imageWidth), + height: bytes.optionalFloat(at: base + Field.imageHeight), + scale: bytes.optionalFloat(at: base + Field.imageScale) + ) + } + + var anchor: MarkerAnchor? + if flags & MarkerBatchLayout.hasAnchor != 0 { + anchor = MarkerAnchor( + x: Double(bytes.float(at: base + Field.anchorX)), + y: Double(bytes.float(at: base + Field.anchorY)) + ) + } + + var centerOffset: MarkerPoint? + if flags & MarkerBatchLayout.hasCenterOffset != 0 { + centerOffset = MarkerPoint( + x: Double(bytes.float(at: base + Field.centerOffsetX)), + y: Double(bytes.float(at: base + Field.centerOffsetY)) + ) + } + + var enteringAnimation: OverlayEnteringAnimationDescriptor? + if let kind = animationKind(bytes.uint8(at: base + Field.animationKind)) { + enteringAnimation = OverlayEnteringAnimationDescriptor( + kind: kind, + duration: bytes.optionalFloat(at: base + Field.animationDuration), + delay: bytes.optionalFloat(at: base + Field.animationDelay), + reduceMotion: reduceMotion(bytes.uint8(at: base + Field.animationReduceMotion)) + ) + } + + return MarkerDescriptor( + id: id, + coordinate: Coordinate( + latitude: bytes.double(at: base + Field.latitude), + longitude: bytes.double(at: base + Field.longitude) + ), + title: string(strings, bytes.int32(at: base + Field.title)), + subtitle: string(strings, bytes.int32(at: base + Field.subtitle)), + draggable: flags & MarkerBatchLayout.draggable != 0 ? true : nil, + clusterable: flags & MarkerBatchLayout.clusterable != 0 ? nil : false, + image: image, + markerColor: string(strings, bytes.int32(at: base + Field.markerColor)), + anchor: anchor, + centerOffset: centerOffset, + rotation: bytes.optionalFloat(at: base + Field.rotation), + flat: flags & MarkerBatchLayout.flat != 0 ? true : nil, + opacity: bytes.optionalFloat(at: base + Field.opacity), + zIndex: bytes.optionalFloat(at: base + Field.zIndex), + enteringAnimation: enteringAnimation + ) + } + + private static func string(_ strings: [String], _ index: Int32) -> String? { + guard index >= 0, Int(index) < strings.count else { + return nil + } + return strings[Int(index)] + } + + private static func animationKind(_ code: UInt8) -> OverlayEnteringAnimationKind? { + switch code { + case 1: return OverlayEnteringAnimationKind.none + case 2: return OverlayEnteringAnimationKind.system + case 3: return OverlayEnteringAnimationKind.fade + case 4: return OverlayEnteringAnimationKind.fadeScale + default: return nil + } + } + + private static func reduceMotion(_ code: UInt8) -> OverlayEnteringAnimationReduceMotion? { + switch code { + case 1: return .system + case 2: return .never + default: return nil + } + } +} + +private extension UnsafeRawBufferPointer { + func uint8(at offset: Int) -> UInt8 { + self[offset] + } + + func uint32(at offset: Int) -> UInt32 { + UInt32(littleEndian: loadUnaligned(fromByteOffset: offset, as: UInt32.self)) + } + + func int32(at offset: Int) -> Int32 { + Int32(bitPattern: uint32(at: offset)) + } + + func float(at offset: Int) -> Float { + Float(bitPattern: uint32(at: offset)) + } + + func double(at offset: Int) -> Double { + Double(bitPattern: UInt64(littleEndian: loadUnaligned(fromByteOffset: offset, as: UInt64.self))) + } + + /// `NaN` marks an absent optional float. + func optionalFloat(at offset: Int) -> Double? { + let value = float(at: offset) + return value.isNaN ? nil : Double(value) + } +} diff --git a/package/ios/MarkerClusterEngine.swift b/package/ios/MarkerClusterEngine.swift index b585f1e..ff7d63b 100644 --- a/package/ios/MarkerClusterEngine.swift +++ b/package/ios/MarkerClusterEngine.swift @@ -2,78 +2,21 @@ import MapKit /// Grid-based marker clustering computed in geographic space. /// -/// Runs entirely off descriptor data (no `MKMapView` projection), so it is safe -/// to call from a background queue. Output is bounded by the number of grid -/// cells that fit on screen, keeping per-frame MapKit work small and constant. +/// Runs over store handles and the store's flat coordinate arrays (no +/// `MKMapView` projection, no descriptor copies), so it is safe to call from a +/// background queue. Output is bounded by the number of grid cells that fit on +/// screen, keeping per-frame MapKit work small and constant. enum MarkerClusterEngine { /// A single display element: an individual marker or a cluster badge. enum Element { - case single(MarkerDescriptor) + case single(handle: Int32) case cluster( - key: String, + id: String, coordinate: CLLocationCoordinate2D, count: Int, - memberIds: [String], + memberHandles: [Int32], region: MKCoordinateRegion ) - - /// Stable identity for diffing. Count changes update the retained badge - /// instead of removing and re-adding the native marker during gestures. - var diffKey: String { - switch self { - case let .single(descriptor): - return "s:" + descriptor.id - case let .cluster(key, _, _, _, _): - return "c:" + key - } - } - - var renderVersion: Int { - switch self { - case let .single(descriptor): - return descriptor.displayedIdentityVersion() - case let .cluster(key, coordinate, count, memberIds, region): - var hasher = Hasher() - hasher.combine("cluster") - hasher.combine(key) - hasher.combine(coordinate.latitude) - hasher.combine(coordinate.longitude) - hasher.combine(count) - for id in memberIds.sorted() { - hasher.combine(id) - } - hasher.combine(region.center.latitude) - hasher.combine(region.center.longitude) - hasher.combine(region.span.latitudeDelta) - hasher.combine(region.span.longitudeDelta) - return hasher.finalize() - } - } - - func makeAnnotation( - markerEnteringAnimation: OverlayEnteringAnimationDescriptor?, - clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? - ) -> MKAnnotation { - switch self { - case let .single(descriptor): - return MapMarkerAnnotation( - descriptor: descriptor, - enteringAnimation: OverlayEnteringAnimationResolver.resolve( - descriptor.enteringAnimation, - fallback: markerEnteringAnimation - ) - ) - case let .cluster(key, coordinate, count, memberIds, region): - return MapClusterAnnotation( - id: key, - coordinate: coordinate, - count: count, - memberIds: memberIds, - region: region, - enteringAnimation: OverlayEnteringAnimationResolver.resolve(clusterEnteringAnimation) - ) - } - } } /// Target cluster cell size in points. @@ -124,7 +67,8 @@ enum MarkerClusterEngine { private static let mergeGap = ClusterBadgeMetrics.mergeGap private struct Bucket { - let key: String + let row: Int + let column: Int var count = 0 var sumLat = 0.0 var sumLon = 0.0 @@ -132,16 +76,21 @@ enum MarkerClusterEngine { var maxLat = -Double.greatestFiniteMagnitude var minLon = Double.greatestFiniteMagnitude var maxLon = -Double.greatestFiniteMagnitude - var memberIds: [String] = [] - var first: MarkerDescriptor? + var memberHandles: [Int32] = [] + + init(row: Int, column: Int) { + self.row = row + self.column = column + } - init(key: String) { - self.key = key + /// Stable identity: the grid cell, which is anchored to geography. + var id: String { + "\(row):\(column)" } /// Adds one marker. Called through `Dictionary.subscript(_:default:)` so - /// the bucket is mutated in place and `memberIds` keeps a unique buffer. - mutating func include(_ descriptor: MarkerDescriptor, lat: Double, lon: Double) { + /// the bucket is mutated in place and `memberHandles` keeps a unique buffer. + mutating func include(_ handle: Int32, lat: Double, lon: Double) { count += 1 sumLat += lat sumLon += lon @@ -149,13 +98,10 @@ enum MarkerClusterEngine { maxLat = max(maxLat, lat) minLon = min(minLon, lon) maxLon = max(maxLon, lon) - if first == nil { - first = descriptor - } - memberIds.append(descriptor.id) + memberHandles.append(handle) } - /// Folds another bucket's members in. The receiver keeps its own key/first, + /// Folds another bucket's members in. The receiver keeps its own cell, /// so callers should seed groups with the dominant (largest) bucket. mutating func absorb(_ other: Bucket) { count += other.count @@ -165,12 +111,15 @@ enum MarkerClusterEngine { maxLat = max(maxLat, other.maxLat) minLon = min(minLon, other.minLon) maxLon = max(maxLon, other.maxLon) - memberIds.append(contentsOf: other.memberIds) + memberHandles.append(contentsOf: other.memberHandles) } } static func clusters( - candidates: [MarkerDescriptor], + candidates: [Int32], + latitudes: [Double], + longitudes: [Double], + flags: [UInt8], region: MKCoordinateRegion, viewSize: CGSize, cellPoints: Double = defaultCellPoints @@ -180,12 +129,12 @@ enum MarkerClusterEngine { } var singles: [Element] = [] - var clusterableCandidates: [MarkerDescriptor] = [] - for descriptor in candidates { - if descriptor.clusterable == false { - singles.append(.single(descriptor)) + var clusterableCandidates: [Int32] = [] + for handle in candidates { + if flags[Int(handle)] & MarkerStore.Flag.clusterable == 0 { + singles.append(.single(handle: handle)) } else { - clusterableCandidates.append(descriptor) + clusterableCandidates.append(handle) } } @@ -204,20 +153,17 @@ enum MarkerClusterEngine { let cellLat = quantize(region.span.latitudeDelta / Double(rows)) let cellLon = quantize(region.span.longitudeDelta / Double(cols)) - var buckets: [String: Bucket] = [:] - for descriptor in clusterableCandidates { - let lat = descriptor.coordinate.latitude + var buckets: [Int64: Bucket] = [:] + for handle in clusterableCandidates { + let index = Int(handle) + let lat = latitudes[index] let lon = wraps - ? normalizeLongitude(descriptor.coordinate.longitude, reference: referenceLon) - : descriptor.coordinate.longitude + ? normalizeLongitude(longitudes[index], reference: referenceLon) + : longitudes[index] let row = Int((lat / cellLat).rounded(.down)) let col = Int((lon / cellLon).rounded(.down)) - let key = "\(row):\(col)" - - // Copying the bucket out, appending, and writing it back shared the - // member array with the dictionary's copy, so every append copied the - // whole array (O(k²) per cell). The default subscript mutates in place. - buckets[key, default: Bucket(key: key)].include(descriptor, lat: lat, lon: lon) + let key = (Int64(row) << 32) | Int64(UInt32(truncatingIfNeeded: col)) + buckets[key, default: Bucket(row: row, column: col)].include(handle, lat: lat, lon: lon) } let merged = mergeOverlapping( @@ -230,17 +176,17 @@ enum MarkerClusterEngine { var elements = singles elements.reserveCapacity(merged.count + singles.count) for bucket in merged { - if bucket.count == 1, let descriptor = bucket.first { - elements.append(.single(descriptor)) + if bucket.count == 1, let handle = bucket.memberHandles.first { + elements.append(.single(handle: handle)) } else { elements.append(.cluster( - key: bucket.key, + id: bucket.id, coordinate: CLLocationCoordinate2D( latitude: bucket.sumLat / Double(bucket.count), longitude: bucket.sumLon / Double(bucket.count) ), count: bucket.count, - memberIds: bucket.memberIds, + memberHandles: bucket.memberHandles, region: expandedRegion( minLat: bucket.minLat, maxLat: bucket.maxLat, @@ -256,7 +202,7 @@ enum MarkerClusterEngine { /// Merges buckets whose badges would overlap on screen, so a zoomed-out view /// collapses neighbouring cells into one badge instead of stacking them. /// Uses union-find on screen-space centroid distance; groups are seeded by the - /// largest bucket so the resulting cluster key is stable. + /// largest bucket so the resulting cluster id is stable. private static func mergeOverlapping( _ buckets: [Bucket], region: MKCoordinateRegion, @@ -350,18 +296,69 @@ enum MarkerClusterEngine { } } +/// Identity of a displayed element across refreshes. +/// +/// A single carries its id as well as its handle: JS reuses a freed handle for +/// the next new marker, and a new marker must not be mistaken for an update of +/// the one that used to own the handle. +enum MarkerRenderKey: Hashable { + case single(handle: Int32, id: String) + case cluster(id: String) +} + +/// A display element with everything the renderer needs, materialized from the +/// store for the elements that will actually be shown. +enum MarkerRenderElement { + case single(descriptor: MarkerDescriptor) + case cluster( + id: String, + coordinate: CLLocationCoordinate2D, + count: Int, + memberHandles: [Int32], + region: MKCoordinateRegion + ) + + func makeAnnotation( + markerEnteringAnimation: OverlayEnteringAnimationDescriptor?, + clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? + ) -> MKAnnotation { + switch self { + case let .single(descriptor): + return MapMarkerAnnotation( + descriptor: descriptor, + enteringAnimation: OverlayEnteringAnimationResolver.resolve( + descriptor.enteringAnimation, + fallback: markerEnteringAnimation + ) + ) + case let .cluster(id, coordinate, count, memberHandles, region): + return MapClusterAnnotation( + id: id, + coordinate: coordinate, + count: count, + memberHandles: memberHandles, + region: region, + enteringAnimation: OverlayEnteringAnimationResolver.resolve(clusterEnteringAnimation) + ) + } + } +} + struct MarkerRenderEntry { - let key: String - let element: MarkerClusterEngine.Element + let key: MarkerRenderKey + let element: MarkerRenderElement let version: Int } struct MarkerRenderDiff { - let removedKeys: Set + let removedKeys: Set let added: [MarkerRenderEntry] let retained: [MarkerRenderEntry] } +/// Drives one map's marker rendering from a `MarkerStore`: the synchronous +/// full diff for small datasets, and the coalesced background viewport pipeline +/// (index query → cluster or LOD filter → diff) for large or clustered ones. final class MarkerRenderPipeline { private static let asyncThreshold = 500 static let liveRefreshInterval: TimeInterval = 0.1 @@ -369,17 +366,17 @@ final class MarkerRenderPipeline { /// The inputs of one refresh: what to show for a viewport, diffed against /// what is shown, and where to deliver the result. private struct RefreshParameters { - let displayedVersions: [String: Int] + let displayedVersions: [MarkerRenderKey: Int] let region: MKCoordinateRegion let viewSize: CGSize let apply: (MarkerRenderDiff) -> Void } /// One viewport query, cluster or filter pass, and diff, computed off the - /// main thread against an immutable spatial index. + /// main thread against the store. private struct ViewportRefreshRequest { let generation: Int - let index: MarkerSpatialIndex + let store: MarkerStore let clustering: Bool let parameters: RefreshParameters } @@ -388,25 +385,11 @@ final class MarkerRenderPipeline { /// compute queue (consumer). At most one compute block is queued at a time; a /// request posted while one is queued replaces the pending request instead of /// adding another block, so a long gesture cannot build a backlog of stale - /// work. The latest generations are mirrored here so queued work can bail - /// out before computing. + /// work. private final class RefreshInbox { private let lock = NSLock() private var pending: ViewportRefreshRequest? private var isComputeQueued = false - private var latestDatasetGeneration = 0 - - func record(datasetGeneration: Int) { - lock.lock() - latestDatasetGeneration = datasetGeneration - lock.unlock() - } - - func isCurrent(datasetGeneration: Int) -> Bool { - lock.lock() - defer { lock.unlock() } - return datasetGeneration == latestDatasetGeneration - } /// Stores `request` as the latest one. Returns true when the caller must /// enqueue a compute block, false when a queued block will pick it up. @@ -439,17 +422,10 @@ final class MarkerRenderPipeline { } private let clusterCellPoints: Double - private var allMarkerDescriptors: [MarkerDescriptor] = [] - private var spatialIndex: MarkerSpatialIndex? + private(set) var store: MarkerStore? private var viewportRefreshWorkItem: DispatchWorkItem? - private var markersFingerprint = 0 /// Invalidates in-flight refresh results (viewport diffs). private var refreshGeneration = 0 - /// Invalidates in-flight index builds. Kept apart from `refreshGeneration` - /// so a burst of refreshes during a gesture cannot keep discarding the index - /// build for a dataset that has not changed. - private var datasetGeneration = 0 - private var latestRefreshParameters: RefreshParameters? private var clusteringEnabled = false private let refreshInbox = RefreshInbox() private let computeQueue = DispatchQueue( @@ -462,19 +438,17 @@ final class MarkerRenderPipeline { } var usesViewportPipeline: Bool { - clusteringEnabled || allMarkerDescriptors.count > Self.asyncThreshold + clusteringEnabled || (store?.markerCount ?? 0) > Self.asyncThreshold + } + + func attach(store: MarkerStore?) { + self.store = store + invalidate() } func reset() { - viewportRefreshWorkItem?.cancel() - viewportRefreshWorkItem = nil - refreshGeneration += 1 - advanceDatasetGeneration() - refreshInbox.discardPending() - latestRefreshParameters = nil - allMarkerDescriptors.removeAll() - spatialIndex = nil - markersFingerprint = 0 + invalidate() + store = nil clusteringEnabled = false } @@ -487,24 +461,10 @@ final class MarkerRenderPipeline { return true } - func setMarkers(_ descriptors: [MarkerDescriptor]?) -> Bool { - let next = descriptors ?? [] - let signpost = MapTrace.begin("markersFingerprint") - let fingerprint = next.markersFingerprint() - MapTrace.end("markersFingerprint", signpost) - guard fingerprint != markersFingerprint else { - return false - } - - markersFingerprint = fingerprint - allMarkerDescriptors = next - spatialIndex = nil - advanceDatasetGeneration() - return true - } - + /// Recomputes what is shown for the current dataset: synchronously for small + /// unclustered datasets, through the viewport pipeline otherwise. func reapply( - displayedVersions: [String: Int], + displayedVersions: [MarkerRenderKey: Int], region: MKCoordinateRegion, viewSize: CGSize, apply: @escaping (MarkerRenderDiff) -> Void @@ -516,22 +476,19 @@ final class MarkerRenderPipeline { apply: apply ) if usesViewportPipeline { - rebuildIndexAndRefresh(parameters) - } else { - viewportRefreshWorkItem?.cancel() - viewportRefreshWorkItem = nil - refreshGeneration += 1 - refreshInbox.discardPending() - latestRefreshParameters = parameters - apply(Self.computeDiff( - target: allMarkerDescriptors.map { .single($0) }, - displayed: displayedVersions - )) + refreshNow(parameters) + return } + + invalidate() + let target: [MarkerRenderEntry] = store?.read { access in + Self.materialize(access.aliveHandles().map { .single(handle: $0) }, access: access) + } ?? [] + apply(Self.computeDiff(target: target, displayed: displayedVersions)) } func scheduleViewportRefresh( - displayedVersions: [String: Int], + displayedVersions: [MarkerRenderKey: Int], region: MKCoordinateRegion, viewSize: CGSize, immediate: Bool = false, @@ -565,7 +522,7 @@ final class MarkerRenderPipeline { } func refreshNow( - displayedVersions: [String: Int], + displayedVersions: [MarkerRenderKey: Int], region: MKCoordinateRegion, viewSize: CGSize, apply: @escaping (MarkerRenderDiff) -> Void @@ -574,26 +531,23 @@ final class MarkerRenderPipeline { return } - let parameters = RefreshParameters( + refreshNow(RefreshParameters( displayedVersions: displayedVersions, region: region, viewSize: viewSize, apply: apply - ) - guard let index = spatialIndex else { - rebuildIndexAndRefresh(parameters) + )) + } + + private func refreshNow(_ parameters: RefreshParameters) { + guard let store else { return } - refreshNow(parameters, index: index) - } - - private func refreshNow(_ parameters: RefreshParameters, index: MarkerSpatialIndex) { - latestRefreshParameters = parameters refreshGeneration += 1 let request = ViewportRefreshRequest( generation: refreshGeneration, - index: index, + store: store, clustering: clusteringEnabled, parameters: parameters ) @@ -618,39 +572,11 @@ final class MarkerRenderPipeline { } } - private func rebuildIndexAndRefresh(_ parameters: RefreshParameters) { - latestRefreshParameters = parameters - // Diffs computed against the previous index are stale from here on. + private func invalidate() { + viewportRefreshWorkItem?.cancel() + viewportRefreshWorkItem = nil refreshGeneration += 1 - let descriptors = allMarkerDescriptors - let builtForDataset = datasetGeneration - - computeQueue.async { [weak self] in - guard let self, self.refreshInbox.isCurrent(datasetGeneration: builtForDataset) else { - // A newer dataset superseded this build before it started. - return - } - - let signpost = MapTrace.begin("buildSpatialIndex") - let index = MarkerSpatialIndex(markers: descriptors) - MapTrace.end("buildSpatialIndex", signpost) - DispatchQueue.main.async { [weak self] in - guard let self, builtForDataset == self.datasetGeneration else { - return - } - self.spatialIndex = index - // Refresh for the viewport that was requested most recently, not the - // one that was current when the build was queued. - if let latest = self.latestRefreshParameters, self.usesViewportPipeline { - self.refreshNow(latest, index: index) - } - } - } - } - - private func advanceDatasetGeneration() { - datasetGeneration += 1 - refreshInbox.record(datasetGeneration: datasetGeneration) + refreshInbox.discardPending() } private static func computeViewportDiff( @@ -660,45 +586,105 @@ final class MarkerRenderPipeline { let signpost = MapTrace.begin("computeViewportDiff") defer { MapTrace.end("computeViewportDiff", signpost) } let parameters = request.parameters - let candidates = request.index.candidates(in: parameters.region) + let store = request.store + + // Snapshot the coordinate arrays under the lock (copy-on-write, O(1)) and + // run the geometry outside it. + let (candidates, latitudes, longitudes, flags) = store.read { access in + (access.index.candidates(in: parameters.region), access.latitudes, access.longitudes, access.flags) + } + let elements: [MarkerClusterEngine.Element] if request.clustering { elements = MarkerClusterEngine.clusters( candidates: candidates, + latitudes: latitudes, + longitudes: longitudes, + flags: flags, region: parameters.region, viewSize: parameters.viewSize, cellPoints: clusterCellPoints ) } else { elements = MarkerViewportFilter - .displaySubset(candidates: candidates, region: parameters.region) - .map { .single($0) } + .displaySubset( + candidates: candidates, + latitudes: latitudes, + longitudes: longitudes, + region: parameters.region + ) + .map { .single(handle: $0) } } - return computeDiff(target: elements, displayed: parameters.displayedVersions) + let target = store.read { access in materialize(elements, access: access) } + return computeDiff(target: target, displayed: parameters.displayedVersions) + } + + /// Turns handles into render entries with their descriptors and versions. + /// A handle removed between the query and this call is dropped. + private static func materialize( + _ elements: [MarkerClusterEngine.Element], + access: MarkerStoreAccess + ) -> [MarkerRenderEntry] { + var entries: [MarkerRenderEntry] = [] + entries.reserveCapacity(elements.count) + for element in elements { + switch element { + case let .single(handle): + let index = Int(handle) + guard access.isAlive(index), let descriptor = access.descriptors[index] else { + continue + } + entries.append(MarkerRenderEntry( + key: .single(handle: handle, id: descriptor.id), + element: .single(descriptor: descriptor), + version: access.versions[index] + )) + case let .cluster(id, coordinate, count, memberHandles, region): + var hasher = Hasher() + hasher.combine(id) + hasher.combine(coordinate.latitude) + hasher.combine(coordinate.longitude) + hasher.combine(count) + hasher.combine(region.center.latitude) + hasher.combine(region.center.longitude) + hasher.combine(region.span.latitudeDelta) + hasher.combine(region.span.longitudeDelta) + entries.append(MarkerRenderEntry( + key: .cluster(id: id), + element: .cluster( + id: id, + coordinate: coordinate, + count: count, + memberHandles: memberHandles, + region: region + ), + version: hasher.finalize() + )) + } + } + return entries } - private static func computeDiff( - target: [MarkerClusterEngine.Element], - displayed: [String: Int] + static func computeDiff( + target: [MarkerRenderEntry], + displayed: [MarkerRenderKey: Int] ) -> MarkerRenderDiff { - var nextKeys = Set() + var nextKeys = Set() nextKeys.reserveCapacity(target.count) var added: [MarkerRenderEntry] = [] var retained: [MarkerRenderEntry] = [] - for element in target { - let key = element.diffKey - guard nextKeys.insert(key).inserted else { + for entry in target { + guard nextKeys.insert(entry.key).inserted else { continue } - let version = element.renderVersion - if let displayedVersion = displayed[key] { - if displayedVersion != version { - retained.append(MarkerRenderEntry(key: key, element: element, version: version)) + if let displayedVersion = displayed[entry.key] { + if displayedVersion != entry.version { + retained.append(entry) } } else { - added.append(MarkerRenderEntry(key: key, element: element, version: version)) + added.append(entry) } } diff --git a/package/ios/MarkerDescriptor+Fingerprint.swift b/package/ios/MarkerDescriptor+Fingerprint.swift deleted file mode 100644 index 26ebe04..0000000 --- a/package/ios/MarkerDescriptor+Fingerprint.swift +++ /dev/null @@ -1,56 +0,0 @@ -extension MarkerDescriptor { - /// Combines optionals as-is so `nil` stays distinguishable from a present value. - private func hashDisplayedIdentity(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(coordinate.latitude) - hasher.combine(coordinate.longitude) - hasher.combine(title) - hasher.combine(subtitle) - hasher.combine(draggable) - hasher.combine(clusterable) - hasher.combine(image?.uri) - hasher.combine(image?.width) - hasher.combine(image?.height) - hasher.combine(image?.scale) - hasher.combine(markerColor) - hasher.combine(anchor?.x) - hasher.combine(anchor?.y) - hasher.combine(centerOffset?.x) - hasher.combine(centerOffset?.y) - hasher.combine(rotation) - hasher.combine(flat) - hasher.combine(opacity) - hasher.combine(zIndex) - } - - func markersDescriptorFingerprint() -> Int { - var hasher = Hasher() - hashDisplayedIdentity(into: &hasher) - hasher.combine(enteringAnimation?.kind) - hasher.combine(enteringAnimation?.duration) - hasher.combine(enteringAnimation?.delay) - hasher.combine(enteringAnimation?.reduceMotion) - return hasher.finalize() - } - - /// Displayed-marker identity. Omits `enteringAnimation`; retained updates skip it. - func displayedIdentityVersion() -> Int { - var hasher = Hasher() - hashDisplayedIdentity(into: &hasher) - return hasher.finalize() - } -} - -extension Array where Element == MarkerDescriptor { - func markersFingerprint() -> Int { - if isEmpty { - return 0 - } - - var hash = count - for descriptor in self { - hash = 31 &* hash &+ descriptor.markersDescriptorFingerprint() - } - return hash - } -} diff --git a/package/ios/MarkerDescriptor.swift b/package/ios/MarkerDescriptor.swift new file mode 100644 index 0000000..1d247b0 --- /dev/null +++ b/package/ios/MarkerDescriptor.swift @@ -0,0 +1,46 @@ +/// Marker data as the native store holds it. +/// +/// Nitrogen used to generate these structs from the `markers` view prop. Markers +/// now reach native code as packed batches (see `MarkerBatchDecoder`), so no +/// spec references them and they live here instead. Field names and types +/// match the TypeScript `MarkerDescriptor`. Should a Nitro spec reference +/// `MarkerDescriptor` again, nitrogen would generate a conflicting type and +/// this file has to go. +struct MarkerImage { + var uri: String + var width: Double? + var height: Double? + var scale: Double? +} + +/// Anchor point on the marker image (0..1). +struct MarkerAnchor { + var x: Double + var y: Double +} + +/// Point offset in density-independent pixels. +struct MarkerPoint { + var x: Double + var y: Double +} + +struct MarkerDescriptor { + var id: String + var coordinate: Coordinate + var title: String? + var subtitle: String? + var draggable: Bool? + var clusterable: Bool? + var image: MarkerImage? + /// Tint of the default pin when there is no image. + var markerColor: String? + var anchor: MarkerAnchor? + var centerOffset: MarkerPoint? + var rotation: Double? + var flat: Bool? + var opacity: Double? + /// Drawing order relative to other overlays. + var zIndex: Double? + var enteringAnimation: OverlayEnteringAnimationDescriptor? +} diff --git a/package/ios/MarkerSpatialIndex.swift b/package/ios/MarkerSpatialIndex.swift index b25f3f6..fafad00 100644 --- a/package/ios/MarkerSpatialIndex.swift +++ b/package/ios/MarkerSpatialIndex.swift @@ -1,63 +1,150 @@ import MapKit -/// Uniform grid spatial index over a marker dataset. +/// Uniform grid spatial index over marker handles. /// -/// Built once per dataset so viewport queries cost O(cells in view + markers in -/// those cells) instead of O(all markers). Immutable after init, so instances -/// are safe to query from a background queue. +/// Cells hold handles, not descriptors, and the grid is updated in place as +/// markers are inserted, moved and removed, so a moving marker costs one cell +/// swap instead of a rebuild. The grid bounds are computed over the dataset +/// with a margin; a marker that lands outside them flags a rebuild, which the +/// store runs once at the end of the batch that caused it. +/// +/// Not thread-safe on its own: the owning `MarkerStore` serializes access. final class MarkerSpatialIndex { - let count: Int private let cellsPerSide: Int - private let minLat: Double - private let minLon: Double - private let latStep: Double - private let lonStep: Double - private var cells: [[MarkerDescriptor]] - - init(markers: [MarkerDescriptor], cellsPerSide: Int = 96) { - count = markers.count + private var minLat = 0.0 + private var maxLat = 0.0 + private var minLon = 0.0 + private var maxLon = 0.0 + private var latStep = 1.0 + private var lonStep = 1.0 + private var hasBounds = false + private var needsRebuild = false + private var cells: [[Int32]] + /// Cell index per handle, -1 when the handle is not indexed. + private var cellOf: [Int32] = [] + private(set) var count = 0 + + init(cellsPerSide: Int = 96) { let side = max(1, cellsPerSide) self.cellsPerSide = side + cells = Array(repeating: [], count: side * side) + } + + func insert(_ handle: Int, latitude: Double, longitude: Double) { + ensureCapacity(handle) + guard cellOf[handle] < 0 else { + move(handle, latitude: latitude, longitude: longitude) + return + } + + count += 1 + if !hasBounds || !contains(latitude: latitude, longitude: longitude) { + needsRebuild = true + } + let cell = clampedCellIndex(latitude: latitude, longitude: longitude) + cells[cell].append(Int32(handle)) + cellOf[handle] = Int32(cell) + } + + func move(_ handle: Int, latitude: Double, longitude: Double) { + guard handle < cellOf.count, cellOf[handle] >= 0 else { + insert(handle, latitude: latitude, longitude: longitude) + return + } + + if !contains(latitude: latitude, longitude: longitude) { + needsRebuild = true + } + let current = Int(cellOf[handle]) + let next = clampedCellIndex(latitude: latitude, longitude: longitude) + guard next != current else { + return + } + removeFromCell(handle, cell: current) + cells[next].append(Int32(handle)) + cellOf[handle] = Int32(next) + } + + func remove(_ handle: Int) { + guard handle < cellOf.count, cellOf[handle] >= 0 else { + return + } + removeFromCell(handle, cell: Int(cellOf[handle])) + cellOf[handle] = -1 + count -= 1 + } + + func removeAll() { + for index in cells.indices { + cells[index].removeAll(keepingCapacity: false) + } + cellOf.removeAll() + count = 0 + hasBounds = false + needsRebuild = false + } + + /// Recomputes the grid over every live marker if one fell outside the + /// current bounds. Called once per applied batch, before any query. + func rebuildIfNeeded(latitudes: [Double], longitudes: [Double], flags: [UInt8]) { + guard needsRebuild else { + return + } + needsRebuild = false var minLatV = Double.greatestFiniteMagnitude var maxLatV = -Double.greatestFiniteMagnitude var minLonV = Double.greatestFiniteMagnitude var maxLonV = -Double.greatestFiniteMagnitude - - for marker in markers { - let lat = marker.coordinate.latitude - let lon = marker.coordinate.longitude - minLatV = min(minLatV, lat) - maxLatV = max(maxLatV, lat) - minLonV = min(minLonV, lon) - maxLonV = max(maxLonV, lon) + var alive = 0 + for handle in 0.. 0 else { + hasBounds = false + for handle in cellOf.indices { + cellOf[handle] = -1 + } + count = 0 + return } - minLat = minLatV - minLon = minLonV - latStep = max(1e-9, (maxLatV - minLatV) / Double(side)) - lonStep = max(1e-9, (maxLonV - minLonV) / Double(side)) - cells = Array(repeating: [], count: side * side) - - for marker in markers { - let index = cellIndex( - lat: marker.coordinate.latitude, - lon: marker.coordinate.longitude - ) - cells[index].append(marker) + // A margin keeps ordinary movement inside the grid; only a marker that + // leaves the dataset's neighbourhood triggers the next rebuild. + let latPad = max((maxLatV - minLatV) * 0.15, 1e-6) + let lonPad = max((maxLonV - minLonV) * 0.15, 1e-6) + minLat = minLatV - latPad + maxLat = maxLatV + latPad + minLon = minLonV - lonPad + maxLon = maxLonV + lonPad + latStep = max(1e-9, (maxLat - minLat) / Double(cellsPerSide)) + lonStep = max(1e-9, (maxLon - minLon) / Double(cellsPerSide)) + hasBounds = true + + ensureCapacity(flags.count - 1) + for handle in 0.. [MarkerDescriptor] { - guard count > 0 else { + /// Handles whose grid cells overlap the padded region. + func candidates(in region: MKCoordinateRegion, padding: Double = 0.2) -> [Int32] { + guard count > 0, hasBounds else { return [] } @@ -67,15 +154,19 @@ final class MarkerSpatialIndex { let maxLatQ = region.center.latitude + region.span.latitudeDelta / 2 + latPad let minLonQ = region.center.longitude - region.span.longitudeDelta / 2 - lonPad let maxLonQ = region.center.longitude + region.span.longitudeDelta / 2 + lonPad + guard maxLatQ >= minLat, minLatQ <= maxLat, overlapsLongitude(minLonQ, maxLonQ) else { + return [] + } let rowStart = clampedRow(minLatQ) let rowEnd = clampedRow(maxLatQ) + let columns = longitudeColumnRange(minLon: minLonQ, maxLon: maxLonQ) - var result: [MarkerDescriptor] = [] + var result: [Int32] = [] var row = rowStart while row <= rowEnd { let base = row * cellsPerSide - for column in longitudeColumnRange(minLon: minLonQ, maxLon: maxLonQ) { + for column in columns { result.append(contentsOf: cells[base + column]) } row += 1 @@ -83,6 +174,40 @@ final class MarkerSpatialIndex { return result } + /// Whether a query's longitude range, which may cross the antimeridian, + /// meets the grid's. Without this a query east or west of the dataset would + /// clamp to the outermost column and return everything in it. + private func overlapsLongitude(_ minLonQ: Double, _ maxLonQ: Double) -> Bool { + if maxLonQ - minLonQ >= 360 { + return true + } + let wrappedMin = wrapLongitude(minLonQ) + let wrappedMax = wrapLongitude(maxLonQ) + if wrappedMin <= wrappedMax { + return wrappedMax >= minLon && wrappedMin <= maxLon + } + return maxLon >= wrappedMin || minLon <= wrappedMax + } + + private func ensureCapacity(_ handle: Int) { + guard handle >= cellOf.count else { + return + } + cellOf.append(contentsOf: repeatElement(-1, count: handle + 1 - cellOf.count)) + } + + private func removeFromCell(_ handle: Int, cell: Int) { + guard let position = cells[cell].firstIndex(of: Int32(handle)) else { + return + } + cells[cell].swapAt(position, cells[cell].count - 1) + cells[cell].removeLast() + } + + private func contains(latitude: Double, longitude: Double) -> Bool { + latitude >= minLat && latitude <= maxLat && longitude >= minLon && longitude <= maxLon + } + private func longitudeColumnRange(minLon: Double, maxLon: Double) -> [Int] { if maxLon - minLon >= 360.0 { return Array(0.. Int { - clampedRow(lat) * cellsPerSide + clampedColumn(lon) + private func clampedCellIndex(latitude: Double, longitude: Double) -> Int { + clampedRow(latitude) * cellsPerSide + clampedColumn(longitude) } private func clampedRow(_ lat: Double) -> Int { diff --git a/package/ios/MarkerStore.swift b/package/ios/MarkerStore.swift new file mode 100644 index 0000000..797f99b --- /dev/null +++ b/package/ios/MarkerStore.swift @@ -0,0 +1,265 @@ +import Foundation + +protocol MarkerStoreListener: AnyObject { + /// Delivered on the main thread after a batch has been applied. + func markerStoreDidChange(_ store: MarkerStore) +} + +/// Read access to the store's arrays. Only valid inside `MarkerStore.read`; the +/// arrays are copy-on-write, so holding them longer is safe but costs the next +/// batch a copy. +struct MarkerStoreAccess { + let latitudes: [Double] + let longitudes: [Double] + let flags: [UInt8] + let descriptors: [MarkerDescriptor?] + let versions: [Int] + let index: MarkerSpatialIndex + let count: Int + + func isAlive(_ handle: Int) -> Bool { + handle >= 0 && handle < flags.count && flags[handle] & MarkerStore.Flag.alive != 0 + } + + func aliveHandles() -> [Int32] { + var handles: [Int32] = [] + handles.reserveCapacity(count) + for handle in 0...weakObjects() + private let index = MarkerSpatialIndex() + private var latitudes: [Double] = [] + private var longitudes: [Double] = [] + private var flags: [UInt8] = [] + private var descriptors: [MarkerDescriptor?] = [] + private var versions: [Int] = [] + /// At most one listener notification is queued on the main thread at a time. + private var isNotificationPending = false + private var count = 0 + private var nextVersion = 1 + + var markerCount: Int { + lock.lock() + defer { lock.unlock() } + return count + } + + /// Rough resident size, reported to the JS garbage collector. + var estimatedBytes: Int { + lock.lock() + defer { lock.unlock() } + return flags.count * (MemoryLayout.size * 2 + MemoryLayout.size + 1) + + count * 400 + } + + // MARK: - Listeners (main thread) + + func addListener(_ listener: MarkerStoreListener) { + listeners.add(listener) + } + + func removeListener(_ listener: MarkerStoreListener) { + listeners.remove(listener) + } + + // MARK: - Writes + + /// Applies an owned copy of a batch on the store queue, after every batch + /// enqueued before it. + func enqueue(batch bytes: [UInt8], strings: [String]) { + queue.async { [self] in + apply(bytes: bytes, strings: strings) + notifyListeners() + } + } + + func enqueueClear() { + queue.async { [self] in + lock.lock() + removeAllLocked() + lock.unlock() + notifyListeners() + } + } + + // MARK: - Reads + + func read(_ body: (MarkerStoreAccess) -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body(MarkerStoreAccess( + latitudes: latitudes, + longitudes: longitudes, + flags: flags, + descriptors: descriptors, + versions: versions, + index: index, + count: count + )) + } + + func ids(for handles: [Int32]) -> [String] { + read { access in + handles.compactMap { handle in + let index = Int(handle) + guard index < access.descriptors.count else { + return nil + } + return access.descriptors[index]?.id + } + } + } + + // MARK: - Batch application + + private func apply(bytes: [UInt8], strings: [String]) { + let signpost = MapTrace.begin("applyMarkerBatch") + defer { MapTrace.end("applyMarkerBatch", signpost) } + + lock.lock() + defer { lock.unlock() } + do { + try bytes.withUnsafeBytes { raw in + try MarkerBatchDecoder.decode( + raw, + strings: strings, + onRemove: { handle in self.removeLocked(handle) }, + onUpsert: { handle, descriptor in self.upsertLocked(handle, descriptor) }, + onPosition: { handle, latitude, longitude in + self.moveLocked(handle, latitude: latitude, longitude: longitude) + } + ) + } + } catch { + // The header was validated on the JS thread; a failure here means the + // bytes changed underneath us, which the copy rules out. + return + } + index.rebuildIfNeeded(latitudes: latitudes, longitudes: longitudes, flags: flags) + } + + private func upsertLocked(_ handle: Int, _ descriptor: MarkerDescriptor) { + // JS hands out handles densely, so a valid batch never asks for more than + // a bounded step past the current arrays; a corrupt one is dropped here + // instead of growing five arrays to whatever it says. + guard handle >= 0, handle < Self.maximumHandle, handle <= flags.count + Self.maximumHandleStep else { + return + } + ensureCapacityLocked(handle) + + let latitude = descriptor.coordinate.latitude + let longitude = descriptor.coordinate.longitude + if flags[handle] & Flag.alive != 0 { + index.move(handle, latitude: latitude, longitude: longitude) + } else { + index.insert(handle, latitude: latitude, longitude: longitude) + count += 1 + } + + latitudes[handle] = latitude + longitudes[handle] = longitude + flags[handle] = Flag.alive | (descriptor.clusterable == false ? 0 : Flag.clusterable) + descriptors[handle] = descriptor + versions[handle] = nextVersion + nextVersion &+= 1 + } + + private func removeLocked(_ handle: Int) { + guard handle >= 0, handle < flags.count, flags[handle] & Flag.alive != 0 else { + return + } + index.remove(handle) + flags[handle] = 0 + descriptors[handle] = nil + count -= 1 + } + + private func moveLocked(_ handle: Int, latitude: Double, longitude: Double) { + guard handle >= 0, handle < flags.count, flags[handle] & Flag.alive != 0 else { + return + } + index.move(handle, latitude: latitude, longitude: longitude) + latitudes[handle] = latitude + longitudes[handle] = longitude + descriptors[handle]?.coordinate = Coordinate(latitude: latitude, longitude: longitude) + versions[handle] = nextVersion + nextVersion &+= 1 + } + + private func removeAllLocked() { + latitudes.removeAll() + longitudes.removeAll() + flags.removeAll() + descriptors.removeAll() + versions.removeAll() + index.removeAll() + count = 0 + } + + private func ensureCapacityLocked(_ handle: Int) { + guard handle >= flags.count else { + return + } + let target = max(handle + 1, flags.count * 2, 64) + let extra = target - flags.count + latitudes.append(contentsOf: repeatElement(0, count: extra)) + longitudes.append(contentsOf: repeatElement(0, count: extra)) + flags.append(contentsOf: repeatElement(0, count: extra)) + descriptors.append(contentsOf: repeatElement(nil, count: extra)) + versions.append(contentsOf: repeatElement(0, count: extra)) + } + + /// Delivers one notification per burst of batches: a stream of position + /// updates does not queue one full diff per batch on the main thread. + private func notifyListeners() { + lock.lock() + let alreadyPending = isNotificationPending + isNotificationPending = true + lock.unlock() + guard !alreadyPending else { + return + } + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + self.lock.lock() + self.isNotificationPending = false + self.lock.unlock() + let listeners = self.listeners.allObjects + guard !listeners.isEmpty else { + return + } + for case let listener as MarkerStoreListener in listeners { + listener.markerStoreDidChange(self) + } + } + } +} diff --git a/package/ios/MarkerViewportFilter.swift b/package/ios/MarkerViewportFilter.swift index e82c991..3c284a8 100644 --- a/package/ios/MarkerViewportFilter.swift +++ b/package/ios/MarkerViewportFilter.swift @@ -6,27 +6,40 @@ enum MarkerViewportFilter { /// /// The caller (spatial index) has already restricted `candidates` to cells /// near the region, so this runs over a small set and is safe to call off the - /// main thread. + /// main thread. Coordinates come from the store's flat arrays. static func displaySubset( - candidates: [MarkerDescriptor], + candidates: [Int32], + latitudes: [Double], + longitudes: [Double], region: MKCoordinateRegion - ) -> [MarkerDescriptor] { + ) -> [Int32] { let maxCount = maxMarkers(for: region.span.latitudeDelta) - let visible = candidates.filter { region.contains($0.coordinate, padding: 0.2) } + let bounds = PaddedBounds(region: region, padding: 0.2) + let visible = candidates.filter { handle in + bounds.contains(latitude: latitudes[Int(handle)], longitude: longitudes[Int(handle)]) + } guard visible.count > maxCount else { return visible } - return spatialSubsample(visible, maxCount: maxCount, region: region) + return spatialSubsample( + visible, + latitudes: latitudes, + longitudes: longitudes, + maxCount: maxCount, + region: region + ) } /// Picks at most one marker per geographic cell so subsampling stays visually even. private static func spatialSubsample( - _ markers: [MarkerDescriptor], + _ handles: [Int32], + latitudes: [Double], + longitudes: [Double], maxCount: Int, region: MKCoordinateRegion - ) -> [MarkerDescriptor] { + ) -> [Int32] { let columns = Int(ceil(sqrt(Double(maxCount)))) let rows = Int(ceil(Double(maxCount) / Double(columns))) @@ -34,17 +47,29 @@ enum MarkerViewportFilter { let latMax = region.center.latitude + region.span.latitudeDelta * 0.6 let lonMin = region.center.longitude - region.span.longitudeDelta * 0.6 let lonMax = region.center.longitude + region.span.longitudeDelta * 0.6 + // A window that leaves [-180, 180] holds markers whose longitude has + // wrapped to the other sign; measure their offset through the antimeridian. + let crossesAntimeridian = lonMin < -180 || lonMax > 180 let latStep = max(1e-9, (latMax - latMin) / Double(rows)) let lonStep = max(1e-9, (lonMax - lonMin) / Double(columns)) - var buckets: [String: [MarkerDescriptor]] = [:] + var buckets: [Int: [Int32]] = [:] buckets.reserveCapacity(maxCount) - for marker in markers { - let row = min(rows - 1, max(0, Int((marker.coordinate.latitude - latMin) / latStep))) - let column = min(columns - 1, max(0, Int((marker.coordinate.longitude - lonMin) / lonStep))) - buckets["\(row)-\(column)", default: []].append(marker) + for handle in handles { + let index = Int(handle) + let row = min(rows - 1, max(0, Int((latitudes[index] - latMin) / latStep))) + var lonOffset = longitudes[index] - lonMin + if crossesAntimeridian { + if lonOffset < 0 { + lonOffset += 360 + } else if lonOffset >= 360 { + lonOffset -= 360 + } + } + let column = min(columns - 1, max(0, Int(lonOffset / lonStep))) + buckets[row * columns + column, default: []].append(handle) } return buckets.values.map { cell in @@ -64,26 +89,49 @@ enum MarkerViewportFilter { } return 200 } -} -private extension MKCoordinateRegion { - func contains(_ coordinate: Coordinate, padding: Double) -> Bool { - let latPadding = span.latitudeDelta * padding - let lonPadding = span.longitudeDelta * padding - let minLat = center.latitude - span.latitudeDelta / 2 - latPadding - let maxLat = center.latitude + span.latitudeDelta / 2 + latPadding - let minLon = center.longitude - span.longitudeDelta / 2 - lonPadding - let maxLon = center.longitude + span.longitudeDelta / 2 + lonPadding + /// The region grown by `padding` on each side, with longitudes wrapped into + /// [-180, 180]: a region across the antimeridian ends up with `minLon` east + /// of `maxLon`, and `contains` reads that as the two-piece range it is. + private struct PaddedBounds { + let minLat: Double + let maxLat: Double + let minLon: Double + let maxLon: Double + let allLongitudes: Bool - let lonInRegion: Bool - if minLon <= maxLon { - lonInRegion = coordinate.longitude >= minLon && coordinate.longitude <= maxLon - } else { - lonInRegion = coordinate.longitude >= minLon || coordinate.longitude <= maxLon + init(region: MKCoordinateRegion, padding: Double) { + let latPadding = region.span.latitudeDelta * padding + let lonPadding = region.span.longitudeDelta * padding + minLat = region.center.latitude - region.span.latitudeDelta / 2 - latPadding + maxLat = region.center.latitude + region.span.latitudeDelta / 2 + latPadding + let paddedSpan = region.span.longitudeDelta + lonPadding * 2 + allLongitudes = paddedSpan >= 360 + minLon = Self.wrap(region.center.longitude - region.span.longitudeDelta / 2 - lonPadding) + maxLon = Self.wrap(region.center.longitude + region.span.longitudeDelta / 2 + lonPadding) } - return coordinate.latitude >= minLat - && coordinate.latitude <= maxLat - && lonInRegion + func contains(latitude: Double, longitude: Double) -> Bool { + let lonInRegion: Bool + if allLongitudes { + lonInRegion = true + } else if minLon <= maxLon { + lonInRegion = longitude >= minLon && longitude <= maxLon + } else { + lonInRegion = longitude >= minLon || longitude <= maxLon + } + return latitude >= minLat && latitude <= maxLat && lonInRegion + } + + private static func wrap(_ longitude: Double) -> Double { + var wrapped = longitude + while wrapped > 180 { + wrapped -= 360 + } + while wrapped < -180 { + wrapped += 360 + } + return wrapped + } } } diff --git a/package/nitro.json b/package/nitro.json index b86f88e..5d941ff 100644 --- a/package/nitro.json +++ b/package/nitro.json @@ -18,6 +18,16 @@ "language": "kotlin", "implementationClassName": "HybridMapView" } + }, + "MarkerCollection": { + "ios": { + "language": "swift", + "implementationClassName": "HybridMarkerCollection" + }, + "android": { + "language": "kotlin", + "implementationClassName": "HybridMarkerCollection" + } } } } diff --git a/package/src/components/MapView.tsx b/package/src/components/MapView.tsx index 4d0ec7d..70f25b6 100644 --- a/package/src/components/MapView.tsx +++ b/package/src/components/MapView.tsx @@ -1,6 +1,8 @@ import { useCallback, + useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, type Ref, @@ -9,6 +11,10 @@ import { import { useCollectedOverlays } from '../hooks/useCollectedOverlays'; import { useNitroCallback } from '../hooks/useNitroCallback'; import { useStableValue } from '../hooks/useStableValue'; +import { + MarkerCollection, + markerCollectionInternals, +} from '../markers/MarkerCollection'; import { NativeMapView } from '../native/MapViewNative'; import type { MapView as NativeMapViewHybrid, @@ -36,6 +42,19 @@ import { const MAP_VIEW_NOT_MOUNTED_ERROR = 'MapView is not mounted'; +let didWarnAboutIgnoredMarkers = false; + +function warnOnceAboutIgnoredMarkers(): void { + if (didWarnAboutIgnoredMarkers) { + return; + } + didWarnAboutIgnoredMarkers = true; + console.warn( + 'MapView: `markers` and children are ignored while `markerCollection` is set. ' + + 'Put those markers into the collection instead.', + ); +} + function withHybridRef( hybridRef: RefObject, run: (hybrid: NativeMapViewHybrid) => T, @@ -71,6 +90,7 @@ export function MapView({ markerEnteringAnimation, clusterEnteringAnimation, markers: markersProp, + markerCollection: markerCollectionProp, polylines: polylinesProp, polygons: polygonsProp, circles: circlesProp, @@ -143,19 +163,48 @@ export function MapView({ const stableCamera = useStableValue(camera, camerasEqual); const stableMapPadding = useStableValue(mapPadding, edgePaddingsEqual); + // `markers` and `` children are sugar over a MarkerCollection owned + // here: the stable array above is compiled to a delta batch after commit, + // so a new array only ships the markers that changed. A caller-provided + // collection replaces the sugar entirely. + const usesMarkerSugar = markerCollectionProp == null; + const sugarCollection = useMemo( + () => (usesMarkerSugar ? new MarkerCollection() : null), + [usesMarkerSugar], + ); + useLayoutEffect(() => { + if (sugarCollection != null) { + markerCollectionInternals(sugarCollection).setNormalized(markers); + } + }, [markers, sugarCollection]); + useEffect( + () => () => { + sugarCollection?.clear(); + }, + [sugarCollection], + ); + if (__DEV__ && markerCollectionProp != null && markers.length > 0) { + warnOnceAboutIgnoredMarkers(); + } + const activeCollection = markerCollectionProp ?? sugarCollection; + const nativeMarkerCollection = + activeCollection != null + ? markerCollectionInternals(activeCollection).native + : undefined; + + // `` children are ignored while a collection is passed, so their + // callbacks must not fire for a collection marker that shares an id. const hasMarkerPress = - onMarkerPressProp != null || hasCollectedMarkerPress; + onMarkerPressProp != null || (usesMarkerSugar && hasCollectedMarkerPress); const hasMarkerDragEnd = - onMarkerDragEndProp != null || hasCollectedMarkerDragEnd; + onMarkerDragEndProp != null || + (usesMarkerSugar && hasCollectedMarkerDragEnd); const hasPolylinePressHandler = onPolylinePressProp != null || hasPolylinePress; - const hasPolygonPressHandler = - onPolygonPressProp != null || hasPolygonPress; - const hasCirclePressHandler = - onCirclePressProp != null || hasCirclePress; + const hasPolygonPressHandler = onPolygonPressProp != null || hasPolygonPress; + const hasCirclePressHandler = onCirclePressProp != null || hasCirclePress; const onPoiPressCallback = onPoiPress as - | ((event: PoiPressEvent) => void) - | undefined; + ((event: PoiPressEvent) => void) | undefined; const handleHybridRef = useCallback((nativeRef: NativeMapViewHybrid) => { hybridRef.current = nativeRef; @@ -163,23 +212,33 @@ export function MapView({ const handleMarkerPress = useCallback( (id: string) => { - callbackRegistry.current.get(overlayCallbackKey(OverlayType.Marker, id))?.onPress?.(); + if (usesMarkerSugar) { + callbackRegistry.current + .get(overlayCallbackKey(OverlayType.Marker, id)) + ?.onPress?.(); + } onMarkerPressProp?.(id); }, - [callbackRegistry, onMarkerPressProp], + [callbackRegistry, onMarkerPressProp, usesMarkerSugar], ); const handleMarkerDragEnd = useCallback( (id: string, coordinate: Coordinate) => { - callbackRegistry.current.get(overlayCallbackKey(OverlayType.Marker, id))?.onDragEnd?.(coordinate); + if (usesMarkerSugar) { + callbackRegistry.current + .get(overlayCallbackKey(OverlayType.Marker, id)) + ?.onDragEnd?.(coordinate); + } onMarkerDragEndProp?.(id, coordinate); }, - [callbackRegistry, onMarkerDragEndProp], + [callbackRegistry, onMarkerDragEndProp, usesMarkerSugar], ); const handlePolylinePress = useCallback( (id: string) => { - callbackRegistry.current.get(overlayCallbackKey(OverlayType.Polyline, id))?.onPress?.(); + callbackRegistry.current + .get(overlayCallbackKey(OverlayType.Polyline, id)) + ?.onPress?.(); onPolylinePressProp?.(id); }, [callbackRegistry, onPolylinePressProp], @@ -187,7 +246,9 @@ export function MapView({ const handlePolygonPress = useCallback( (id: string) => { - callbackRegistry.current.get(overlayCallbackKey(OverlayType.Polygon, id))?.onPress?.(); + callbackRegistry.current + .get(overlayCallbackKey(OverlayType.Polygon, id)) + ?.onPress?.(); onPolygonPressProp?.(id); }, [callbackRegistry, onPolygonPressProp], @@ -195,7 +256,9 @@ export function MapView({ const handleCirclePress = useCallback( (id: string) => { - callbackRegistry.current.get(overlayCallbackKey(OverlayType.Circle, id))?.onPress?.(); + callbackRegistry.current + .get(overlayCallbackKey(OverlayType.Circle, id)) + ?.onPress?.(); onCirclePressProp?.(id); }, [callbackRegistry, onCirclePressProp], @@ -215,7 +278,11 @@ export function MapView({ return; } - if (event.provider === 'google' && event.name != null && event.placeId != null) { + if ( + event.provider === 'google' && + event.name != null && + event.placeId != null + ) { const poiEvent: PoiPressEvent = { provider: 'google', coordinate: event.coordinate, @@ -276,6 +343,10 @@ export function MapView({ withHybridRef(hybridRef, (hybrid) => hybrid.fitToCoordinates(coordinates, padding, animated), ), + getClusterMembers: (clusterId) => + withHybridRef(hybridRef, (hybrid) => + hybrid.getClusterMembers(clusterId), + ), }), [], ); @@ -303,7 +374,7 @@ export function MapView({ mapPadding={stableMapPadding} markerEnteringAnimation={markerEntering} clusterEnteringAnimation={clusterEntering} - markers={markers} + markerCollection={nativeMarkerCollection} polylines={polylines} polygons={polygons} circles={circles} diff --git a/package/src/index.ts b/package/src/index.ts index be56c29..a8b7f33 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -7,6 +7,8 @@ export { Geojson, } from './components'; export { geojsonToOverlayDescriptors } from './geojson/geojsonToDescriptors'; +export { MarkerCollection, useMarkerCollection } from './markers'; +export type { MarkerPositionUpdate } from './markers'; export type { Coordinate, @@ -16,6 +18,7 @@ export type { VisibleRegion, ApplePoiCategory, ApplePoiPressEvent, + ClusterPressEvent, GooglePoiPressEvent, MapProvider, MapType, diff --git a/package/src/markers/MarkerCollection.ts b/package/src/markers/MarkerCollection.ts new file mode 100644 index 0000000..9f6b5ef --- /dev/null +++ b/package/src/markers/MarkerCollection.ts @@ -0,0 +1,115 @@ +import { NitroModules } from 'react-native-nitro-modules'; +import type { MarkerCollection as NativeMarkerCollection } from '../native/specs/MarkerCollection.nitro'; +import type { MarkerDescriptor as NativeMarkerDescriptor } from '../native/specs/overlays'; +import { normalizeMarkerDescriptors } from '../overlays/normalizeMarkerDescriptors'; +import type { MarkerDescriptor } from '../types/overlays'; +import type { MarkerBatch } from './markerBatch'; +import { + MarkerDeltaCompiler, + type MarkerPositionUpdate, +} from './markerDeltaCompiler'; + +export type { MarkerPositionUpdate } from './markerDeltaCompiler'; + +/** What `MapView` needs from a collection and application code does not. */ +export interface MarkerCollectionInternals { + native: NativeMarkerCollection; + /** Like `set`, for descriptors that are already normalized. */ + setNormalized(descriptors: NativeMarkerDescriptor[]): void; +} + +const internals = new WeakMap(); + +/** + * A marker dataset owned by native code and updated through deltas. + * + * Pass it to `MapView` through the `markerCollection` prop, then keep it up to + * date with `set`, `upsert`, `remove` and `updatePositions`. Every call sends + * one packed batch across JSI that only carries what changed, so a single + * marker moving in a 100,000-marker dataset costs one 24-byte record instead + * of re-serializing the whole array. + * + * The `markers` prop and `` children compile to the same batches + * through an internal collection, so they stay the simplest option for small + * or mostly static datasets. + */ +export class MarkerCollection { + private readonly compiler = new MarkerDeltaCompiler(); + private readonly native: NativeMarkerCollection; + + constructor() { + this.native = + NitroModules.createHybridObject( + 'MarkerCollection', + ); + internals.set(this, { + native: this.native, + setNormalized: (descriptors) => + this.flush(this.compiler.set(descriptors)), + }); + } + + /** Number of markers in the collection. */ + get size(): number { + return this.compiler.size; + } + + has(id: string): boolean { + return this.compiler.has(id); + } + + /** Every marker id in the collection, in insertion order. */ + ids(): string[] { + return this.compiler.ids(); + } + + /** + * Replaces the whole dataset. Markers that are unchanged compared to the + * last call are not sent again; markers missing from `markers` are removed. + */ + set(markers: MarkerDescriptor[]): void { + this.flush(this.compiler.set(normalizeMarkerDescriptors(markers))); + } + + /** Adds new markers and updates existing ones by id. */ + upsert(markers: MarkerDescriptor[]): void { + this.flush(this.compiler.upsert(normalizeMarkerDescriptors(markers))); + } + + /** Removes markers by id. Unknown ids are ignored. */ + remove(ids: string[]): void { + this.flush(this.compiler.remove(ids)); + } + + /** + * Moves markers without changing anything else about them. This is the path + * for animated or live-updating markers: no strings, no descriptor rebuild, + * one small record per marker. + */ + updatePositions(updates: MarkerPositionUpdate[]): void { + this.flush(this.compiler.updatePositions(updates)); + } + + /** Removes every marker. */ + clear(): void { + this.compiler.clear(); + this.native.clear(); + } + + private flush(batch: MarkerBatch | null): void { + if (batch != null) { + this.native.applyBatch(batch.buffer, batch.strings); + } + } +} + +/** @internal */ +export function markerCollectionInternals( + collection: MarkerCollection, +): MarkerCollectionInternals { + const found = internals.get(collection); + if (found == null) { + throw new Error('Not a MarkerCollection'); + } + return found; +} diff --git a/package/src/markers/__tests__/markerBatch.test.ts b/package/src/markers/__tests__/markerBatch.test.ts new file mode 100644 index 0000000..e09ce77 --- /dev/null +++ b/package/src/markers/__tests__/markerBatch.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from 'bun:test'; +import type { MarkerDescriptor } from '../../native/specs/overlays'; +import { + MARKER_BATCH_HEADER_BYTES, + MARKER_BATCH_MAGIC, + MarkerBatchWriter, + POSITION_RECORD_BYTES, + REMOVE_RECORD_BYTES, + UPSERT_RECORD_BYTES, + decodeMarkerBatch, +} from '../markerBatch'; + +const full: MarkerDescriptor = { + id: 'full', + coordinate: { latitude: 52.2297, longitude: 21.0122 }, + title: 'Warsaw', + subtitle: 'Capital', + draggable: true, + clusterable: false, + image: { + uri: 'https://example.com/pin.png', + width: 32, + height: 40, + scale: 2, + }, + anchor: { x: 0.5, y: 1 }, + centerOffset: { x: 4, y: -8 }, + rotation: 45, + flat: true, + opacity: 0.75, + markerColor: '#FF9500', + zIndex: 3, + enteringAnimation: { + kind: 'fade-scale', + duration: 180, + delay: 20, + reduceMotion: 'never', + }, +}; + +const minimal: MarkerDescriptor = { + id: 'minimal', + coordinate: { latitude: -33.8688, longitude: 151.2093 }, +}; + +describe('MarkerBatchWriter', () => { + test('returns null when nothing was recorded', () => { + expect(new MarkerBatchWriter().finish()).toBeNull(); + }); + + test('lays records out behind the header in the documented order', () => { + const writer = new MarkerBatchWriter(); + writer.upsert(3, minimal); + writer.position(7, { latitude: 1, longitude: 2 }); + writer.remove(9); + writer.upsert(4, minimal); + const batch = writer.finish(); + + expect(batch).not.toBeNull(); + expect(batch!.buffer.byteLength).toBe( + MARKER_BATCH_HEADER_BYTES + + 2 * UPSERT_RECORD_BYTES + + REMOVE_RECORD_BYTES + + POSITION_RECORD_BYTES, + ); + const view = new DataView(batch!.buffer); + expect(view.getUint32(0, true)).toBe(MARKER_BATCH_MAGIC); + expect(view.getUint32(4, true)).toBe(2); + expect(view.getUint32(8, true)).toBe(1); + expect(view.getUint32(12, true)).toBe(1); + + const decoded = decodeMarkerBatch(batch!); + expect(decoded.upserts.map((upsert) => upsert.handle)).toEqual([3, 4]); + expect(decoded.removes).toEqual([9]); + expect(decoded.positions).toEqual([ + { handle: 7, coordinate: { latitude: 1, longitude: 2 } }, + ]); + }); + + test('round-trips every field of a full descriptor', () => { + const writer = new MarkerBatchWriter(); + writer.upsert(0, full); + const decoded = decodeMarkerBatch(writer.finish()!); + + expect(decoded.upserts).toHaveLength(1); + const { descriptor } = decoded.upserts[0]; + expect(descriptor.id).toBe('full'); + expect(descriptor.coordinate).toEqual(full.coordinate); + expect(descriptor.title).toBe('Warsaw'); + expect(descriptor.subtitle).toBe('Capital'); + expect(descriptor.draggable).toBe(true); + expect(descriptor.clusterable).toBe(false); + expect(descriptor.image).toEqual(full.image); + expect(descriptor.anchor).toEqual({ x: 0.5, y: 1 }); + expect(descriptor.centerOffset).toEqual({ x: 4, y: -8 }); + expect(descriptor.rotation).toBe(45); + expect(descriptor.flat).toBe(true); + expect(descriptor.opacity).toBeCloseTo(0.75, 6); + expect(descriptor.enteringAnimation).toEqual(full.enteringAnimation); + }); + + test('keeps absent optionals absent', () => { + const writer = new MarkerBatchWriter(); + writer.upsert(1, minimal); + const { descriptor } = decodeMarkerBatch(writer.finish()!).upserts[0]; + + expect(descriptor.title).toBeUndefined(); + expect(descriptor.subtitle).toBeUndefined(); + expect(descriptor.image).toBeUndefined(); + expect(descriptor.anchor).toBeUndefined(); + expect(descriptor.centerOffset).toBeUndefined(); + expect(descriptor.rotation).toBeUndefined(); + expect(descriptor.opacity).toBeUndefined(); + expect(descriptor.enteringAnimation).toBeUndefined(); + expect(descriptor.draggable).toBeUndefined(); + expect(descriptor.clusterable).toBeUndefined(); + expect(descriptor.flat).toBeUndefined(); + }); + + test('distinguishes zero from absent for floats and points', () => { + const writer = new MarkerBatchWriter(); + writer.upsert(1, { + ...minimal, + rotation: 0, + opacity: 0, + anchor: { x: 0, y: 0 }, + image: { uri: 'asset:/pin.png' }, + enteringAnimation: { kind: 'fade', duration: 0 }, + }); + const { descriptor } = decodeMarkerBatch(writer.finish()!).upserts[0]; + + expect(descriptor.rotation).toBe(0); + expect(descriptor.opacity).toBe(0); + expect(descriptor.anchor).toEqual({ x: 0, y: 0 }); + expect(descriptor.image).toEqual({ + uri: 'asset:/pin.png', + width: undefined, + height: undefined, + scale: undefined, + }); + expect(descriptor.enteringAnimation).toEqual({ + kind: 'fade', + duration: 0, + delay: undefined, + reduceMotion: undefined, + }); + }); + + test('sends each distinct string once', () => { + const writer = new MarkerBatchWriter(); + writer.upsert(0, { + ...minimal, + id: 'a', + title: 'Shared', + image: { uri: 'asset:/pin.png' }, + }); + writer.upsert(1, { + ...minimal, + id: 'b', + title: 'Shared', + image: { uri: 'asset:/pin.png' }, + }); + const batch = writer.finish()!; + + expect(batch.strings).toEqual(['a', 'Shared', 'asset:/pin.png', 'b']); + const decoded = decodeMarkerBatch(batch); + expect(decoded.upserts[1].descriptor.title).toBe('Shared'); + expect(decoded.upserts[1].descriptor.image?.uri).toBe('asset:/pin.png'); + }); + + test('grows past its initial capacity', () => { + const writer = new MarkerBatchWriter(); + const count = 1_000; + for (let index = 0; index < count; index += 1) { + writer.upsert(index, { ...minimal, id: `m-${index}` }); + writer.position(index, { latitude: index, longitude: -index }); + } + const decoded = decodeMarkerBatch(writer.finish()!); + + expect(decoded.upserts).toHaveLength(count); + expect(decoded.positions).toHaveLength(count); + expect(decoded.upserts[count - 1].descriptor.id).toBe(`m-${count - 1}`); + expect(decoded.positions[count - 1].coordinate).toEqual({ + latitude: count - 1, + longitude: -(count - 1), + }); + }); + + test('rejects buffers that are not batches', () => { + expect(() => + decodeMarkerBatch({ + buffer: new ArrayBuffer(16), + strings: [], + upsertCount: 0, + removeCount: 0, + positionCount: 0, + }), + ).toThrow('Not a marker batch'); + }); +}); diff --git a/package/src/markers/__tests__/markerDeltaCompiler.test.ts b/package/src/markers/__tests__/markerDeltaCompiler.test.ts new file mode 100644 index 0000000..2c70f48 --- /dev/null +++ b/package/src/markers/__tests__/markerDeltaCompiler.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test'; +import type { MarkerDescriptor } from '../../native/specs/overlays'; +import { decodeMarkerBatch, type MarkerBatch } from '../markerBatch'; +import { MarkerDeltaCompiler } from '../markerDeltaCompiler'; + +function marker( + id: string, + latitude = 1, + longitude = 2, + title?: string, +): MarkerDescriptor { + return { id, coordinate: { latitude, longitude }, title }; +} + +function decoded(batch: MarkerBatch | null) { + return batch == null ? null : decodeMarkerBatch(batch); +} + +describe('MarkerDeltaCompiler', () => { + test('set sends every marker the first time and nothing when repeated', () => { + const compiler = new MarkerDeltaCompiler(); + const first = decoded(compiler.set([marker('a'), marker('b')])); + + expect(first?.upserts.map((upsert) => upsert.handle)).toEqual([0, 1]); + expect(first?.removes).toEqual([]); + expect(compiler.size).toBe(2); + + expect(compiler.set([marker('a'), marker('b')])).toBeNull(); + // A structurally equal rebuild is also a no-op. + expect(compiler.set([marker('a', 1, 2), marker('b', 1, 2)])).toBeNull(); + }); + + test('set sends only the changed markers', () => { + const compiler = new MarkerDeltaCompiler(); + compiler.set([marker('a'), marker('b'), marker('c')]); + const batch = decoded( + compiler.set([marker('a'), marker('b', 5, 6), marker('c')]), + ); + + expect(batch?.upserts).toHaveLength(1); + expect(batch?.upserts[0].handle).toBe(1); + expect(batch?.upserts[0].descriptor.coordinate).toEqual({ + latitude: 5, + longitude: 6, + }); + expect(batch?.removes).toEqual([]); + }); + + test('set removes markers missing from the array and reuses their handles', () => { + const compiler = new MarkerDeltaCompiler(); + compiler.set([marker('a'), marker('b'), marker('c')]); + const removal = decoded(compiler.set([marker('a'), marker('c')])); + + expect(removal?.removes).toEqual([1]); + expect(removal?.upserts).toEqual([]); + expect(compiler.has('b')).toBe(false); + + const reuse = decoded( + compiler.set([marker('a'), marker('c'), marker('d')]), + ); + expect(reuse?.upserts.map((upsert) => upsert.handle)).toEqual([1]); + expect(reuse?.upserts[0].descriptor.id).toBe('d'); + }); + + test('set reuses the handles of the markers it replaces', () => { + const compiler = new MarkerDeltaCompiler(); + compiler.set([marker('a'), marker('b'), marker('c')]); + const replaced = decoded( + compiler.set([marker('d'), marker('e'), marker('f')]), + ); + + expect(replaced?.removes.sort()).toEqual([0, 1, 2]); + expect(replaced?.upserts.map((upsert) => upsert.handle).sort()).toEqual([ + 0, 1, 2, + ]); + expect(compiler.ids()).toEqual(['d', 'e', 'f']); + }); + + test('set keeps the first descriptor when an id repeats', () => { + const compiler = new MarkerDeltaCompiler(); + const batch = decoded( + compiler.set([marker('a', 1, 1, 'first'), marker('a', 2, 2, 'second')]), + ); + + expect(batch?.upserts).toHaveLength(1); + expect(batch?.upserts[0].descriptor.title).toBe('first'); + expect(compiler.size).toBe(1); + }); + + test('upsert adds and updates without removing', () => { + const compiler = new MarkerDeltaCompiler(); + compiler.set([marker('a'), marker('b')]); + const batch = decoded(compiler.upsert([marker('a', 9, 9), marker('c')])); + + expect( + batch?.upserts.map((upsert) => [upsert.handle, upsert.descriptor.id]), + ).toEqual([ + [0, 'a'], + [2, 'c'], + ]); + expect(batch?.removes).toEqual([]); + expect(compiler.ids()).toEqual(['a', 'b', 'c']); + expect(compiler.upsert([marker('a', 9, 9)])).toBeNull(); + }); + + test('remove ignores unknown ids', () => { + const compiler = new MarkerDeltaCompiler(); + compiler.set([marker('a'), marker('b')]); + + expect(decoded(compiler.remove(['b', 'missing']))?.removes).toEqual([1]); + expect(compiler.remove(['missing'])).toBeNull(); + expect(compiler.size).toBe(1); + }); + + test('updatePositions writes position records and updates the stored coordinate', () => { + const compiler = new MarkerDeltaCompiler(); + compiler.set([marker('a'), marker('b')]); + const batch = decoded( + compiler.updatePositions([ + { id: 'a', coordinate: { latitude: 3, longitude: 4 } }, + { id: 'b', coordinate: { latitude: 1, longitude: 2 } }, + { id: 'missing', coordinate: { latitude: 0, longitude: 0 } }, + ]), + ); + + expect(batch?.positions).toEqual([ + { handle: 0, coordinate: { latitude: 3, longitude: 4 } }, + ]); + expect(batch?.upserts).toEqual([]); + expect(compiler.get('a')?.coordinate).toEqual({ + latitude: 3, + longitude: 4, + }); + // The moved marker is now equal to a matching descriptor, so `set` is quiet. + expect(compiler.set([marker('a', 3, 4), marker('b')])).toBeNull(); + }); + + test('clear forgets handles', () => { + const compiler = new MarkerDeltaCompiler(); + compiler.set([marker('a'), marker('b')]); + compiler.clear(); + + expect(compiler.size).toBe(0); + expect(decoded(compiler.set([marker('z')]))?.upserts[0].handle).toBe(0); + }); +}); diff --git a/package/src/markers/index.ts b/package/src/markers/index.ts new file mode 100644 index 0000000..904635d --- /dev/null +++ b/package/src/markers/index.ts @@ -0,0 +1,6 @@ +export { + MarkerCollection, + markerCollectionInternals, +} from './MarkerCollection'; +export type { MarkerPositionUpdate } from './MarkerCollection'; +export { useMarkerCollection } from './useMarkerCollection'; diff --git a/package/src/markers/markerBatch.ts b/package/src/markers/markerBatch.ts new file mode 100644 index 0000000..bb2acc7 --- /dev/null +++ b/package/src/markers/markerBatch.ts @@ -0,0 +1,504 @@ +import type { + MarkerDescriptor, + OverlayEnteringAnimationDescriptor, + OverlayEnteringAnimationKind, + OverlayEnteringAnimationReduceMotion, +} from '../native/specs/overlays'; +import type { Coordinate } from '../types/coordinate'; + +/** + * Wire format between `MarkerCollection` (JS) and the native marker store. + * + * One batch is one `ArrayBuffer` plus a string table. Numbers are + * little-endian and every record has a fixed size, so the native decoders are + * a straight loop over offsets with no per-field branching: + * + * ``` + * header 16 bytes magic u32 · upsertCount u32 · removeCount u32 · positionCount u32 + * upserts 96 bytes see `UpsertOffset` + * removes 4 bytes handle u32 + * positions 24 bytes handle u32 · padding u32 · latitude f64 · longitude f64 + * ``` + * + * Strings (id, title, subtitle, image uri, marker color) are indices into the string table, + * `-1` when absent; each distinct string is sent once per batch. Optional + * floats are `NaN` when absent. Booleans and the presence of `anchor` and + * `centerOffset` are bits in `flags`. + * + * The native side applies removals first, then upserts, then positions, so a + * handle freed by a removal may be reused by an upsert in the same batch. + * + * Keep in sync with `MarkerBatchDecoder.swift` and `MarkerBatchDecoder.kt`. + */ +export const MARKER_BATCH_MAGIC = 0x4e4d4b31; +export const MARKER_BATCH_HEADER_BYTES = 16; +export const UPSERT_RECORD_BYTES = 96; +export const REMOVE_RECORD_BYTES = 4; +export const POSITION_RECORD_BYTES = 24; +export const NO_STRING = -1; + +export const UpsertFlag = { + hasAnchor: 1 << 0, + hasCenterOffset: 1 << 1, + draggable: 1 << 16, + clusterable: 1 << 17, + flat: 1 << 18, +} as const; + +/** Byte offsets within an upsert record. */ +export const UpsertOffset = { + handle: 0, + flags: 4, + id: 8, + title: 12, + subtitle: 16, + imageUri: 20, + latitude: 24, + longitude: 32, + imageWidth: 40, + imageHeight: 44, + imageScale: 48, + anchorX: 52, + anchorY: 56, + centerOffsetX: 60, + centerOffsetY: 64, + rotation: 68, + opacity: 72, + animationDuration: 76, + animationDelay: 80, + animationKind: 84, + animationReduceMotion: 85, + markerColor: 88, + zIndex: 92, +} as const; + +const ANIMATION_KINDS: OverlayEnteringAnimationKind[] = [ + 'none', + 'system', + 'fade', + 'fade-scale', +]; + +const REDUCE_MOTION_VALUES: OverlayEnteringAnimationReduceMotion[] = [ + 'system', + 'never', +]; + +function animationKindCode(kind: OverlayEnteringAnimationKind): number { + return ANIMATION_KINDS.indexOf(kind) + 1; +} + +function reduceMotionCode( + value: OverlayEnteringAnimationReduceMotion | undefined, +): number { + return value == null ? 0 : REDUCE_MOTION_VALUES.indexOf(value) + 1; +} + +export interface MarkerBatch { + buffer: ArrayBuffer; + strings: string[]; + upsertCount: number; + removeCount: number; + positionCount: number; +} + +/** Append-only byte buffer that doubles its capacity when a record does not fit. */ +class RecordBuffer { + private buffer: ArrayBuffer; + private bytes: Uint8Array; + view: DataView; + length = 0; + + constructor(initialCapacity: number) { + this.buffer = new ArrayBuffer(initialCapacity); + this.bytes = new Uint8Array(this.buffer); + this.view = new DataView(this.buffer); + } + + /** Reserves `recordBytes` and returns the record's start offset. */ + reserve(recordBytes: number): number { + const offset = this.length; + const needed = offset + recordBytes; + if (needed > this.buffer.byteLength) { + const next = new ArrayBuffer( + Math.max(needed, this.buffer.byteLength * 2), + ); + new Uint8Array(next).set(this.bytes.subarray(0, this.length)); + this.buffer = next; + this.bytes = new Uint8Array(next); + this.view = new DataView(next); + } + this.length = needed; + return offset; + } + + copyInto(target: Uint8Array, offset: number): void { + target.set(this.bytes.subarray(0, this.length), offset); + } +} + +/** + * Builds one batch. Records are appended as they come; `finish()` lays them + * out in the fixed order the header describes. + */ +export class MarkerBatchWriter { + private readonly upserts = new RecordBuffer(UPSERT_RECORD_BYTES * 64); + private readonly positions = new RecordBuffer(POSITION_RECORD_BYTES * 64); + private readonly removes: number[] = []; + private readonly strings: string[] = []; + private readonly stringIndices = new Map(); + private upsertCount = 0; + private positionCount = 0; + + get isEmpty(): boolean { + return ( + this.upsertCount === 0 && + this.removes.length === 0 && + this.positionCount === 0 + ); + } + + upsert(handle: number, descriptor: MarkerDescriptor): void { + const base = this.upserts.reserve(UPSERT_RECORD_BYTES); + const view = this.upserts.view; + const image = descriptor.image; + const anchor = descriptor.anchor; + const centerOffset = descriptor.centerOffset; + const animation = descriptor.enteringAnimation; + + let flags = 0; + if (anchor != null) { + flags |= UpsertFlag.hasAnchor; + } + if (centerOffset != null) { + flags |= UpsertFlag.hasCenterOffset; + } + if (descriptor.draggable === true) { + flags |= UpsertFlag.draggable; + } + if (descriptor.clusterable !== false) { + flags |= UpsertFlag.clusterable; + } + if (descriptor.flat === true) { + flags |= UpsertFlag.flat; + } + + view.setUint32(base + UpsertOffset.handle, handle, true); + view.setUint32(base + UpsertOffset.flags, flags >>> 0, true); + view.setInt32(base + UpsertOffset.id, this.intern(descriptor.id), true); + view.setInt32( + base + UpsertOffset.title, + this.internOptional(descriptor.title), + true, + ); + view.setInt32( + base + UpsertOffset.subtitle, + this.internOptional(descriptor.subtitle), + true, + ); + view.setInt32( + base + UpsertOffset.imageUri, + this.internOptional(image?.uri), + true, + ); + view.setFloat64( + base + UpsertOffset.latitude, + descriptor.coordinate.latitude, + true, + ); + view.setFloat64( + base + UpsertOffset.longitude, + descriptor.coordinate.longitude, + true, + ); + view.setFloat32(base + UpsertOffset.imageWidth, image?.width ?? NaN, true); + view.setFloat32( + base + UpsertOffset.imageHeight, + image?.height ?? NaN, + true, + ); + view.setFloat32(base + UpsertOffset.imageScale, image?.scale ?? NaN, true); + view.setFloat32(base + UpsertOffset.anchorX, anchor?.x ?? 0, true); + view.setFloat32(base + UpsertOffset.anchorY, anchor?.y ?? 0, true); + view.setFloat32( + base + UpsertOffset.centerOffsetX, + centerOffset?.x ?? 0, + true, + ); + view.setFloat32( + base + UpsertOffset.centerOffsetY, + centerOffset?.y ?? 0, + true, + ); + view.setFloat32( + base + UpsertOffset.rotation, + descriptor.rotation ?? NaN, + true, + ); + view.setFloat32( + base + UpsertOffset.opacity, + descriptor.opacity ?? NaN, + true, + ); + view.setFloat32( + base + UpsertOffset.animationDuration, + animation?.duration ?? NaN, + true, + ); + view.setFloat32( + base + UpsertOffset.animationDelay, + animation?.delay ?? NaN, + true, + ); + view.setUint8( + base + UpsertOffset.animationKind, + animation == null ? 0 : animationKindCode(animation.kind), + ); + view.setUint8( + base + UpsertOffset.animationReduceMotion, + reduceMotionCode(animation?.reduceMotion), + ); + view.setInt32( + base + UpsertOffset.markerColor, + this.internOptional(descriptor.markerColor), + true, + ); + view.setFloat32(base + UpsertOffset.zIndex, descriptor.zIndex ?? NaN, true); + this.upsertCount += 1; + } + + remove(handle: number): void { + this.removes.push(handle); + } + + position(handle: number, coordinate: Coordinate): void { + const base = this.positions.reserve(POSITION_RECORD_BYTES); + const view = this.positions.view; + view.setUint32(base, handle, true); + view.setUint32(base + 4, 0, true); + view.setFloat64(base + 8, coordinate.latitude, true); + view.setFloat64(base + 16, coordinate.longitude, true); + this.positionCount += 1; + } + + /** Returns the packed batch, or `null` when nothing was recorded. */ + finish(): MarkerBatch | null { + if (this.isEmpty) { + return null; + } + + const removeBytes = this.removes.length * REMOVE_RECORD_BYTES; + const total = + MARKER_BATCH_HEADER_BYTES + + this.upserts.length + + removeBytes + + this.positions.length; + const buffer = new ArrayBuffer(total); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + + view.setUint32(0, MARKER_BATCH_MAGIC, true); + view.setUint32(4, this.upsertCount, true); + view.setUint32(8, this.removes.length, true); + view.setUint32(12, this.positionCount, true); + + let offset = MARKER_BATCH_HEADER_BYTES; + this.upserts.copyInto(bytes, offset); + offset += this.upserts.length; + for (const handle of this.removes) { + view.setUint32(offset, handle, true); + offset += REMOVE_RECORD_BYTES; + } + this.positions.copyInto(bytes, offset); + + return { + buffer, + strings: this.strings, + upsertCount: this.upsertCount, + removeCount: this.removes.length, + positionCount: this.positionCount, + }; + } + + private intern(value: string): number { + const existing = this.stringIndices.get(value); + if (existing != null) { + return existing; + } + const index = this.strings.length; + this.strings.push(value); + this.stringIndices.set(value, index); + return index; + } + + private internOptional(value: string | undefined): number { + return value == null ? NO_STRING : this.intern(value); + } +} + +export interface DecodedUpsert { + handle: number; + descriptor: MarkerDescriptor; +} + +export interface DecodedPosition { + handle: number; + coordinate: Coordinate; +} + +export interface DecodedMarkerBatch { + upserts: DecodedUpsert[]; + removes: number[]; + positions: DecodedPosition[]; +} + +function optionalFloat(value: number): number | undefined { + return Number.isNaN(value) ? undefined : value; +} + +function stringAt(strings: string[], index: number): string | undefined { + return index === NO_STRING ? undefined : strings[index]; +} + +/** + * Reads a batch back into descriptors with the same optional-field semantics + * the native decoders use. Reference implementation for tests and debugging. + */ +export function decodeMarkerBatch(batch: MarkerBatch): DecodedMarkerBatch { + const view = new DataView(batch.buffer); + if (view.getUint32(0, true) !== MARKER_BATCH_MAGIC) { + throw new Error('Not a marker batch'); + } + + const upsertCount = view.getUint32(4, true); + const removeCount = view.getUint32(8, true); + const positionCount = view.getUint32(12, true); + const expected = + MARKER_BATCH_HEADER_BYTES + + upsertCount * UPSERT_RECORD_BYTES + + removeCount * REMOVE_RECORD_BYTES + + positionCount * POSITION_RECORD_BYTES; + if (batch.buffer.byteLength !== expected) { + throw new Error( + `Marker batch is ${batch.buffer.byteLength} bytes, expected ${expected}`, + ); + } + + const strings = batch.strings; + let offset = MARKER_BATCH_HEADER_BYTES; + const upserts: DecodedUpsert[] = []; + for (let index = 0; index < upsertCount; index += 1) { + const base = offset; + const flags = view.getUint32(base + UpsertOffset.flags, true); + const imageUri = stringAt( + strings, + view.getInt32(base + UpsertOffset.imageUri, true), + ); + const animationKind = view.getUint8(base + UpsertOffset.animationKind); + const reduceMotion = view.getUint8( + base + UpsertOffset.animationReduceMotion, + ); + const enteringAnimation: OverlayEnteringAnimationDescriptor | undefined = + animationKind === 0 + ? undefined + : { + kind: ANIMATION_KINDS[animationKind - 1], + duration: optionalFloat( + view.getFloat32(base + UpsertOffset.animationDuration, true), + ), + delay: optionalFloat( + view.getFloat32(base + UpsertOffset.animationDelay, true), + ), + reduceMotion: + reduceMotion === 0 + ? undefined + : REDUCE_MOTION_VALUES[reduceMotion - 1], + }; + + upserts.push({ + handle: view.getUint32(base + UpsertOffset.handle, true), + descriptor: { + id: strings[view.getInt32(base + UpsertOffset.id, true)], + coordinate: { + latitude: view.getFloat64(base + UpsertOffset.latitude, true), + longitude: view.getFloat64(base + UpsertOffset.longitude, true), + }, + title: stringAt( + strings, + view.getInt32(base + UpsertOffset.title, true), + ), + subtitle: stringAt( + strings, + view.getInt32(base + UpsertOffset.subtitle, true), + ), + draggable: (flags & UpsertFlag.draggable) !== 0 ? true : undefined, + clusterable: (flags & UpsertFlag.clusterable) !== 0 ? undefined : false, + image: + imageUri == null + ? undefined + : { + uri: imageUri, + width: optionalFloat( + view.getFloat32(base + UpsertOffset.imageWidth, true), + ), + height: optionalFloat( + view.getFloat32(base + UpsertOffset.imageHeight, true), + ), + scale: optionalFloat( + view.getFloat32(base + UpsertOffset.imageScale, true), + ), + }, + anchor: + (flags & UpsertFlag.hasAnchor) !== 0 + ? { + x: view.getFloat32(base + UpsertOffset.anchorX, true), + y: view.getFloat32(base + UpsertOffset.anchorY, true), + } + : undefined, + centerOffset: + (flags & UpsertFlag.hasCenterOffset) !== 0 + ? { + x: view.getFloat32(base + UpsertOffset.centerOffsetX, true), + y: view.getFloat32(base + UpsertOffset.centerOffsetY, true), + } + : undefined, + rotation: optionalFloat( + view.getFloat32(base + UpsertOffset.rotation, true), + ), + flat: (flags & UpsertFlag.flat) !== 0 ? true : undefined, + opacity: optionalFloat( + view.getFloat32(base + UpsertOffset.opacity, true), + ), + markerColor: stringAt( + strings, + view.getInt32(base + UpsertOffset.markerColor, true), + ), + zIndex: optionalFloat( + view.getFloat32(base + UpsertOffset.zIndex, true), + ), + enteringAnimation, + }, + }); + offset += UPSERT_RECORD_BYTES; + } + + const removes: number[] = []; + for (let index = 0; index < removeCount; index += 1) { + removes.push(view.getUint32(offset, true)); + offset += REMOVE_RECORD_BYTES; + } + + const positions: DecodedPosition[] = []; + for (let index = 0; index < positionCount; index += 1) { + positions.push({ + handle: view.getUint32(offset, true), + coordinate: { + latitude: view.getFloat64(offset + 8, true), + longitude: view.getFloat64(offset + 16, true), + }, + }); + offset += POSITION_RECORD_BYTES; + } + + return { upserts, removes, positions }; +} diff --git a/package/src/markers/markerDeltaCompiler.ts b/package/src/markers/markerDeltaCompiler.ts new file mode 100644 index 0000000..c43d030 --- /dev/null +++ b/package/src/markers/markerDeltaCompiler.ts @@ -0,0 +1,154 @@ +import type { MarkerDescriptor } from '../native/specs/overlays'; +import { markerDescriptorsEqual } from '../overlays/descriptorEquality'; +import type { Coordinate } from '../types/coordinate'; +import { MarkerBatchWriter, type MarkerBatch } from './markerBatch'; + +/** A coordinate-only update for a marker that is already in the collection. */ +export interface MarkerPositionUpdate { + id: string; + coordinate: Coordinate; +} + +interface Entry { + handle: number; + descriptor: MarkerDescriptor; +} + +/** + * Turns marker arrays and edits into delta batches. + * + * Keeps the last descriptor sent for every id, so a `set()` with a new array + * costs one structural comparison per marker and produces records only for + * the markers that changed. Handles are dense integers assigned here; a + * removed marker's handle goes back on a free list and is reused by the next + * insert. + */ +export class MarkerDeltaCompiler { + private readonly entries = new Map(); + private readonly freeHandles: number[] = []; + private nextHandle = 0; + + get size(): number { + return this.entries.size; + } + + has(id: string): boolean { + return this.entries.has(id); + } + + /** The descriptor last sent for `id`, if any. */ + get(id: string): MarkerDescriptor | undefined { + return this.entries.get(id)?.descriptor; + } + + /** Every id currently in the collection, in insertion order. */ + ids(): string[] { + return Array.from(this.entries.keys()); + } + + /** + * Makes the collection equal to `descriptors`: upserts what is new or + * changed and removes what is missing. The first descriptor wins when an id + * repeats. + */ + set(descriptors: MarkerDescriptor[]): MarkerBatch | null { + const writer = new MarkerBatchWriter(); + const next = new Set(); + for (const descriptor of descriptors) { + next.add(descriptor.id); + } + + // Removals first: native applies them before the upserts of the same + // batch, so the freed handles are reused instead of growing the arrays. + for (const [id, entry] of this.entries) { + if (!next.has(id)) { + this.entries.delete(id); + this.freeHandles.push(entry.handle); + writer.remove(entry.handle); + } + } + + const seen = new Set(); + for (const descriptor of descriptors) { + if (seen.has(descriptor.id)) { + continue; + } + seen.add(descriptor.id); + this.upsertOne(descriptor, writer); + } + + return writer.finish(); + } + + upsert(descriptors: MarkerDescriptor[]): MarkerBatch | null { + const writer = new MarkerBatchWriter(); + for (const descriptor of descriptors) { + this.upsertOne(descriptor, writer); + } + return writer.finish(); + } + + remove(ids: string[]): MarkerBatch | null { + const writer = new MarkerBatchWriter(); + for (const id of ids) { + const entry = this.entries.get(id); + if (entry == null) { + continue; + } + this.entries.delete(id); + this.freeHandles.push(entry.handle); + writer.remove(entry.handle); + } + return writer.finish(); + } + + /** + * Moves markers without touching their other fields. Unknown ids are + * ignored; an unchanged coordinate produces no record. + */ + updatePositions(updates: MarkerPositionUpdate[]): MarkerBatch | null { + const writer = new MarkerBatchWriter(); + for (const update of updates) { + const entry = this.entries.get(update.id); + if (entry == null) { + continue; + } + const current = entry.descriptor.coordinate; + if ( + current.latitude === update.coordinate.latitude && + current.longitude === update.coordinate.longitude + ) { + continue; + } + entry.descriptor = { ...entry.descriptor, coordinate: update.coordinate }; + writer.position(entry.handle, update.coordinate); + } + return writer.finish(); + } + + /** Forgets everything, including handle assignments. */ + clear(): void { + this.entries.clear(); + this.freeHandles.length = 0; + this.nextHandle = 0; + } + + private upsertOne( + descriptor: MarkerDescriptor, + writer: MarkerBatchWriter, + ): void { + const entry = this.entries.get(descriptor.id); + if (entry == null) { + const handle = this.freeHandles.pop() ?? this.nextHandle++; + this.entries.set(descriptor.id, { handle, descriptor }); + writer.upsert(handle, descriptor); + return; + } + + if (markerDescriptorsEqual(entry.descriptor, descriptor)) { + return; + } + entry.descriptor = descriptor; + writer.upsert(entry.handle, descriptor); + } +} diff --git a/package/src/markers/useMarkerCollection.ts b/package/src/markers/useMarkerCollection.ts new file mode 100644 index 0000000..b18bc36 --- /dev/null +++ b/package/src/markers/useMarkerCollection.ts @@ -0,0 +1,16 @@ +import { useState } from 'react'; +import { MarkerCollection } from './MarkerCollection'; + +/** + * Creates a `MarkerCollection` once for the lifetime of the component. + * + * ```tsx + * const markers = useMarkerCollection(); + * useEffect(() => { markers.set(vehicles); }, [markers, vehicles]); + * return ; + * ``` + */ +export function useMarkerCollection(): MarkerCollection { + const [collection] = useState(() => new MarkerCollection()); + return collection; +} diff --git a/package/src/native/README.md b/package/src/native/README.md index 3029c15..43feac4 100644 --- a/package/src/native/README.md +++ b/package/src/native/README.md @@ -7,9 +7,10 @@ This directory contains the Nitro Module specifications and will host the JS ↔ ``` native/ ├── specs/ -│ └── MapView.nitro.ts # HybridView spec (props + methods) -├── MapViewNative.ts # getHostComponent bridge to the native view -└── README.md # This file +│ ├── MapView.nitro.ts # HybridView spec (props + methods) +│ └── MarkerCollection.nitro.ts # HybridObject spec (native marker store) +├── MapViewNative.ts # getHostComponent bridge to the native view +└── README.md # This file ``` ## JS ↔ native flow @@ -32,24 +33,29 @@ Platform map SDK (MapKit / Google Maps) **Decision: data-driven descriptors (Option B).** -Overlay components (``, ``, etc.) are lightweight React wrappers. `MapView` collects their props via `React.Children`, assigns stable `id` values, and serializes them into descriptor struct arrays passed to the native `HybridMapView`. Overlay interaction events flow back through id-keyed map-level callbacks; `MapView` dispatches them to the matching overlay's `onPress` / `onDragEnd` handlers. +Overlay components (``, ``, etc.) are lightweight React wrappers. `MapView` collects their props via `React.Children` and assigns stable `id` values. Polylines, polygons and circles are serialized into descriptor struct arrays passed as props to the native `HybridMapView`. Overlay interaction events flow back through id-keyed map-level callbacks; `MapView` dispatches them to the matching overlay's `onPress` / `onDragEnd` handlers. + +Markers do not travel as a prop. `MapView` compiles them into delta batches for a `MarkerCollection` HybridObject (`src/markers/`), which owns the dataset natively; the `markerCollection` prop hands the native view that object. The batch layout is documented in `src/markers/markerBatch.ts` and decoded by `ios/MarkerBatchDecoder.swift` and `android/.../MarkerBatchDecoder.kt`. ``` - → collected, serialized as MarkerDescriptor[] + → collected, diffed by id, sent as a packed batch to MarkerCollection → collected, serialized as PolylineDescriptor[] ``` -The public JSX API stays idiomatic React; native MapKit / Google Maps render overlays from the descriptor arrays. +The public JSX API stays idiomatic React; native MapKit / Google Maps render overlays from the store and the descriptor arrays. ## Regenerating native bindings -After changing `MapView.nitro.ts`, regenerate the native bindings: +After changing a spec in `specs/`, regenerate the native bindings: 1. Run `bun run nitrogen` from the repo root (outputs to `package/nitrogen/generated`). -2. Implement any new members in `ios/HybridMapView.swift` and - `android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt`. +2. Implement any new members in `ios/HybridMapView.swift` / `ios/HybridMarkerCollection.swift` and + `android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt` / `HybridMarkerCollection.kt`. + `MarkerDescriptor`, `MarkerImage`, `MarkerAnchor` and `MarkerPoint` are hand-written natively + (`ios/MarkerDescriptor.swift`, `android/.../MarkerDescriptor.kt`) because no spec references + them anymore; a spec that references them again would make nitrogen generate conflicting types. 3. The React `MapView` component is bridged via `getHostComponent` in `src/native/MapViewNative.ts`; the Android view manager is registered in `NitroMapsPackage.kt` and the C++ library is loaded from `cpp-adapter.cpp`. diff --git a/package/src/native/specs/MapView.nitro.ts b/package/src/native/specs/MapView.nitro.ts index 0522c39..82a2add 100644 --- a/package/src/native/specs/MapView.nitro.ts +++ b/package/src/native/specs/MapView.nitro.ts @@ -7,9 +7,9 @@ import type { Camera } from '../../types/camera'; import type { Coordinate } from '../../types/coordinate'; import type { MapProvider, MapType } from '../../types/map'; import type { EdgePadding, Region, VisibleRegion } from '../../types/region'; +import type { MarkerCollection } from './MarkerCollection.nitro'; import type { CircleDescriptor, - MarkerDescriptor, OverlayEnteringAnimationDescriptor, PolygonDescriptor, PolylineDescriptor, @@ -102,6 +102,22 @@ export interface NativePoiPressEvent { placeId?: string; } +/** + * Payload of a marker-cluster press. Member ids are fetched on demand through + * `getClusterMembers` so a press on a 100k-marker cluster does not ship + * every id across JSI. + */ +export interface NativeClusterPressEvent { + /** Identity of the pressed cluster while it is displayed. */ + clusterId: string; + + /** Number of markers in the cluster. */ + count: number; + + /** Position of the cluster badge. */ + coordinate: Coordinate; +} + /** * Native props for the {@linkcode MapView} Nitro HybridView. * @@ -184,8 +200,8 @@ export interface MapViewProps extends HybridViewProps { /** Called when the user long-presses the map. */ onLongPress?: (coordinate: Coordinate) => void; - /** Marker overlays to render on the map. */ - markers?: MarkerDescriptor[]; + /** Native marker store rendered by this map. */ + markerCollection?: MarkerCollection; /** Polyline overlays to render on the map. */ polylines?: PolylineDescriptor[]; @@ -212,7 +228,7 @@ export interface MapViewProps extends HybridViewProps { onCirclePress?: (id: string) => void; /** Called when a marker cluster is pressed. */ - onClusterPress?: (markerIds: string[], coordinate: Coordinate) => void; + onClusterPress?: (event: NativeClusterPressEvent) => void; } /** @@ -250,6 +266,12 @@ export interface MapViewMethods extends HybridViewMethods { padding?: EdgePadding, animated?: boolean, ): Promise; + + /** + * Returns the ids of the markers inside a displayed cluster. Resolves to an + * empty array when the cluster is no longer displayed. + */ + getClusterMembers(clusterId: string): Promise; } /** diff --git a/package/src/native/specs/MarkerCollection.nitro.ts b/package/src/native/specs/MarkerCollection.nitro.ts new file mode 100644 index 0000000..fd49df0 --- /dev/null +++ b/package/src/native/specs/MarkerCollection.nitro.ts @@ -0,0 +1,31 @@ +import type { HybridObject } from 'react-native-nitro-modules'; + +/** + * Native-owned marker store. + * + * JS assigns each marker an integer handle and feeds the store with packed + * batches (see `overlays/markerBatch.ts` for the record layout). The store + * keeps one copy of the dataset natively, maintains a spatial index over + * handles and notifies every attached map view when a batch has been applied. + * + * Nothing here is called by application code directly; use the + * `MarkerCollection` class or the `markers` prop, which compile to batches. + */ +export interface MarkerCollection extends HybridObject<{ + ios: 'swift'; + android: 'kotlin'; +}> { + /** + * Applies one packed batch of upserts, removals and position updates. + * + * `strings` holds every string the batch references (ids, titles, + * subtitles, image URIs), each sent once per batch. + */ + applyBatch(batch: ArrayBuffer, strings: string[]): void; + + /** Removes every marker from the store. */ + clear(): void; + + /** Number of markers currently stored. */ + readonly size: number; +} diff --git a/package/src/types/index.ts b/package/src/types/index.ts index b6b344d..14a458c 100644 --- a/package/src/types/index.ts +++ b/package/src/types/index.ts @@ -4,6 +4,7 @@ export type { Region, EdgePadding, VisibleRegion } from './region'; export type { ApplePoiCategory } from '../native/specs/MapView.nitro'; export type { ApplePoiPressEvent, + ClusterPressEvent, GooglePoiPressEvent, MapProvider, MapType, diff --git a/package/src/types/map.ts b/package/src/types/map.ts index 7d96db7..ef97613 100644 --- a/package/src/types/map.ts +++ b/package/src/types/map.ts @@ -8,6 +8,7 @@ import type { PolylineDescriptor, } from '../native/specs/overlays'; import type { ApplePoiCategory } from '../native/specs/MapView.nitro'; +import type { MarkerCollection } from '../markers/MarkerCollection'; import type { MarkerDescriptor, OverlayEnteringAnimation } from './overlays'; import type { EdgePadding, Region } from './region'; @@ -38,6 +39,21 @@ export interface GooglePoiPressEvent { export type PoiPressEvent = ApplePoiPressEvent | GooglePoiPressEvent; +/** + * Payload of `onClusterPress`. The member ids are not part of the event; + * fetch them with `MapViewRef.getClusterMembers(event.clusterId)` when needed. + */ +export interface ClusterPressEvent { + /** Identity of the pressed cluster while it stays displayed. */ + clusterId: string; + + /** Number of markers in the cluster. */ + count: number; + + /** Position of the cluster badge. */ + coordinate: Coordinate; +} + /** * Props shared by all map providers. */ @@ -74,10 +90,21 @@ interface BaseMapViewProps { /** * Bulk marker descriptors. Prefer this over {@linkcode Marker} children - * when rendering hundreds or thousands of markers. + * when rendering hundreds or thousands of markers. Compiled to deltas: a new + * array only sends the markers that changed since the previous one. + * + * Ignored when {@linkcode markerCollection} is set. */ markers?: MarkerDescriptor[]; + /** + * A {@linkcode MarkerCollection} updated imperatively through `set`, + * `upsert`, `remove` and `updatePositions`. The right choice for live or + * animated markers and for datasets that change often, because each call + * ships only its own changes. Replaces `markers` and `` children. + */ + markerCollection?: MarkerCollection; + /** Bulk polyline descriptors. */ polylines?: PolylineDescriptor[]; @@ -121,7 +148,7 @@ interface BaseMapViewProps { onLongPress?: (coordinate: Coordinate) => void; /** Called when a marker cluster is pressed. */ - onClusterPress?: (markerIds: string[], coordinate: Coordinate) => void; + onClusterPress?: (event: ClusterPressEvent) => void; /** Default entering animation for marker overlays. */ markerEnteringAnimation?: OverlayEnteringAnimation; diff --git a/package/src/types/ref.ts b/package/src/types/ref.ts index fa24206..18d9535 100644 --- a/package/src/types/ref.ts +++ b/package/src/types/ref.ts @@ -24,4 +24,10 @@ export interface MapViewRef { padding?: EdgePadding, animated?: boolean, ): Promise; + + /** + * Returns the ids of the markers inside a displayed cluster, as identified + * by `onClusterPress`. Resolves to an empty array once the cluster is gone. + */ + getClusterMembers(clusterId: string): Promise; }