diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d133d4..72e428d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,33 @@ fetch the ids on demand when you need them: /> ``` +### Behavior changes + +**Image-less markers on Apple Maps are flat pins by default** + +MapKit used to draw every marker as an `MKMarkerAnnotationView`, the balloon marker with +a drop and selection animation. Those views are a small view tree each, and MapKit lays +all of them out on the main thread every frame, which is what limited a map to a few +hundred visible markers at 120 Hz. Markers without an `image` are now one pre-rendered +image on a plain `MKAnnotationView`. Pass `pinStyle="system"` to get the balloon back: + +```tsx + +``` + +With flat pins the `system` entering animation is a plain appearance; `fade` and +`fade-scale` still animate. + +**Marker changes reach the map over several frames** + +Adds and removals from a viewport refresh used to be applied in one main-thread pass, so a +zoom into a dense area cost one long frame. They are now spread over frames within a +budget, nearest to the camera first. During a large change the outer markers appear a few +frames after the inner ones; no frame waits for all of them. + ### Added +- `pinStyle` prop (`'flat' | 'system'`) for the Apple provider. - `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 @@ -46,6 +71,9 @@ fetch the ids on demand when you need them: 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. +- The MapKit live refresh during gestures is driven by `CADisplayLink` instead of a + wall-clock timer, and clustering reuses the grid cells that stay in view across a pan + within one zoom octave. ## 1.1.0 diff --git a/README.md b/README.md index 6ac7e29..bf8636a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Built with [Nitro Modules](https://nitro.margelo.com/) for high-performance nati - **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. +- **Frame-budgeted rendering** - Marker changes reach the map SDK over several frames, nearest to the camera first, with a per-frame budget that adapts to the frame rate; a zoom into a dense area no longer costs one long frame. - **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. @@ -277,7 +278,7 @@ When `provider` is omitted, defaults stay backward-compatible: Changing `provider` remounts the native map view. Controlled props such as `region`, `camera`, overlays, and callbacks should therefore be supplied again through React props. -Provider-specific TypeScript props are exposed through `MapViewPropsForProvider

`. For example, `showsScale` is accepted for `apple` but rejected for `google` because Google Maps SDK has no native scale control. +Provider-specific TypeScript props are exposed through `MapViewPropsForProvider

`. For example, `showsScale` and `pinStyle` are accepted for `apple` but rejected for `google`, because Google Maps SDK has no native scale control and draws its own default marker. ## Native POI press events @@ -574,6 +575,16 @@ Explicit configs use milliseconds. `duration` defaults to `180`, `delay` default On Google Maps providers, marker and cluster entering animations can reduce UI-thread frame rate when a large viewport refresh adds many markers at once. The provider caps animated markers per refresh and may show the remaining markers immediately to preserve map gesture performance. For very large marker sets, prefer clustering, shorter durations, or `markerEnteringAnimation={false}` / `clusterEnteringAnimation={false}` when smooth gestures are more important than entrance motion. +## Pin style on Apple Maps + +Markers without an `image` are drawn by MapKit. By default they are `flat` pins: one pre-rendered image per pin on a plain `MKAnnotationView`, which MapKit can move by the hundred at 120 Hz. `pinStyle="system"` switches to `MKMarkerAnnotationView`, the balloon marker with its drop and selection animations, at a higher per-marker cost: + +```tsx + +``` + +The prop is accepted for the `apple` provider and the default provider on iOS; Google Maps draws its own default marker. With flat pins the `system` entering animation is a plain appearance; use `fade` or `fade-scale` for motion. + ## Re-renders 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: @@ -616,6 +627,7 @@ setMarkers((current) => | Scale control | Supported | Unsupported | Unsupported | | Markers / overlays | Supported | Supported | Supported | | Marker collections (deltas) | Supported | Supported | Supported | +| Pin style | `flat` (default) or `system` | Google default marker | Google default marker | | Custom marker images | Supported | Supported | Supported | | Marker callouts / dragging | Supported | Supported | Supported | | Overlay press events | Supported | Supported | Supported | @@ -658,6 +670,7 @@ setMarkers((current) => | `Camera` | Position, zoom, heading, pitch | | `MapType` | `'standard' \| 'satellite' \| 'hybrid' \| 'terrain'` | | `MapProvider` | `'apple' \| 'google' \| 'openstreetmap' \| 'mapbox'` | +| `MarkerPinStyle` | `'flat' \| 'system'`, Apple MapKit pin rendering | | `PoiPressEvent` | Provider-discriminated native POI press payload | | `ApplePoiPressEvent` | Apple Maps POI payload with category | | `GooglePoiPressEvent` | Google Maps POI payload with place ID | diff --git a/docs/adr/0006-frame-budgeted-rendering.md b/docs/adr/0006-frame-budgeted-rendering.md new file mode 100644 index 0000000..5f08e96 --- /dev/null +++ b/docs/adr/0006-frame-budgeted-rendering.md @@ -0,0 +1,76 @@ +# ADR 0006: Frame-budgeted marker rendering + +## Status + +Accepted + +## Context + +After [ADR 0005](0005-marker-collection-store.md) the transport is O(Δ), but the render +layer still applied every viewport diff in one main-thread pass. Zooming into a dense area +or crossing a cluster octave produced diffs of several hundred adds and removals; on +MapKit each add was an `MKMarkerAnnotationView`, a small view tree that MapKit lays out +on the main thread every frame, so the frame that applied the diff ran for 100–300 ms and +every later frame paid for the views that stayed. The MapKit live refresh during gestures +ran off a wall-clock `Timer`, so its applies landed at arbitrary points in a frame, and +clustering rebuilt every bucket from scratch on every refresh even when a pan within one +zoom octave had only moved the viewport by a few cells. + +The performance audit rated these the next items after the transport: bounded apply +passes with a frame budget (P1), lightweight MapKit annotation views (P1) and an +incremental cluster cache, plus vsync-aligned refresh (P3). + +## Decision + +- **A frame-budgeted apply scheduler per map.** `MarkerApplyScheduler` (Swift) and + `MarkerApplyQueue` + `MarkerApplyScheduler` (Kotlin) hold one pending diff. Each step + applies all removals first, then a bounded number of adds sorted by distance to the + viewport centre, then retained updates until a 2 ms budget is spent. The add count + starts at 32, halves after a frame longer than 1.5× the display interval and grows by + half after a frame within 1.1× of it, between 8 and 256, but never above three quarters + of the last count that dropped a frame; that ceiling creeps up by one per good frame, so + a one-off hitch does not pin the rate and a real limit is probed slowly. The scheduler runs a + `CADisplayLink` / `Choreographer` callback only while work is pending. A new diff + replaces the pending one: diffs are computed against what is on the map, so anything + not yet applied is either in the new diff again or no longer wanted. +- **Flat pins by default on MapKit.** Image-less markers use `NitroFlatPinAnnotationView`, + an `MKAnnotationView` with one pre-rendered pin image per screen scale. `pinStyle="system"` + keeps `MKMarkerAnnotationView`. Google providers are unaffected. +- **Vsync-aligned live refresh.** The MapKit adapter's 10 Hz `Timer` is replaced by a + display link that triggers a viewport refresh at most every 100 ms while the camera + moves and is stopped otherwise. +- **Octave cache for clustering.** `ClusterOctaveCache` keeps the buckets of the cells + that were fully inside the previous padded viewport, keyed by cell, for as long as the + cell size (zoom octave) and the dataset generation stay the same. Cells that entered are + accumulated from the candidates; cells that left are dropped. Edge cells that the + candidate region only partially covers are never cached. The union-find merge works on + copies so cached buckets are not mutated. The cache is off across the antimeridian, + where cell keys depend on the viewport's own longitude reference. + +## Consequences + +- The worst frame of a viewport change is bounded by the per-frame add count instead of + the diff size. During a large change the map fills from the centre outwards over a few + frames. +- Apple markers look different by default. The flat pin is drawn to resemble the system + marker; apps that want the balloon and its animations set `pinStyle="system"`. +- Entering animations on MapKit: `fade` and `fade-scale` work on flat pins; `system` has + no drop animation there. +- A diff superseded mid-way leaves the map exactly as the next diff expects; there is no + partial-state bookkeeping beyond the versions of what was actually applied. +- The cluster cache holds about one padded viewport of buckets per map. Any dataset change + invalidates it, so live-updating clustered datasets get no reuse; static datasets get + reuse for every pan within an octave. + +## Alternatives considered + +- **A fixed chunk size per frame.** Simpler, but the right number differs by SDK, device + and view class. The observed frame interval is the only signal that includes what the + SDK does after our call returns. +- **Measuring our own apply time as the budget.** MapKit and Google Maps create views and + upload icons after the apply call, in their own layout pass, so the time inside + `addAnnotations` says little about the frame's cost. +- **An `MKOverlayRenderer` sprite layer for bulk markers.** Would remove per-marker views + entirely above a few hundred visible markers, at the cost of a second rendering path + and no per-marker hit testing. Left for a later phase; the scheduler and flat pins + keep the annotation model. diff --git a/docs/architecture.md b/docs/architecture.md index a8d1e72..e9dee1f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,6 +93,7 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C | `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`. | +| `pinStyle` | Apple MapKit only. `flat` (default) draws image-less markers as one pre-rendered image on an `MKAnnotationView`; `system` uses `MKMarkerAnnotationView`. | | `getClusterMembers(clusterId)` | Imperative ref method; resolves the marker ids inside a displayed cluster. | ### Platform gaps (Phase 8) @@ -109,6 +110,8 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C 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). +The diff does not reach the map SDK in one pass. A per-map scheduler driven by `CADisplayLink` on iOS and `Choreographer` on Android applies removals at once, then a bounded number of adds per frame, nearest to the camera first, then retained updates within a 2 ms budget; the add count halves after a long frame and grows back on frames within budget. A newer diff replaces whatever is still pending, which is safe because diffs are computed against what is actually on the map. On MapKit the live refresh during gestures runs off the same display link instead of a wall-clock timer, and image-less markers are flat pre-rendered pins unless `pinStyle="system"` asks for `MKMarkerAnnotationView`. Clustering keeps the buckets of the cells that were fully inside the previous padded viewport for as long as the zoom octave and the dataset stay the same, so a pan only accumulates the cells that entered. See [ADR 0006](adr/0006-frame-budgeted-rendering.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. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index a613196..1d77e3d 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -50,6 +50,7 @@ They are implemented in `benchmark/thresholds.ts` and unit-tested with | 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 | +| N | 10,000 markers inside the viewport | street-level zoom sweep, where the LOD cap allows 2,000 markers on screen | 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. @@ -254,6 +255,103 @@ hundreds of `MKMarkerAnnotationView`s at once; that is the phase-3 work - 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% +### Frame-budgeted rendering runs (not a device baseline) + +The same simulator and emulator after ADR 0006: viewport diffs applied over +frames with an adaptive per-frame add count, flat pins on MapKit, the live +refresh on a display link, and the cluster octave cache. Scenario N (10,000 +markers inside the city viewport, street-level zoom sweep) is new in this +round, so the "before" tables below were recorded on the marker-store build +from the previous section with the scenario added, minutes before the "after" +tables on the same host. + +**iOS before**, iPhone 17 Pro simulator, release build, MapKit, 60 Hz, started +by hand, recorded 2026-09-08: + +| Scenario | Result | FPS | p50 | p95 | p99 | Worst | Jank | JS lag p95 | RSS Δ | +| --------------------- | -------- | --- | ------- | ------- | ------- | ------ | ------ | ---------- | ------- | +| A-empty-idle | fail (3) | 59 | 16.7 ms | 16.7 ms | 26.8 ms | 57 ms | 1.1 % | 1.0 ms | +73 MB | +| B-markers-100 | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 45 ms | 0.7 % | 1.2 ms | +71 MB | +| C-markers-1k | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 43 ms | 0.7 % | 1.0 ms | +70 MB | +| D-markers-10k | pass | 59 | 16.7 ms | 16.7 ms | 23.7 ms | 43 ms | 1.0 % | 1.0 ms | +89 MB | +| E-clustered-10k | pass | 59 | 16.7 ms | 16.7 ms | 16.9 ms | 50 ms | 1.0 % | 1.1 ms | +123 MB | +| F-pan-10k | pass | 59 | 16.7 ms | 16.7 ms | 23.0 ms | 44 ms | 1.0 % | 1.0 ms | +72 MB | +| G-zoom-10k | fail (2) | 58 | 16.7 ms | 16.7 ms | 34.7 ms | 38 ms | 3.7 % | 1.1 ms | +94 MB | +| H-rotate-10k | fail (3) | 59 | 16.7 ms | 16.7 ms | 36.8 ms | 62 ms | 1.0 % | 1.1 ms | +61 MB | +| I-animated-collection | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 44 ms | 0.3 % | 1.2 ms | +5 MB | +| I2-animated-prop | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 1.0 ms | -7 MB | +| K-shapes | pass | 59 | 16.7 ms | 16.7 ms | 20.9 ms | 46 ms | 0.9 % | 1.1 ms | +81 MB | +| L-idle-after-pan | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 44 ms | 0.6 % | 1.0 ms | +53 MB | +| M-one-of-10k | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 37 ms | 1.0 % | 1.2 ms | -3 MB | +| N-dense-10k | fail (4) | 46 | 16.7 ms | 40.9 ms | 99.4 ms | 315 ms | 14.6 % | 1.5 ms | +168 MB | + +- A-empty-idle: p99 26.78 ms > 25.00 ms; worst frame 56.55 ms > 50.00 ms; jank 1.14% > 1% +- G-zoom-10k: p99 34.68 ms > 25.00 ms; jank 3.68% > 1% +- H-rotate-10k: p99 36.82 ms > 25.00 ms; worst frame 62.34 ms > 50.00 ms; jank 1.02% > 1% +- M-one-of-10k: p99 33.33 ms > 25.00 ms; jank 1.01% > 1% +- N-dense-10k: p95 40.91 ms > budget 17.50 ms; p99 99.40 ms > 25.00 ms; worst frame 315.15 ms > 50.00 ms; jank 14.64% > 1% + +**iOS after**, same simulator and build type, started by hand, recorded +2026-09-08. The dense scenario N went from a p95 of 41 ms, a p99 of 99 ms and +a worst frame of 315 ms to a p95 of one frame, a p99 of two and a worst frame +of 46 ms; jank fell from 14.6 % to 3.3 %. D and E hold one frame at p99 with +a 33 ms worst frame, and M stays at 17 ms. What is left at the octave +crossings of G and N is MapKit laying out the flat pins that are already on +screen, which is the sprite-layer work noted in ADR 0006. The JS-lag column +in B–D of this run coincides with the Android emulator shutting down on the +same host; the frame columns do not show it. + +| 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 | 49 ms | 0.6 % | 1.2 ms | +69 MB | +| B-markers-100 | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 43 ms | 0.7 % | 16.9 ms | +71 MB | +| C-markers-1k | pass | 59 | 16.7 ms | 16.7 ms | 20.6 ms | 46 ms | 1.0 % | 17.4 ms | +65 MB | +| D-markers-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.7 % | 17.2 ms | +64 MB | +| E-clustered-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.4 % | 1.3 ms | +91 MB | +| F-pan-10k | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 42 ms | 1.0 % | 1.1 ms | +97 MB | +| G-zoom-10k | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 38 ms | 2.3 % | 1.1 ms | +90 MB | +| H-rotate-10k | fail (3) | 58 | 16.7 ms | 16.7 ms | 42.1 ms | 83 ms | 1.0 % | 1.1 ms | +56 MB | +| I-animated-collection | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.6 % | 1.4 ms | +0 MB | +| I2-animated-prop | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 1.1 ms | -3 MB | +| K-shapes | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 43 ms | 1.1 % | 1.3 ms | +64 MB | +| L-idle-after-pan | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 44 ms | 0.4 % | 1.1 ms | +43 MB | +| M-one-of-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 1.3 ms | -0 MB | +| N-dense-10k | fail (2) | 58 | 16.7 ms | 16.7 ms | 33.3 ms | 46 ms | 3.3 % | 1.0 ms | +138 MB | + +- G-zoom-10k: p99 33.33 ms > 25.00 ms; jank 2.32% > 1% +- H-rotate-10k: p99 42.13 ms > 25.00 ms; worst frame 83.21 ms > 50.00 ms; jank 1.03% > 1% +- K-shapes: p99 33.33 ms > 25.00 ms; jank 1.15% > 1% +- N-dense-10k: p99 33.33 ms > 25.00 ms; jank 3.34% > 1% + +**Android after**, API 35 emulator, arm64, Google Maps, 60 Hz, release build, +Maestro-driven, recorded 2026-09-08. The "before" numbers are the Android table +in the previous section (same build type, one scenario fewer). Every scenario +but N now holds 16.7 ms at p99 with a worst frame of 17 ms; the `(1)` failures +are the emulator's JS-lag floor of about 18 ms, which the empty map shows too. +N keeps a 67 ms worst frame at the octave crossings. + +| Scenario | Result | FPS | p50 | p95 | p99 | Worst | Jank | JS lag p95 | RSS Δ | +| --------------------- | -------- | --- | ------- | ------- | ------- | ----- | ----- | ---------- | ------ | +| A-empty-idle | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.3 % | 18.9 ms | -24 MB | +| B-markers-100 | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.5 ms | -17 MB | +| C-markers-1k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.3 ms | +25 MB | +| D-markers-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.3 ms | +35 MB | +| E-clustered-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.4 ms | +23 MB | +| F-pan-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.3 ms | -80 MB | +| G-zoom-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.3 ms | +24 MB | +| H-rotate-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.0 ms | -37 MB | +| I-animated-collection | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.5 ms | -46 MB | +| I2-animated-prop | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.9 ms | -61 MB | +| K-shapes | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.7 ms | -43 MB | +| L-idle-after-pan | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.9 ms | -18 MB | +| M-one-of-10k | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 19.1 ms | +14 MB | +| N-dense-10k | fail (3) | 58 | 16.7 ms | 16.7 ms | 33.3 ms | 67 ms | 2.0 % | 23.2 ms | -63 MB | + +- I-animated-collection: JS lag p95 18.52 ms > budget 17.50 ms +- I2-animated-prop: JS lag p95 18.88 ms > budget 17.50 ms +- M-one-of-10k: JS lag p95 19.08 ms > budget 17.50 ms +- N-dense-10k: p99 33.33 ms > 25.00 ms; worst frame 66.67 ms > 50.00 ms; jank 2.01% > 1% + ## Profiling markers The library emits `os_signpost` intervals (iOS, subsystem `com.nitromaps`, diff --git a/example/benchmark/datasets.ts b/example/benchmark/datasets.ts index 04d628a..4f3fe60 100644 --- a/example/benchmark/datasets.ts +++ b/example/benchmark/datasets.ts @@ -26,6 +26,43 @@ export const POLAND_REGION: Region = { }; const markerCache = new Map(); +const denseCache = new Map(); + +/** Deterministic PRNG, same as the Poland generator. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * `count` markers spread uniformly over the Warsaw viewport, so every one of + * them competes for the viewport LOD cap instead of hiding across the country. + */ +export function denseMarkers(count: number): MarkerDescriptor[] { + let cached = denseCache.get(count); + if (cached == null) { + const rng = mulberry32(0xd3a5e); + const halfLat = WARSAW_REGION.latitudeDelta * 0.45; + const halfLon = WARSAW_REGION.longitudeDelta * 0.45; + cached = []; + for (let index = 0; index < count; index += 1) { + cached.push({ + id: `dense-${index}`, + coordinate: { + latitude: WARSAW_REGION.latitude + (rng() * 2 - 1) * halfLat, + longitude: WARSAW_REGION.longitude + (rng() * 2 - 1) * halfLon, + }, + }); + } + denseCache.set(count, cached); + } + return cached; +} /** Deterministic Poland dataset, memoized so scenarios share one array identity. */ export function markers(count: number): MarkerDescriptor[] { diff --git a/example/benchmark/scenarios.ts b/example/benchmark/scenarios.ts index e53c668..056e2bb 100644 --- a/example/benchmark/scenarios.ts +++ b/example/benchmark/scenarios.ts @@ -8,6 +8,7 @@ import { import { POLAND_REGION, WARSAW_REGION, + denseMarkers, longRoute, markers, polygonGrid, @@ -310,6 +311,16 @@ export const SCENARIOS: BenchmarkScenario[] = [ }, ]; +SCENARIOS.push({ + id: 'N-dense-10k', + name: 'N · Dense 10,000', + description: + '10,000 markers inside the city viewport; a street-level zoom sweep where the LOD cap allows 2,000 on screen.', + props: () => ({ region: WARSAW_REGION, markers: denseMarkers(10_000) }), + settleMs: 2500, + run: (context) => zoomSweep(context, WARSAW_REGION, [13, 14, 12, 15, 11]), +}); + export const SKIPPED_SCENARIOS = [ 'J · Live location: needs location permission and a scripted GPS feed; run manually with the simulator location menu.', ]; diff --git a/example/maestro/benchmark-run-all.yaml b/example/maestro/benchmark-run-all.yaml index 2bec575..ece0545 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 "/13 passed" once every scenario has a result; the +# The summary reads "/14 passed" once every scenario has a result; the # last result row can sit below the fold of the results list. - extendedWaitUntil: visible: - text: '.*/13 passed' + text: '.*/14 passed' timeout: 300000 diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterOctaveCache.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterOctaveCache.kt new file mode 100644 index 0000000..7b3c6c6 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterOctaveCache.kt @@ -0,0 +1,63 @@ +package com.margelo.nitro.nitromaps + +/** + * Buckets from the previous refresh, kept while the zoom octave and the + * dataset stay the same. + * + * The cluster grid is anchored to geography, so a pan within one octave only + * changes which cells are on screen. Cells that were fully inside the previous + * padded viewport are reused as they are; only the cells that entered are + * accumulated. Cells that leave are dropped so the cache stays the size of one + * viewport. Owned by one compute thread; not thread-safe. + */ +internal class ClusterOctaveCache { + private var cellLat = Double.NaN + private var cellLon = Double.NaN + private var generation = Long.MIN_VALUE + internal val buckets = HashMap() + private val computed = HashSet() + + /** Number of candidates skipped because their cell was already computed. */ + var reusedCandidates = 0L + private set + + /** Starts a refresh; drops everything when the octave or the dataset changed. */ + fun begin(cellLat: Double, cellLon: Double, generation: Long) { + if (this.cellLat != cellLat || this.cellLon != cellLon || this.generation != generation) { + buckets.clear() + computed.clear() + this.cellLat = cellLat + this.cellLon = cellLon + this.generation = generation + } + } + + fun isComputed(key: Long): Boolean { + val hit = computed.contains(key) + if (hit) reusedCandidates += 1 + return hit + } + + /** + * Marks every cell of [range] computed and evicts cells outside it, including + * the edge cells accumulated this pass that the next viewport may only cover + * partially. + */ + fun finish(range: MarkerClusterEngine.CellRange) { + computed.removeAll { key -> !range.contains(key) } + buckets.keys.removeAll { key -> !range.contains(key) } + for (row in range.rowMin..range.rowMax) { + for (column in range.colMin..range.colMax) { + computed.add(MarkerClusterEngine.cellKey(row, column)) + } + } + } + + fun clear() { + buckets.clear() + computed.clear() + cellLat = Double.NaN + cellLon = Double.NaN + generation = Long.MIN_VALUE + } +} 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 7a0eafb..679b2f6 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 @@ -41,6 +41,9 @@ class HybridMapView(private val context: ThemedReactContext) : override val view: FrameLayout = FrameLayout(context) + /** Apple-only: Google Maps draws its own default marker. Stored so the prop round-trips. */ + override var pinStyle: MarkerPinStyle? = null + override var provider: MapProvider? get() = _provider set(value) { @@ -334,6 +337,7 @@ class HybridMapView(private val context: ThemedReactContext) : _mapPadding = null _markerEnteringAnimation = null _clusterEnteringAnimation = null + pinStyle = null onRegionChange = null onRegionChangeComplete = null onMapReady = null 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 index ec9a395..5b21648 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/IntList.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/IntList.kt @@ -44,4 +44,10 @@ internal class IntList(initialCapacity: Int = 4) { fun isEmpty(): Boolean = size == 0 fun toIntArray(): IntArray = values.copyOf(size) + + fun copy(): IntList { + val other = IntList(size.coerceAtLeast(1)) + other.addAll(this) + return other + } } 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 f3e28c2..95d5c4a 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 @@ -6,6 +6,7 @@ import android.animation.ValueAnimator import android.os.Handler import android.os.Looper import android.os.SystemClock +import android.view.WindowManager import com.facebook.react.uimanager.ThemedReactContext import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap @@ -42,6 +43,30 @@ class MapOverlayController( private var store: MarkerStore? = null /** Invalidates in-flight refresh results (viewport diffs). */ private var refreshGeneration: Int = 0 + /** Bumped whenever the dataset or the clustering mode changes; keyed into the octave cache. */ + private var datasetGeneration: Long = 0L + private var clusterCache = ClusterOctaveCache() + private val applyQueue = MarkerApplyQueue() + private val applyScheduler = MarkerApplyScheduler( + applyQueue, + object : MarkerApplyQueue.Sink { + override fun remove(keys: List) = applyRemovals(keys) + + override fun add(elements: List, pending: PendingMarkerApply) = + applyAdds(elements, pending) + + override fun update(element: ClusterElement) = applyRetained(element) + }, + expectedFrameNanos = { expectedFrameNanos }, + ) + private val expectedFrameNanos: Long by lazy { + val rate = runCatching { + @Suppress("DEPRECATION") + (context.getSystemService(android.content.Context.WINDOW_SERVICE) as? WindowManager) + ?.defaultDisplay?.refreshRate + }.getOrNull()?.takeIf { it > 0f } ?: 60f + (1_000_000_000.0 / rate).toLong() + } private val refreshInbox = RefreshInbox() private var viewWidthPx: Int = 0 private var viewHeightPx: Int = 0 @@ -80,6 +105,7 @@ class MapOverlayController( } clusteringEnabled = enabled + invalidateClusterCache() reapplyMarkers() } @@ -104,15 +130,26 @@ class MapOverlayController( next?.addListener(this) refreshGeneration += 1 refreshInbox.discardPending() + invalidateClusterCache() reapplyMarkers() } override fun onMarkerStoreChanged(store: MarkerStore) { if (this.store === store) { + invalidateClusterCache() reapplyMarkers() } } + /** + * The compute executor may still be inside a refresh that holds the old + * cache, so a fresh object replaces it instead of clearing it in place. + */ + private fun invalidateClusterCache() { + datasetGeneration += 1 + clusterCache = ClusterOctaveCache() + } + /** Ids of the markers inside a displayed cluster; empty once it is gone. */ fun clusterMembers(id: String): Array { val cluster = clustersById[id] ?: return emptyArray() @@ -123,6 +160,7 @@ class MapOverlayController( fun markerId(marker: Marker): String? = (marker.tag as? MarkerRenderKey.Single)?.id fun clear() { + applyScheduler.cancel() markerEnterAnimators.values.toSet().forEach { it.cancel() } cancelIdleRefresh() cancelLiveRefresh() @@ -142,6 +180,7 @@ class MapOverlayController( circleVersions.clear() refreshGeneration += 1 refreshInbox.discardPending() + invalidateClusterCache() computeExecutor.shutdown() computeExecutor = Executors.newSingleThreadExecutor() } @@ -185,12 +224,13 @@ class MapOverlayController( val request = ViewportRefreshRequest( generation = refreshGeneration, store = store, + cache = clusterCache, + datasetGeneration = datasetGeneration, bounds = bounds, latitudeSpan = bounds.northeast.latitude - bounds.southwest.latitude, clustering = clusteringEnabled, widthPx = viewWidthPx, heightPx = viewHeightPx, - displayedVersions = HashMap(markerVersions), animateEntering = animateEntering, maxAnimatedMarkers = maxAnimatedMarkers, ) @@ -201,20 +241,24 @@ class MapOverlayController( computeExecutor.execute { val pending = refreshInbox.take() ?: return@execute - val diff = computeViewportDiff(pending) + val target = computeViewportTarget(pending) mainHandler.post { if (pending.generation != refreshGeneration) { return@post } - applyDiff(diff, pending.animateEntering, pending.maxAnimatedMarkers) + // Diffed here, against what is on the map now: the scheduler may have + // applied adds from the previous diff while the target was computed, + // and a diff against an older snapshot would add those markers twice. + applyDiff(computeMarkerRenderDiff(target, markerVersions), pending.animateEntering, pending.maxAnimatedMarkers) } } } - private fun computeViewportDiff( + /** What the viewport should show: the index query, the cluster or LOD pass, and the materialized elements. */ + private fun computeViewportTarget( request: ViewportRefreshRequest, - ): MarkerRenderDiff = traceSection("NitroMaps.computeViewportDiff") { + ): List = traceSection("NitroMaps.computeViewportDiff") { // 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 @@ -235,6 +279,8 @@ class MapOverlayController( request.widthPx, request.heightPx, density, + cache = request.cache, + generation = request.datasetGeneration, ) } else { MarkerViewportFilter @@ -242,8 +288,7 @@ class MapOverlayController( .map { MarkerClusterEngine.Element.Single(it) } } - val target = request.store.read { access -> materialize(elements, access) } - computeMarkerRenderDiff(target, request.displayedVersions) + request.store.read { access -> materialize(elements, access) } } /** What one viewport refresh takes from the store under its lock. */ @@ -286,14 +331,28 @@ class MapOverlayController( return result } + /** + * Hands a diff to the frame scheduler: removals now, adds spread over frames + * nearest to the camera first, retained updates in the remaining budget. + */ private fun applyDiff( diff: MarkerRenderDiff, animateEntering: Boolean = true, maxAnimatedMarkers: Int = MAX_ANIMATED_MARKERS_PER_DIFF, - ) = traceSection("NitroMaps.applyMarkerDiff") { - val map = googleMap ?: return@traceSection + ) { + val map = googleMap ?: return + applyScheduler.schedule( + PendingMarkerApply( + diff, + center = map.cameraPosition?.target, + animateEntering = animateEntering, + animationBudget = maxAnimatedMarkers.coerceAtLeast(0), + ), + ) + } - for (key in diff.removedKeys) { + private fun applyRemovals(keys: List) { + for (key in keys) { cancelEnteringAnimation(key) markers.remove(key)?.remove() markerVersions.remove(key) @@ -301,10 +360,14 @@ class MapOverlayController( clustersById.remove(key.id) } } + } - var remainingAnimationBudget = maxAnimatedMarkers.coerceAtLeast(0) - val addedMarkers = ArrayList(minOf(diff.added.size, remainingAnimationBudget)) - for (element in diff.added) { + private fun applyAdds(elements: List, pending: PendingMarkerApply) { + val map = googleMap ?: return + val animateEntering = pending.animateEntering + var remainingAnimationBudget = pending.animationBudget + val addedMarkers = ArrayList(minOf(elements.size, remainingAnimationBudget)) + for (element in elements) { val key = element.key when (element) { is ClusterElement.Single -> { @@ -353,33 +416,33 @@ class MapOverlayController( } } } + pending.animationBudget = remainingAnimationBudget + animateEntering(addedMarkers) + } - for (element in diff.retained) { - val key = element.key - val marker = markers[key] ?: continue - cancelEnteringAnimation(key) - when (element) { - is ClusterElement.Single -> { - marker.position = LatLng( - element.descriptor.coordinate.latitude, - element.descriptor.coordinate.longitude, - ) - marker.title = element.descriptor.title - marker.snippet = element.descriptor.subtitle - marker.isDraggable = element.descriptor.draggable == true - markerIconFactory.applyVisualProps(element.descriptor, marker, key) - } - is ClusterElement.Cluster -> { - marker.alpha = 1f - marker.position = element.position - marker.setIcon(iconFactory.icon(element.count)) - clustersById[element.id] = element - } + private fun applyRetained(element: ClusterElement) { + val key = element.key + val marker = markers[key] ?: return + cancelEnteringAnimation(key) + when (element) { + is ClusterElement.Single -> { + marker.position = LatLng( + element.descriptor.coordinate.latitude, + element.descriptor.coordinate.longitude, + ) + marker.title = element.descriptor.title + marker.snippet = element.descriptor.subtitle + marker.isDraggable = element.descriptor.draggable == true + markerIconFactory.applyVisualProps(element.descriptor, marker, key) + } + is ClusterElement.Cluster -> { + marker.alpha = 1f + marker.position = element.position + marker.setIcon(iconFactory.icon(element.count)) + clustersById[element.id] = element } - markerVersions[key] = element.renderVersion } - - animateEntering(addedMarkers) + markerVersions[key] = element.renderVersion } /** Applies entering animations to newly added markers via a single shared animator. */ @@ -661,12 +724,13 @@ class MapOverlayController( private data class ViewportRefreshRequest( val generation: Int, val store: MarkerStore, + val cache: ClusterOctaveCache, + val datasetGeneration: Long, val bounds: LatLngBounds, val latitudeSpan: Double, val clustering: Boolean, val widthPx: Int, val heightPx: Int, - val displayedVersions: Map, val animateEntering: Boolean, val maxAnimatedMarkers: Int, ) diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerApplyQueue.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerApplyQueue.kt new file mode 100644 index 0000000..5b02470 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerApplyQueue.kt @@ -0,0 +1,142 @@ +package com.margelo.nitro.nitromaps + +import com.google.android.gms.maps.model.LatLng +import kotlin.math.cos + +/** One render diff waiting to be applied over frames. */ +internal class PendingMarkerApply( + diff: MarkerRenderDiff, + center: LatLng?, + val animateEntering: Boolean, + /** Entering animations left for this diff; the sink decrements it. */ + var animationBudget: Int, +) { + private var removals: List = diff.removedKeys.toList() + val adds: ArrayDeque = ArrayDeque(sortedByDistance(diff.added, center)) + val retained: ArrayDeque = ArrayDeque(diff.retained) + + val isEmpty: Boolean + get() = removals.isEmpty() && adds.isEmpty() && retained.isEmpty() + + /** Hands out the removals once. */ + fun takeRemovals(): List { + val taken = removals + removals = emptyList() + return taken + } + + companion object { + /** Nearest to the viewport centre first, so the visible middle fills before the edges. */ + fun sortedByDistance(elements: List, center: LatLng?): List { + if (center == null || elements.size < 2) { + return elements + } + val cosLat = cos(Math.toRadians(center.latitude)) + return elements.sortedBy { element -> + val (latitude, longitude) = position(element) + val dLat = latitude - center.latitude + val dLon = (longitude - center.longitude) * cosLat + dLat * dLat + dLon * dLon + } + } + + private fun position(element: ClusterElement): Pair = when (element) { + is ClusterElement.Single -> + element.descriptor.coordinate.latitude to element.descriptor.coordinate.longitude + is ClusterElement.Cluster -> element.position.latitude to element.position.longitude + } + } +} + +/** + * Applies render diffs over several frames instead of in one pass. + * + * Removals go out in full on the first step (cheap, and they free the screen), + * adds go out a bounded number per frame, nearest to the centre first, and + * retained updates fill whatever is left of the time budget. The number of + * adds per frame adapts to the observed frame interval: a long frame halves + * it, frames on budget grow it back, but only up to three quarters of the + * last count that dropped a frame; that ceiling creeps up by one per good + * frame so a one-off hitch does not pin the rate. + * + * A new diff replaces whatever was still pending. Diffs are computed against + * what is actually on the map, so anything not yet applied is either in the + * new diff again or no longer wanted. + */ +internal class MarkerApplyQueue(private val now: () -> Long = System::nanoTime) { + interface Sink { + fun remove(keys: List) + + fun add(elements: List, pending: PendingMarkerApply) + + fun update(element: ClusterElement) + } + + private var pending: PendingMarkerApply? = null + + var addsPerFrame: Int = INITIAL_ADDS_PER_FRAME + private set + + /** The add count that last dropped a frame, or null before the first one. */ + var ceiling: Int? = null + private set + + val hasWork: Boolean + get() = pending?.isEmpty == false + + fun replace(next: PendingMarkerApply) { + pending = if (next.isEmpty) null else next + } + + fun clear() { + pending = null + } + + /** Adapts the per-frame add count to how long the last frame took. */ + fun observeFrame(intervalNanos: Long, expectedNanos: Long) { + if (expectedNanos <= 0) { + return + } + if (intervalNanos > expectedNanos + expectedNanos / 2) { + ceiling = addsPerFrame + addsPerFrame = maxOf(MIN_ADDS_PER_FRAME, addsPerFrame / 2) + } else if (intervalNanos <= expectedNanos + expectedNanos / 10) { + ceiling?.let { ceiling = it + 1 } + val limit = ceiling?.let { maxOf(MIN_ADDS_PER_FRAME, it * 3 / 4) } ?: MAX_ADDS_PER_FRAME + addsPerFrame = minOf(limit, addsPerFrame + addsPerFrame / 2) + } + } + + /** One frame's worth of work. */ + fun step(budgetNanos: Long, sink: Sink) { + val current = pending ?: return + val start = now() + + val removals = current.takeRemovals() + if (removals.isNotEmpty()) { + sink.remove(removals) + } + + if (current.adds.isNotEmpty()) { + val chunk = ArrayList(minOf(addsPerFrame, current.adds.size)) + while (chunk.size < addsPerFrame && current.adds.isNotEmpty()) { + chunk.add(current.adds.removeFirst()) + } + sink.add(chunk, current) + } + + while (current.retained.isNotEmpty() && now() - start < budgetNanos) { + sink.update(current.retained.removeFirst()) + } + + if (current.isEmpty) { + pending = null + } + } + + companion object { + const val INITIAL_ADDS_PER_FRAME = 32 + const val MIN_ADDS_PER_FRAME = 8 + const val MAX_ADDS_PER_FRAME = 256 + } +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerApplyScheduler.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerApplyScheduler.kt new file mode 100644 index 0000000..75188f8 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerApplyScheduler.kt @@ -0,0 +1,64 @@ +package com.margelo.nitro.nitromaps + +import android.view.Choreographer + +/** + * Drives a [MarkerApplyQueue] from the [Choreographer]: one step per vsync + * while there is work, nothing scheduled when there is none. + */ +internal class MarkerApplyScheduler( + private val queue: MarkerApplyQueue, + private val sink: MarkerApplyQueue.Sink, + private val expectedFrameNanos: () -> Long, +) : Choreographer.FrameCallback { + private var isScheduled = false + private var lastFrameNanos = 0L + + /** Replaces pending work, applies the first step right away and continues per frame. */ + fun schedule(pending: PendingMarkerApply) { + queue.replace(pending) + step() + if (queue.hasWork) { + request() + } + } + + fun cancel() { + queue.clear() + if (isScheduled) { + Choreographer.getInstance().removeFrameCallback(this) + isScheduled = false + } + lastFrameNanos = 0L + } + + override fun doFrame(frameTimeNanos: Long) { + isScheduled = false + if (lastFrameNanos != 0L) { + queue.observeFrame(frameTimeNanos - lastFrameNanos, expectedFrameNanos()) + } + lastFrameNanos = frameTimeNanos + step() + if (queue.hasWork) { + request() + } else { + lastFrameNanos = 0L + } + } + + private fun step() = traceSection("NitroMaps.applyMarkerDiff") { + queue.step(STEP_BUDGET_NANOS, sink) + } + + private fun request() { + if (!isScheduled) { + isScheduled = true + Choreographer.getInstance().postFrameCallback(this) + } + } + + private companion object { + /** Time for retained updates after the frame's adds, about a quarter of a 120 Hz frame. */ + const val STEP_BUDGET_NANOS = 2_000_000L + } +} 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 b1fef30..2b8495d 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 @@ -2,6 +2,7 @@ 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.floor import kotlin.math.log2 import kotlin.math.pow @@ -82,6 +83,20 @@ internal object MarkerClusterEngine { private const val CELL_DP = 64.0 + /** Padding the spatial index applies around the visible bounds when it selects candidates. */ + const val CANDIDATE_PADDING = 0.2 + + /** Rows and columns of cluster cells, inclusive. */ + class CellRange(val rowMin: Int, val rowMax: Int, val colMin: Int, val colMax: Int) { + fun contains(key: Long): Boolean { + val row = (key shr 32).toInt() + val column = key.toInt() + return row in rowMin..rowMax && column in colMin..colMax + } + } + + fun cellKey(row: Int, column: Int): Long = (row.toLong() shl 32) or (column.toLong() and 0xFFFF_FFFFL) + private fun wrapsLongitude(sw: LatLng, ne: LatLng): Boolean { return ne.longitude < sw.longitude } @@ -133,6 +148,8 @@ internal object MarkerClusterEngine { viewWidthPx: Int, viewHeightPx: Int, density: Float, + cache: ClusterOctaveCache? = null, + generation: Long = 0L, ): List { if (candidates.isEmpty()) { return emptyList() @@ -164,14 +181,21 @@ internal object MarkerClusterEngine { val cellLat = quantize((ne.latitude - sw.latitude) / rows) val cellLon = quantize(longitudeSpan(sw, ne) / cols) - val buckets = HashMap() + // Cells fully inside the padded candidate region can be kept for the next + // refresh; the cache is off across the antimeridian, where cell keys depend + // on the viewport's own longitude reference. + val activeCache = if (wraps) null else cache?.also { it.begin(cellLat, cellLon, generation) } + val buckets = activeCache?.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.toLong() shl 32) or (col.toLong() and 0xFFFF_FFFFL) + val key = cellKey(row, col) + if (activeCache != null && activeCache.isComputed(key)) { + continue + } val bucket = buckets.getOrPut(key) { Bucket(row, col) } bucket.count += 1 bucket.sumLat += lat @@ -183,8 +207,41 @@ internal object MarkerClusterEngine { bucket.memberHandles.add(handle) } + // Render the cells that overlap the padded region and nothing else: the + // cache may still hold cells from the previous viewport, and a stale cell + // would merge into an on-screen cluster and churn the trailing edge. + val latPad = (ne.latitude - sw.latitude) * CANDIDATE_PADDING + val lonPad = longitudeSpan(sw, ne) * CANDIDATE_PADDING + val inView: ArrayList + if (wraps) { + inView = ArrayList(buckets.values) + } else { + val overlapping = CellRange( + rowMin = floor((sw.latitude - latPad) / cellLat).toInt(), + rowMax = floor((ne.latitude + latPad) / cellLat).toInt(), + colMin = floor((sw.longitude - lonPad) / cellLon).toInt(), + colMax = floor((ne.longitude + lonPad) / cellLon).toInt(), + ) + inView = ArrayList(buckets.size) + for ((key, bucket) in buckets) { + if (overlapping.contains(key)) { + inView.add(bucket) + } + } + } + if (activeCache != null) { + activeCache.finish( + CellRange( + rowMin = ceil((sw.latitude - latPad) / cellLat).toInt(), + rowMax = floor((ne.latitude + latPad) / cellLat).toInt() - 1, + colMin = ceil((sw.longitude - lonPad) / cellLon).toInt(), + colMax = floor((ne.longitude + lonPad) / cellLon).toInt() - 1, + ), + ) + } + val merged = mergeOverlapping( - ArrayList(buckets.values), + inView, bounds, wraps, viewWidthPx, @@ -293,6 +350,8 @@ internal object MarkerClusterEngine { } } + // Groups are built on copies: the seeds may live in the octave cache and + // must not absorb their neighbours in place. val groups = HashMap() val order = ArrayList() for (index in (0 until n).sortedByDescending { buckets[it].count }) { @@ -301,14 +360,14 @@ internal object MarkerClusterEngine { if (existing != null) { existing.absorb(buckets[index]) } else { - groups[root] = buckets[index] + groups[root] = buckets[index].copy() order.add(root) } } return order.mapNotNull { groups[it] } } - private class Bucket(val row: Int, val column: Int) { + class Bucket(val row: Int, val column: Int) { var count = 0 var sumLat = 0.0 var sumLon = 0.0 @@ -322,6 +381,19 @@ internal object MarkerClusterEngine { val id: String get() = "$row:$column" + fun copy(): Bucket { + val other = Bucket(row, column) + other.count = count + other.sumLat = sumLat + other.sumLon = sumLon + other.minLat = minLat + other.maxLat = maxLat + other.minLon = minLon + other.maxLon = maxLon + other.memberHandles.addAll(memberHandles) + return other + } + /** Folds another bucket's members in; keeps own cell (seed = dominant). */ fun absorb(other: Bucket) { count += other.count diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/ClusterOctaveCacheTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/ClusterOctaveCacheTest.kt new file mode 100644 index 0000000..d3d1c4e --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/ClusterOctaveCacheTest.kt @@ -0,0 +1,120 @@ +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.assertTrue +import org.junit.Test + +class ClusterOctaveCacheTest { + private val count = 400 + private val latitudes = DoubleArray(count) { 52.0 + (it % 20) * 0.01 } + private val longitudes = DoubleArray(count) { 21.0 + (it / 20) * 0.01 } + private val flags = ByteArray(count) { (MarkerStore.FLAG_ALIVE or MarkerStore.FLAG_CLUSTERABLE).toByte() } + private val candidates = IntArray(count) { it } + + private fun bounds(centerLat: Double, centerLon: Double, span: Double = 0.1) = + LatLngBounds(LatLng(centerLat - span / 2, centerLon - span / 2), LatLng(centerLat + span / 2, centerLon + span / 2)) + + private fun run( + bounds: LatLngBounds, + cache: ClusterOctaveCache?, + generation: Long = 1L, + candidates: IntArray = this.candidates, + ) = MarkerClusterEngine.clusters(candidates, latitudes, longitudes, flags, bounds, 1080, 1920, 3f, cache, generation) + + /** What the spatial index would hand the engine: the handles near the padded bounds. */ + private fun candidatesNear(bounds: LatLngBounds): IntArray { + val latPad = (bounds.northeast.latitude - bounds.southwest.latitude) * 0.25 + val lonPad = (bounds.northeast.longitude - bounds.southwest.longitude) * 0.25 + return candidates.filter { handle -> + latitudes[handle] in (bounds.southwest.latitude - latPad)..(bounds.northeast.latitude + latPad) && + longitudes[handle] in (bounds.southwest.longitude - lonPad)..(bounds.northeast.longitude + lonPad) + }.toIntArray() + } + + private fun signature(elements: List): List = + elements.map { element -> + when (element) { + is MarkerClusterEngine.Element.Single -> "s${element.handle}" + is MarkerClusterEngine.Element.Cluster -> "c${element.id}:${element.count}:${element.memberHandles.sorted()}" + } + }.sorted() + + @Test + fun `cached refreshes match uncached ones`() { + val cache = ClusterOctaveCache() + val first = bounds(52.1, 21.1) + val uncached = run(first, null) + + val warm = run(first, cache) + assertEquals(0L, cache.reusedCandidates) + val reused = run(first, cache) + assertTrue(cache.reusedCandidates > 0) + + assertEquals(signature(uncached), signature(warm)) + assertEquals(signature(uncached), signature(reused)) + } + + @Test + fun `a pan reuses the cells that stay in view`() { + val cache = ClusterOctaveCache() + run(bounds(52.1, 21.1), cache) + val panned = bounds(52.12, 21.12) + + val cached = run(panned, cache) + val fresh = run(panned, null) + + assertTrue(cache.reusedCandidates > 0) + assertEquals(signature(fresh), signature(cached)) + } + + @Test + fun `a pan drops the cells that left the padded region`() { + val cache = ClusterOctaveCache() + val first = bounds(52.05, 21.05, span = 0.06) + run(first, cache, candidates = candidatesNear(first)) + // Far enough that cells of the first viewport fall outside the padded second one. + val panned = bounds(52.15, 21.15, span = 0.06) + val nearPanned = candidatesNear(panned) + + val cached = run(panned, cache, candidates = nearPanned) + val fresh = run(panned, null, candidates = nearPanned) + + assertEquals(signature(fresh), signature(cached)) + val members = cached.flatMap { element -> + when (element) { + is MarkerClusterEngine.Element.Single -> listOf(element.handle) + is MarkerClusterEngine.Element.Cluster -> element.memberHandles.toList() + } + } + assertTrue(members.all { it in nearPanned }) + } + + @Test + fun `a dataset change drops the cache`() { + val cache = ClusterOctaveCache() + val view = bounds(52.1, 21.1) + run(view, cache, generation = 1L) + latitudes[0] = 52.19 + val fresh = run(view, null) + + val afterChange = run(view, cache, generation = 2L) + + assertEquals(0L, cache.reusedCandidates) + assertEquals(signature(fresh), signature(afterChange)) + } + + @Test + fun `a zoom octave change drops the cache`() { + val cache = ClusterOctaveCache() + run(bounds(52.1, 21.1, span = 0.1), cache) + val zoomed = bounds(52.1, 21.1, span = 0.4) + + val cached = run(zoomed, cache) + val fresh = run(zoomed, null) + + assertEquals(0L, cache.reusedCandidates) + assertEquals(signature(fresh), signature(cached)) + } +} diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerApplyQueueTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerApplyQueueTest.kt new file mode 100644 index 0000000..1188bc4 --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerApplyQueueTest.kt @@ -0,0 +1,193 @@ +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.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkerApplyQueueTest { + private class RecordingSink : MarkerApplyQueue.Sink { + val events = ArrayList() + + override fun remove(keys: List) { + events.add("remove " + keys.joinToString(",") { (it as MarkerRenderKey.Single).id }) + } + + override fun add(elements: List, pending: PendingMarkerApply) { + events.add("add " + elements.joinToString(",") { it.key.idForTest() }) + pending.animationBudget -= elements.size + } + + override fun update(element: ClusterElement) { + events.add("update " + element.key.idForTest()) + } + } + + private fun single(id: String, handle: Int, latitude: Double, longitude: Double = 21.0) = + ClusterElement.Single( + handle, + MarkerDescriptor(id, Coordinate(latitude, longitude), null, null, null, null, null, null, null, null, null, null, null), + 1L, + ) + + private fun diff( + removed: List = emptyList(), + added: List = emptyList(), + retained: List = emptyList(), + ) = MarkerRenderDiff(removed.toSet(), added, retained) + + @Test + fun `removals go first and adds nearest the centre`() { + val clock = FakeClock() + val queue = MarkerApplyQueue(clock::now) + val sink = RecordingSink() + val far = single("far", 1, 53.0) + val near = single("near", 2, 52.01) + val mid = single("mid", 3, 52.5) + queue.replace( + PendingMarkerApply( + diff( + removed = listOf(MarkerRenderKey.Single(9, "gone")), + added = listOf(far, near, mid), + retained = listOf(single("kept", 4, 52.0)), + ), + center = LatLng(52.0, 21.0), + animateEntering = true, + animationBudget = 10, + ), + ) + + queue.step(budgetNanos = 1_000_000, sink) + + assertEquals(listOf("remove gone", "add near,mid,far", "update kept"), sink.events) + assertFalse(queue.hasWork) + } + + @Test + fun `adds are spread over frames by the per-frame count`() { + val queue = MarkerApplyQueue(FakeClock()::now) + val sink = RecordingSink() + val added = (0 until 70).map { single("m$it", it, 52.0 + it * 0.001) } + queue.replace(PendingMarkerApply(diff(added = added), null, true, 100)) + + queue.step(1_000_000, sink) + assertEquals(MarkerApplyQueue.INITIAL_ADDS_PER_FRAME, sink.events.single().split(" ")[1].split(",").size) + assertTrue(queue.hasWork) + queue.step(1_000_000, sink) + queue.step(1_000_000, sink) + assertEquals(3, sink.events.size) + assertEquals(6, sink.events[2].split(" ")[1].split(",").size) + assertFalse(queue.hasWork) + } + + @Test + fun `retained updates stop when the budget is spent`() { + val clock = FakeClock(stepNanos = 600_000) + val queue = MarkerApplyQueue(clock::now) + val sink = RecordingSink() + val retained = (0 until 10).map { single("r$it", it, 52.0) } + queue.replace(PendingMarkerApply(diff(retained = retained), null, true, 0)) + + queue.step(budgetNanos = 2_000_000, sink) + + // The clock advances 0.6 ms per read, one read at the start and one per + // budget check: three updates go out before a check sees 2 ms spent. + assertEquals(3, sink.events.size) + assertTrue(queue.hasWork) + } + + @Test + fun `a new diff replaces what was pending`() { + val queue = MarkerApplyQueue(FakeClock()::now) + val sink = RecordingSink() + val first = (0 until 50).map { single("a$it", it, 52.0) } + queue.replace(PendingMarkerApply(diff(added = first), null, true, 0)) + queue.step(1_000_000, sink) + + queue.replace(PendingMarkerApply(diff(added = listOf(single("b", 99, 52.0))), null, true, 0)) + queue.step(1_000_000, sink) + + assertEquals("add b", sink.events.last()) + assertFalse(queue.hasWork) + } + + @Test + fun `the per-frame count adapts to frame intervals`() { + val queue = MarkerApplyQueue(FakeClock()::now) + val expected = 16_666_667L + + queue.observeFrame(intervalNanos = expected * 3, expectedNanos = expected) + assertEquals(MarkerApplyQueue.INITIAL_ADDS_PER_FRAME / 2, queue.addsPerFrame) + + repeat(20) { queue.observeFrame(intervalNanos = expected * 2, expectedNanos = expected) } + assertEquals(MarkerApplyQueue.MIN_ADDS_PER_FRAME, queue.addsPerFrame) + } + + @Test + fun `the per-frame count grows without limit until a frame drops`() { + val queue = MarkerApplyQueue(FakeClock()::now) + val expected = 16_666_667L + + repeat(20) { queue.observeFrame(intervalNanos = expected, expectedNanos = expected) } + + assertEquals(MarkerApplyQueue.MAX_ADDS_PER_FRAME, queue.addsPerFrame) + assertEquals(null, queue.ceiling) + } + + @Test + fun `after a dropped frame growth stays below the count that dropped it`() { + val queue = MarkerApplyQueue(FakeClock()::now) + val expected = 16_666_667L + repeat(3) { queue.observeFrame(intervalNanos = expected, expectedNanos = expected) } + val beforeDrop = queue.addsPerFrame + + queue.observeFrame(intervalNanos = expected * 2, expectedNanos = expected) + assertEquals(beforeDrop, queue.ceiling) + assertEquals(beforeDrop / 2, queue.addsPerFrame) + + repeat(10) { queue.observeFrame(intervalNanos = expected, expectedNanos = expected) } + // Ten good frames let the ceiling creep by ten; growth stays at three quarters of it. + assertEquals((beforeDrop + 10) * 3 / 4, queue.addsPerFrame) + assertTrue(queue.addsPerFrame < beforeDrop) + } + + @Test + fun `the animation budget carries across frames`() { + val queue = MarkerApplyQueue(FakeClock()::now) + val sink = RecordingSink() + val added = (0 until 40).map { single("m$it", it, 52.0) } + val pending = PendingMarkerApply(diff(added = added), null, true, animationBudget = 40) + queue.replace(pending) + + queue.step(1_000_000, sink) + assertEquals(8, pending.animationBudget) + queue.step(1_000_000, sink) + assertEquals(0, pending.animationBudget) + } + + @Test + fun `clusters sort by their badge position`() { + val bounds = LatLngBounds(LatLng(51.0, 20.0), LatLng(53.0, 22.0)) + val farCluster = ClusterElement.Cluster("1:1", LatLng(53.0, 22.0), 5, intArrayOf(1), bounds) + val nearCluster = ClusterElement.Cluster("2:2", LatLng(52.0, 21.0), 5, intArrayOf(2), bounds) + val sorted = PendingMarkerApply.sortedByDistance(listOf(farCluster, nearCluster), LatLng(52.0, 21.0)) + + assertEquals(listOf("2:2", "1:1"), sorted.map { (it as ClusterElement.Cluster).id }) + } + + private class FakeClock(private val stepNanos: Long = 0L) { + private var nowNanos = 0L + + fun now(): Long { + nowNanos += stepNanos + return nowNanos + } + } +} + +private fun MarkerRenderKey.idForTest(): String = when (this) { + is MarkerRenderKey.Single -> id + is MarkerRenderKey.Cluster -> id +} diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index 3123b4d..6a7f49c 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -7,7 +7,6 @@ final class AppleMapProviderAdapter: MapProviderAdapter { private var isUserRegionChange = false private var isMapReady = false private var hasDeliveredMapReady = false - private var liveClusterTimer: Timer? fileprivate lazy var overlayController = MapOverlayController(mapView: view) var contentView: UIView { @@ -32,6 +31,10 @@ final class AppleMapProviderAdapter: MapProviderAdapter { NitroPinAnnotationView.self, forAnnotationViewWithReuseIdentifier: NitroPinAnnotationView.reuseIdentifier ) + mapView.register( + NitroFlatPinAnnotationView.self, + forAnnotationViewWithReuseIdentifier: NitroFlatPinAnnotationView.reuseIdentifier + ) mapView.register( NitroImageAnnotationView.self, forAnnotationViewWithReuseIdentifier: NitroImageAnnotationView.reuseIdentifier @@ -149,6 +152,15 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } } + var pinStyle: MarkerPinStyle? { + didSet { + guard pinStyle != oldValue else { + return + } + overlayController.reloadMarkerViews() + } + } + var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? var onMapReady: (() -> Void)? { @@ -321,20 +333,11 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } func startLiveClustering() { - guard liveClusterTimer == nil else { - return - } - let timer = Timer(timeInterval: MarkerRenderPipeline.liveRefreshInterval, repeats: true) { [weak self] _ in - self?.overlayController.refreshNow() - } - RunLoop.main.add(timer, forMode: .common) - liveClusterTimer = timer + overlayController.beginLiveRefresh() } func stopLiveClustering() { - liveClusterTimer?.invalidate() - liveClusterTimer = nil - overlayController.scheduleViewportRefresh(immediate: true) + overlayController.endLiveRefresh() } func notifyMapReadyIfNeeded() { @@ -405,8 +408,6 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } func prepareForRecycle() { - liveClusterTimer?.invalidate() - liveClusterTimer = nil isUserRegionChange = false isMapReady = false hasDeliveredMapReady = false @@ -444,6 +445,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { mapPadding = nil markerEnteringAnimation = nil clusterEnteringAnimation = nil + pinStyle = nil view.mapType = .standard view.isScrollEnabled = true view.isZoomEnabled = true diff --git a/package/ios/ClusterOctaveCache.swift b/package/ios/ClusterOctaveCache.swift new file mode 100644 index 0000000..b8abc16 --- /dev/null +++ b/package/ios/ClusterOctaveCache.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Buckets from the previous refresh, kept while the zoom octave and the +/// dataset stay the same. +/// +/// The cluster grid is anchored to geography, so a pan within one octave only +/// changes which cells are on screen. Cells that were fully inside the previous +/// padded viewport are reused as they are; only the cells that entered are +/// accumulated. Cells that leave are dropped so the cache stays the size of one +/// viewport. Owned by one compute queue; not thread-safe. +final class ClusterOctaveCache { + private var cellLat = Double.nan + private var cellLon = Double.nan + private var generation = Int.min + var buckets: [Int64: MarkerClusterEngine.Bucket] = [:] + private var computed = Set() + + /// Number of candidates skipped because their cell was already computed. + private(set) var reusedCandidates = 0 + + /// Starts a refresh; drops everything when the octave or the dataset changed. + func begin(cellLat: Double, cellLon: Double, generation: Int) { + if self.cellLat != cellLat || self.cellLon != cellLon || self.generation != generation { + buckets.removeAll(keepingCapacity: true) + computed.removeAll(keepingCapacity: true) + self.cellLat = cellLat + self.cellLon = cellLon + self.generation = generation + } + } + + /// Moves the buckets out for accumulation; `buckets` is assigned back afterwards. + func takeBuckets() -> [Int64: MarkerClusterEngine.Bucket] { + let taken = buckets + buckets = [:] + return taken + } + + func isComputed(_ key: Int64) -> Bool { + let hit = computed.contains(key) + if hit { + reusedCandidates += 1 + } + return hit + } + + /// Marks every cell of `range` computed and evicts cells outside it, including + /// the edge cells accumulated this pass that the next viewport may only cover + /// partially. + func finish(_ range: MarkerClusterEngine.CellRange) { + computed = computed.filter { range.contains($0) } + buckets = buckets.filter { range.contains($0.key) } + guard range.rowMin <= range.rowMax, range.colMin <= range.colMax else { + return + } + for row in range.rowMin...range.rowMax { + for column in range.colMin...range.colMax { + computed.insert(MarkerClusterEngine.cellKey(row: row, column: column)) + } + } + } + + func clear() { + buckets.removeAll() + computed.removeAll() + cellLat = .nan + cellLon = .nan + generation = .min + } +} diff --git a/package/ios/FrameClock.swift b/package/ios/FrameClock.swift new file mode 100644 index 0000000..7462417 --- /dev/null +++ b/package/ios/FrameClock.swift @@ -0,0 +1,63 @@ +import QuartzCore + +/// A `CADisplayLink` that runs only while its owner has work. +/// +/// The link targets a proxy, so scheduling it does not keep the owner alive; +/// the owner stops it explicitly and it is invalidated on deinit either way. +final class FrameClock { + struct Frame { + let timestamp: CFTimeInterval + /// Time since the previous callback, nil on the first frame after start. + let interval: CFTimeInterval? + /// The display's current frame interval. + let expected: CFTimeInterval + } + + private final class Proxy: NSObject { + weak var clock: FrameClock? + + @objc func tick(_ link: CADisplayLink) { + clock?.tick(link) + } + } + + private let onFrame: (Frame) -> Void + private var link: CADisplayLink? + private var lastTimestamp: CFTimeInterval = 0 + + init(onFrame: @escaping (Frame) -> Void) { + self.onFrame = onFrame + } + + deinit { + link?.invalidate() + } + + var isRunning: Bool { + link != nil + } + + func start() { + guard link == nil else { + return + } + let proxy = Proxy() + proxy.clock = self + let link = CADisplayLink(target: proxy, selector: #selector(Proxy.tick(_:))) + link.add(to: .main, forMode: .common) + self.link = link + lastTimestamp = 0 + } + + func stop() { + link?.invalidate() + link = nil + lastTimestamp = 0 + } + + private func tick(_ link: CADisplayLink) { + let interval: CFTimeInterval? = lastTimestamp > 0 ? link.timestamp - lastTimestamp : nil + lastTimestamp = link.timestamp + onFrame(Frame(timestamp: link.timestamp, interval: interval, expected: link.duration)) + } +} diff --git a/package/ios/GoogleMapOverlayController.swift b/package/ios/GoogleMapOverlayController.swift index 5404d82..4a52a9a 100644 --- a/package/ios/GoogleMapOverlayController.swift +++ b/package/ios/GoogleMapOverlayController.swift @@ -38,6 +38,11 @@ final class GoogleMapOverlayController { private var polygonVersions: [String: ShapeRenderVersion] = [:] private var circleVersions: [String: ShapeRenderVersion] = [:] private let markerPipeline: MarkerRenderPipeline + private lazy var applyScheduler = MarkerApplyScheduler(sink: MarkerApplyScheduler.Sink( + remove: { [weak self] keys in self?.applyRemovals(keys) }, + add: { [weak self] entries, pending in self?.applyAdds(entries, pending: pending) }, + update: { [weak self] entry in self?.applyRetained(entry) } + )) private let visualApplier = GoogleMarkerVisualApplier() private var clusterIconCache: [String: UIImage] = [:] @@ -61,6 +66,7 @@ final class GoogleMapOverlayController { } func reset() { + applyScheduler.cancel() markerPipeline.store?.removeListener(self) markerPipeline.reset() clearMarkers() @@ -105,12 +111,11 @@ final class GoogleMapOverlayController { } markerPipeline.refreshNow( - displayedVersions: markerVersions, region: mapView.currentNitroRegion().toMKCoordinateRegion(), viewSize: mapView.bounds.size, - apply: { [weak self] diff in - self?.applyDiff( - diff, + apply: { [weak self] target in + self?.applyTarget( + target, animateEntering: animateEntering, animationBudget: animationBudget ) @@ -128,13 +133,12 @@ final class GoogleMapOverlayController { } markerPipeline.scheduleViewportRefresh( - displayedVersions: markerVersions, region: mapView.currentNitroRegion().toMKCoordinateRegion(), viewSize: mapView.bounds.size, immediate: immediate, - apply: { [weak self] diff in - self?.applyDiff( - diff, + apply: { [weak self] target in + self?.applyTarget( + target, animateEntering: animateEntering, animationBudget: animationBudget ) @@ -221,15 +225,32 @@ final class GoogleMapOverlayController { } markerPipeline.reapply( - displayedVersions: markerVersions, region: mapView.currentNitroRegion().toMKCoordinateRegion(), viewSize: mapView.bounds.size, - apply: { [weak self] diff in - self?.applyDiff(diff) + apply: { [weak self] target in + self?.applyTarget(target) } ) } + /// Diffs a computed target against what is on the map now. The scheduler + /// may have applied adds from the previous diff while the target was being + /// computed, and a diff against an older snapshot would add those twice. + private func applyTarget( + _ target: [MarkerRenderEntry], + animateEntering: Bool = true, + animationBudget: Int = maximumAnimatedMarkersPerDiff + ) { + applyDiff( + MarkerRenderPipeline.computeDiff(target: target, displayed: markerVersions), + animateEntering: animateEntering, + animationBudget: animationBudget + ) + } + + /// Hands a diff to the frame scheduler: removals now, adds spread over + /// frames nearest to the camera first, retained updates in the remaining + /// budget. The entering-animation budget spans the whole diff. private func applyDiff( _ diff: MarkerRenderDiff, animateEntering: Bool = true, @@ -238,23 +259,34 @@ final class GoogleMapOverlayController { guard let mapView else { return } + applyScheduler.schedule(PendingMarkerApply( + diff: diff, + center: mapView.camera.target, + animateEntering: animateEntering, + animationBudget: animateEntering ? max(0, animationBudget) : 0 + )) + } - let signpost = MapTrace.begin("applyMarkerDiff") - defer { MapTrace.end("applyMarkerDiff", signpost) } - - for key in diff.removedKeys { + private func applyRemovals(_ keys: [MarkerRenderKey]) { + for key in keys { markers.removeValue(forKey: key)?.map = nil markerVersions.removeValue(forKey: key) } + } + + private func applyAdds(_ entries: [MarkerRenderEntry], pending: PendingMarkerApply) { + guard let mapView else { + return + } var animationBatches: [MarkerAnimationBatch] = [] - var remainingAnimationBudget = animateEntering ? max(0, animationBudget) : 0 + var remainingAnimationBudget = pending.animationBudget - for entry in diff.added { + for entry in entries { let marker = GMSMarker() updateMarker(marker, with: entry.element) let animation = enteringAnimation(for: entry.element) - let shouldAnimate = animateEntering + let shouldAnimate = pending.animateEntering && remainingAnimationBudget > 0 && OverlayEnteringAnimationResolver.canAnimateGoogleMarker(animation) @@ -271,18 +303,19 @@ final class GoogleMapOverlayController { markers[entry.key] = marker markerVersions[entry.key] = entry.version } + pending.animationBudget = remainingAnimationBudget for batch in animationBatches { OverlayEnteringAnimationResolver.animateGoogleMarkers(batch.markers, animation: batch.animation) } + } - for entry in diff.retained { - guard let marker = markers[entry.key] else { - continue - } - updateMarker(marker, with: entry.element) - markerVersions[entry.key] = entry.version + private func applyRetained(_ entry: MarkerRenderEntry) { + guard let marker = markers[entry.key] else { + return } + updateMarker(marker, with: entry.element) + markerVersions[entry.key] = entry.version } private func append( @@ -517,6 +550,7 @@ extension GoogleMapOverlayController: MarkerStoreListener { guard markerPipeline.store === store else { return } + markerPipeline.invalidateClusterCache() reapplyMarkers() } } diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift index a68b409..ac3958a 100644 --- a/package/ios/GoogleMapProviderAdapter.swift +++ b/package/ios/GoogleMapProviderAdapter.swift @@ -171,6 +171,9 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { } } + /// Google Maps draws its own default marker; the Apple pin style does not apply. + var pinStyle: MarkerPinStyle? + var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? var onMapReady: (() -> Void)? { @@ -306,6 +309,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { mapPadding = nil markerEnteringAnimation = nil clusterEnteringAnimation = nil + pinStyle = nil } private func applyRegion(_ region: Region, animated: Bool = false) { diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift index a4357f6..7804a19 100644 --- a/package/ios/HybridMapView.swift +++ b/package/ios/HybridMapView.swift @@ -162,6 +162,11 @@ final class HybridMapView: HybridMapViewSpec { } } + var pinStyle: MarkerPinStyle? { + get { getBacked(\.pinStyle) } + set { setBackedOnMain(newValue, store: \.pinStyle) { $0.pinStyle = $1 } } + } + var onRegionChange: ((Region) -> Void)? { get { getBacked(\.onRegionChange) } set { setBackedOnMain(newValue, store: \.onRegionChange) { $0.onRegionChange = $1 } } diff --git a/package/ios/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift index 4e16451..c1279e7 100644 --- a/package/ios/HybridMapViewDelegate.swift +++ b/package/ios/HybridMapViewDelegate.swift @@ -128,17 +128,31 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni return imageView } - let pinView = mapView.dequeueReusableAnnotationView( - withIdentifier: NitroPinAnnotationView.reuseIdentifier, + if parent?.pinStyle == .system { + let pinView = mapView.dequeueReusableAnnotationView( + withIdentifier: NitroPinAnnotationView.reuseIdentifier, + for: marker + ) as! NitroPinAnnotationView + + pinView.configure(for: marker) + return pinView + } + + let flatView = mapView.dequeueReusableAnnotationView( + withIdentifier: NitroFlatPinAnnotationView.reuseIdentifier, for: marker - ) as! NitroPinAnnotationView + ) as! NitroFlatPinAnnotationView - pinView.configure(for: marker) - return pinView + flatView.configure(for: marker) + return flatView } func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) { for view in views { + if let marker = view.annotation as? MapMarkerAnnotation, marker.suppressesNextEnteringAnimation { + marker.suppressesNextEnteringAnimation = false + continue + } if let marker = view.annotation as? MapMarkerAnnotation, marker.enteringAnimation.kind != .system { OverlayEnteringAnimationResolver.animateAnnotationView( diff --git a/package/ios/MapMarkerAnnotation.swift b/package/ios/MapMarkerAnnotation.swift index 97e8d04..aa6850d 100644 --- a/package/ios/MapMarkerAnnotation.swift +++ b/package/ios/MapMarkerAnnotation.swift @@ -14,6 +14,9 @@ final class MapMarkerAnnotation: NSObject, MKAnnotation { private(set) var opacity: CGFloat private(set) var zIndex: Double? let enteringAnimation: ResolvedOverlayEnteringAnimation + /// Set before an annotation that is already on screen is re-added for a + /// view change, so the re-add does not replay its entering animation. + var suppressesNextEnteringAnimation = false @objc dynamic var coordinate: CLLocationCoordinate2D @objc dynamic var title: String? diff --git a/package/ios/MapOverlayController.swift b/package/ios/MapOverlayController.swift index 90b351f..100bb85 100644 --- a/package/ios/MapOverlayController.swift +++ b/package/ios/MapOverlayController.swift @@ -23,6 +23,15 @@ final class MapOverlayController { private var displayedAnnotations: [MarkerRenderKey: MKAnnotation] = [:] private var displayedAnnotationVersions: [MarkerRenderKey: Int] = [:] private let markerPipeline = MarkerRenderPipeline() + private lazy var applyScheduler = MarkerApplyScheduler(sink: MarkerApplyScheduler.Sink( + remove: { [weak self] keys in self?.applyRemovals(keys) }, + add: { [weak self] entries, _ in self?.applyAdds(entries) }, + update: { [weak self] entry in self?.applyRetained(entry) } + )) + private lazy var liveRefreshClock = FrameClock { [weak self] frame in + self?.liveRefreshTick(frame) + } + private var lastLiveRefreshTime: CFTimeInterval = 0 private var shapeOverlays: [String: MKOverlay] = [:] private var shapeVersions: [String: ShapeRenderVersion] = [:] private var overlayStyles: [ObjectIdentifier: OverlayStyle] = [:] @@ -46,6 +55,8 @@ final class MapOverlayController { } func reset() { + applyScheduler.cancel() + liveRefreshClock.stop() markerPipeline.store?.removeListener(self) markerPipeline.reset() guard let mapView else { @@ -88,26 +99,63 @@ final class MapOverlayController { } markerPipeline.reapply( - displayedVersions: displayedAnnotationVersions, region: mapView.region, viewSize: mapView.bounds.size, - apply: { [weak self] diff in - self?.applyDiff(diff) + apply: { [weak self] target in + self?.applyTarget(target) } ) } + /// Starts the vsync-aligned live refresh that runs while the camera moves. + func beginLiveRefresh() { + guard usesViewportPipeline else { + return + } + lastLiveRefreshTime = 0 + liveRefreshClock.start() + } + + /// Stops the live refresh and settles on the final viewport. + func endLiveRefresh() { + liveRefreshClock.stop() + scheduleViewportRefresh(immediate: true) + } + + private func liveRefreshTick(_ frame: FrameClock.Frame) { + guard frame.timestamp - lastLiveRefreshTime >= MarkerRenderPipeline.liveRefreshInterval else { + return + } + lastLiveRefreshTime = frame.timestamp + refreshNow() + } + + /// Re-creates the annotation views of every displayed marker, for a pin style change. + func reloadMarkerViews() { + guard let mapView else { + return + } + let markers = displayedAnnotations.values.compactMap { $0 as? MapMarkerAnnotation } + guard !markers.isEmpty else { + return + } + for marker in markers { + marker.suppressesNextEnteringAnimation = true + } + mapView.removeAnnotations(markers) + mapView.addAnnotations(markers) + } + /// Immediate (non-debounced) refresh used for live updates during gestures. func refreshNow() { guard let mapView, usesViewportPipeline else { return } markerPipeline.refreshNow( - displayedVersions: displayedAnnotationVersions, region: mapView.region, viewSize: mapView.bounds.size, - apply: { [weak self] diff in - self?.applyDiff(diff) + apply: { [weak self] target in + self?.applyTarget(target) } ) } @@ -119,76 +167,94 @@ final class MapOverlayController { } markerPipeline.scheduleViewportRefresh( - displayedVersions: displayedAnnotationVersions, region: mapView.region, viewSize: mapView.bounds.size, immediate: immediate, - apply: { [weak self] diff in - self?.applyDiff(diff) + apply: { [weak self] target in + self?.applyTarget(target) } ) } + /// Diffs a computed target against what is on the map now. The scheduler + /// may have applied adds from the previous diff while the target was being + /// computed, and a diff against an older snapshot would add those twice. + private func applyTarget(_ target: [MarkerRenderEntry]) { + applyDiff(MarkerRenderPipeline.computeDiff(target: target, displayed: displayedAnnotationVersions)) + } + + /// Hands a diff to the frame scheduler: removals now, adds spread over + /// frames nearest to the viewport centre first, retained updates in the + /// remaining budget. private func applyDiff(_ diff: MarkerRenderDiff) { guard let mapView else { return } + applyScheduler.schedule(PendingMarkerApply( + diff: diff, + center: mapView.region.center, + animateEntering: true, + animationBudget: .max + )) + } - let signpost = MapTrace.begin("applyMarkerDiff") - defer { MapTrace.end("applyMarkerDiff", signpost) } - - if !diff.removedKeys.isEmpty { - let removed = diff.removedKeys.compactMap { key in - displayedAnnotationVersions.removeValue(forKey: key) - return displayedAnnotations.removeValue(forKey: key) - } - mapView.removeAnnotations(removed) + private func applyRemovals(_ keys: [MarkerRenderKey]) { + guard let mapView else { + return + } + let removed = keys.compactMap { key in + displayedAnnotationVersions.removeValue(forKey: key) + return displayedAnnotations.removeValue(forKey: key) } + mapView.removeAnnotations(removed) + } - if !diff.added.isEmpty { - var annotations: [MKAnnotation] = [] - annotations.reserveCapacity(diff.added.count) - for entry in diff.added { - let annotation = entry.element.makeAnnotation( - markerEnteringAnimation: markerEnteringAnimation, - clusterEnteringAnimation: clusterEnteringAnimation - ) - displayedAnnotations[entry.key] = annotation - displayedAnnotationVersions[entry.key] = entry.version - annotations.append(annotation) - } - mapView.addAnnotations(annotations) + private func applyAdds(_ entries: [MarkerRenderEntry]) { + guard let mapView else { + return + } + var annotations: [MKAnnotation] = [] + annotations.reserveCapacity(entries.count) + for entry in entries { + let annotation = entry.element.makeAnnotation( + markerEnteringAnimation: markerEnteringAnimation, + clusterEnteringAnimation: clusterEnteringAnimation + ) + displayedAnnotations[entry.key] = annotation + displayedAnnotationVersions[entry.key] = entry.version + annotations.append(annotation) } + mapView.addAnnotations(annotations) + } - for entry in diff.retained { - guard let existing = displayedAnnotations[entry.key] else { - continue - } + private func applyRetained(_ entry: MarkerRenderEntry) { + guard let mapView, let existing = displayedAnnotations[entry.key] else { + return + } - switch entry.element { - case let .single(descriptor): - if let marker = existing as? MapMarkerAnnotation { - let visualChanged = marker.update(from: descriptor) - if visualChanged { - refreshMarkerView(for: marker) - } + switch entry.element { + case let .single(descriptor): + if let marker = existing as? MapMarkerAnnotation { + let visualChanged = marker.update(from: descriptor) + if visualChanged { + refreshMarkerView(for: marker) } - case let .cluster(id, coordinate, count, memberHandles, region): - if let cluster = existing as? MapClusterAnnotation { - cluster.update( - id: id, - coordinate: coordinate, - count: count, - memberHandles: memberHandles, - region: region - ) - if let view = mapView.view(for: cluster) as? NitroClusterAnnotationView { - view.configure(count: count) - } + } + case let .cluster(id, coordinate, count, memberHandles, region): + if let cluster = existing as? MapClusterAnnotation { + cluster.update( + id: id, + coordinate: coordinate, + count: count, + memberHandles: memberHandles, + region: region + ) + if let view = mapView.view(for: cluster) as? NitroClusterAnnotationView { + view.configure(count: count) } } - displayedAnnotationVersions[entry.key] = entry.version } + displayedAnnotationVersions[entry.key] = entry.version } private func refreshMarkerView(for marker: MapMarkerAnnotation) { @@ -200,6 +266,7 @@ final class MapOverlayController { let hasImageView = view is NitroImageAnnotationView if needsImageView != hasImageView { + marker.suppressesNextEnteringAnimation = true mapView.removeAnnotation(marker) mapView.addAnnotation(marker) return @@ -207,6 +274,8 @@ final class MapOverlayController { if let imageView = view as? NitroImageAnnotationView { imageView.configure(for: marker) + } else if let flatView = view as? NitroFlatPinAnnotationView { + flatView.configure(for: marker) } else { (view as? NitroPinAnnotationView)?.configure(for: marker) } @@ -413,6 +482,7 @@ extension MapOverlayController: MarkerStoreListener { guard markerPipeline.store === store else { return } + markerPipeline.invalidateClusterCache() reapplyMarkers() } } diff --git a/package/ios/MapProviderAdapter.swift b/package/ios/MapProviderAdapter.swift index d68aa31..141e259 100644 --- a/package/ios/MapProviderAdapter.swift +++ b/package/ios/MapProviderAdapter.swift @@ -21,6 +21,7 @@ protocol MapProviderAdapter: AnyObject { var mapPadding: EdgePadding? { get set } var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? { get set } var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? { get set } + var pinStyle: MarkerPinStyle? { get set } var onRegionChange: ((Region) -> Void)? { get set } var onRegionChangeComplete: ((Region) -> Void)? { get set } @@ -71,6 +72,7 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter { var mapPadding: EdgePadding? var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? + var pinStyle: MarkerPinStyle? var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? diff --git a/package/ios/MapViewState.swift b/package/ios/MapViewState.swift index 53ee19e..3221653 100644 --- a/package/ios/MapViewState.swift +++ b/package/ios/MapViewState.swift @@ -19,6 +19,7 @@ struct MapViewState { var mapPadding: EdgePadding? var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? + var pinStyle: MarkerPinStyle? var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? var onMapReady: (() -> Void)? @@ -54,6 +55,7 @@ struct MapViewState { adapter.mapPadding = mapPadding adapter.markerEnteringAnimation = markerEnteringAnimation adapter.clusterEnteringAnimation = clusterEnteringAnimation + adapter.pinStyle = pinStyle adapter.onRegionChange = onRegionChange adapter.onRegionChangeComplete = onRegionChangeComplete adapter.onMapReady = onMapReady diff --git a/package/ios/MarkerApplyScheduler.swift b/package/ios/MarkerApplyScheduler.swift new file mode 100644 index 0000000..e0958be --- /dev/null +++ b/package/ios/MarkerApplyScheduler.swift @@ -0,0 +1,181 @@ +import MapKit + +/// One render diff waiting to be applied over frames. +final class PendingMarkerApply { + private(set) var removals: [MarkerRenderKey] + var adds: ArraySlice + var retained: ArraySlice + let animateEntering: Bool + /// Entering animations left for this diff; the sink decrements it. + var animationBudget: Int + + init( + diff: MarkerRenderDiff, + center: CLLocationCoordinate2D?, + animateEntering: Bool, + animationBudget: Int + ) { + removals = Array(diff.removedKeys) + adds = Self.sortedByDistance(diff.added, center: center)[...] + retained = diff.retained[...] + self.animateEntering = animateEntering + self.animationBudget = animationBudget + } + + var isEmpty: Bool { + removals.isEmpty && adds.isEmpty && retained.isEmpty + } + + /// Hands out the removals once. + func takeRemovals() -> [MarkerRenderKey] { + let taken = removals + removals = [] + return taken + } + + /// Nearest to the viewport centre first, so the visible middle fills before the edges. + static func sortedByDistance( + _ entries: [MarkerRenderEntry], + center: CLLocationCoordinate2D? + ) -> [MarkerRenderEntry] { + guard let center, entries.count > 1 else { + return entries + } + let cosLat = cos(center.latitude * .pi / 180) + func distance(_ entry: MarkerRenderEntry) -> Double { + let coordinate = entry.element.coordinate + let dLat = coordinate.latitude - center.latitude + let dLon = (coordinate.longitude - center.longitude) * cosLat + return dLat * dLat + dLon * dLon + } + return entries.sorted { distance($0) < distance($1) } + } +} + +private extension MarkerRenderElement { + var coordinate: CLLocationCoordinate2D { + switch self { + case let .single(descriptor): + return descriptor.coordinate.toCLLocationCoordinate2D() + case let .cluster(_, coordinate, _, _, _): + return coordinate + } + } +} + +/// Applies render diffs over several frames instead of in one pass. +/// +/// Removals go out in full on the first step (cheap, and they free the screen), +/// adds go out a bounded number per frame, nearest to the centre first, and +/// retained updates fill whatever is left of the time budget. The number of +/// adds per frame adapts to the observed frame interval: a long frame halves +/// it, frames on budget grow it back, but only up to three quarters of the +/// last count that dropped a frame; that ceiling creeps up by one per good +/// frame so a one-off hitch does not pin the rate. The display link runs only +/// while work is pending. +/// +/// A new diff replaces whatever was still pending. Diffs are computed against +/// what is actually on the map, so anything not yet applied is either in the +/// new diff again or no longer wanted. +final class MarkerApplyScheduler { + struct Sink { + let remove: ([MarkerRenderKey]) -> Void + let add: ([MarkerRenderEntry], PendingMarkerApply) -> Void + let update: (MarkerRenderEntry) -> Void + } + + static let initialAddsPerFrame = 32 + static let minimumAddsPerFrame = 8 + static let maximumAddsPerFrame = 256 + /// Time for retained updates after the frame's adds, about a quarter of a 120 Hz frame. + static let stepBudget: CFTimeInterval = 0.002 + + private let sink: Sink + private var pending: PendingMarkerApply? + private(set) var addsPerFrame = MarkerApplyScheduler.initialAddsPerFrame + /// The add count that last dropped a frame, if any. + private(set) var ceiling: Int? + private lazy var clock = FrameClock { [weak self] frame in + self?.tick(frame) + } + + init(sink: Sink) { + self.sink = sink + } + + var hasWork: Bool { + pending?.isEmpty == false + } + + /// Replaces pending work, applies the first step right away and continues per frame. + func schedule(_ next: PendingMarkerApply) { + pending = next.isEmpty ? nil : next + step() + if hasWork { + clock.start() + } + } + + func cancel() { + pending = nil + clock.stop() + } + + /// Adapts the per-frame add count to how long the last frame took. + func observeFrame(interval: CFTimeInterval, expected: CFTimeInterval) { + guard expected > 0 else { + return + } + if interval > expected * 1.5 { + ceiling = addsPerFrame + addsPerFrame = max(Self.minimumAddsPerFrame, addsPerFrame / 2) + } else if interval <= expected * 1.1 { + if let known = ceiling { + ceiling = known + 1 + } + let limit = ceiling.map { max(Self.minimumAddsPerFrame, $0 * 3 / 4) } + ?? Self.maximumAddsPerFrame + addsPerFrame = min(limit, addsPerFrame + addsPerFrame / 2) + } + } + + private func tick(_ frame: FrameClock.Frame) { + if let interval = frame.interval { + observeFrame(interval: interval, expected: frame.expected) + } + step() + if !hasWork { + clock.stop() + } + } + + /// One frame's worth of work. + private func step() { + guard let current = pending else { + return + } + let signpost = MapTrace.begin("applyMarkerDiff") + defer { MapTrace.end("applyMarkerDiff", signpost) } + let start = CACurrentMediaTime() + + let removals = current.takeRemovals() + if !removals.isEmpty { + sink.remove(removals) + } + + if !current.adds.isEmpty { + let chunk = Array(current.adds.prefix(addsPerFrame)) + current.adds = current.adds.dropFirst(chunk.count) + sink.add(chunk, current) + } + + while let entry = current.retained.first, CACurrentMediaTime() - start < Self.stepBudget { + current.retained = current.retained.dropFirst() + sink.update(entry) + } + + if current.isEmpty { + pending = nil + } + } +} diff --git a/package/ios/MarkerClusterEngine.swift b/package/ios/MarkerClusterEngine.swift index ff7d63b..d514e85 100644 --- a/package/ios/MarkerClusterEngine.swift +++ b/package/ios/MarkerClusterEngine.swift @@ -22,6 +22,27 @@ enum MarkerClusterEngine { /// Target cluster cell size in points. static let defaultCellPoints: Double = 64 + /// Padding the spatial index applies around the visible region when it selects candidates. + static let candidatePadding: Double = 0.2 + + /// Rows and columns of cluster cells, inclusive. + struct CellRange { + let rowMin: Int + let rowMax: Int + let colMin: Int + let colMax: Int + + func contains(_ key: Int64) -> Bool { + let row = Int(key >> 32) + let column = Int(Int32(truncatingIfNeeded: key)) + return row >= rowMin && row <= rowMax && column >= colMin && column <= colMax + } + } + + static func cellKey(row: Int, column: Int) -> Int64 { + (Int64(row) << 32) | Int64(UInt32(truncatingIfNeeded: column)) + } + private static func wrapsLongitude(in region: MKCoordinateRegion) -> Bool { region.span.longitudeDelta > 180 } @@ -66,7 +87,7 @@ enum MarkerClusterEngine { /// Extra slack (points) so near-touching badges still merge. private static let mergeGap = ClusterBadgeMetrics.mergeGap - private struct Bucket { + struct Bucket { let row: Int let column: Int var count = 0 @@ -122,7 +143,9 @@ enum MarkerClusterEngine { flags: [UInt8], region: MKCoordinateRegion, viewSize: CGSize, - cellPoints: Double = defaultCellPoints + cellPoints: Double = defaultCellPoints, + cache: ClusterOctaveCache? = nil, + generation: Int = 0 ) -> [Element] { guard !candidates.isEmpty else { return [] @@ -153,7 +176,12 @@ enum MarkerClusterEngine { let cellLat = quantize(region.span.latitudeDelta / Double(rows)) let cellLon = quantize(region.span.longitudeDelta / Double(cols)) - var buckets: [Int64: Bucket] = [:] + // Cells fully inside the padded candidate region can be kept for the next + // refresh; the cache is off across the antimeridian, where cell keys depend + // on the viewport's own longitude reference. + let activeCache = wraps ? nil : cache + activeCache?.begin(cellLat: cellLat, cellLon: cellLon, generation: generation) + var buckets = activeCache?.takeBuckets() ?? [:] for handle in clusterableCandidates { let index = Int(handle) let lat = latitudes[index] @@ -162,12 +190,48 @@ enum MarkerClusterEngine { : longitudes[index] let row = Int((lat / cellLat).rounded(.down)) let col = Int((lon / cellLon).rounded(.down)) - let key = (Int64(row) << 32) | Int64(UInt32(truncatingIfNeeded: col)) + let key = cellKey(row: row, column: col) + if let activeCache, activeCache.isComputed(key) { + continue + } buckets[key, default: Bucket(row: row, column: col)].include(handle, lat: lat, lon: lon) } + // Render the cells that overlap the padded region and nothing else: the + // cache may still hold cells from the previous viewport, and a stale cell + // would merge into an on-screen cluster and churn the trailing edge. + let latPad = region.span.latitudeDelta * candidatePadding + let lonPad = region.span.longitudeDelta * candidatePadding + let minLat = region.center.latitude - region.span.latitudeDelta / 2 - latPad + let maxLat = region.center.latitude + region.span.latitudeDelta / 2 + latPad + let minLon = region.center.longitude - region.span.longitudeDelta / 2 - lonPad + let maxLon = region.center.longitude + region.span.longitudeDelta / 2 + lonPad + let inView: [Bucket] + if wraps { + inView = Array(buckets.values) + } else { + let overlapping = CellRange( + rowMin: Int((minLat / cellLat).rounded(.down)), + rowMax: Int((maxLat / cellLat).rounded(.down)), + colMin: Int((minLon / cellLon).rounded(.down)), + colMax: Int((maxLon / cellLon).rounded(.down)) + ) + inView = buckets.compactMap { key, bucket in overlapping.contains(key) ? bucket : nil } + } + if let activeCache { + activeCache.buckets = buckets + activeCache.finish(CellRange( + rowMin: Int((minLat / cellLat).rounded(.up)), + rowMax: Int((maxLat / cellLat).rounded(.down)) - 1, + colMin: Int((minLon / cellLon).rounded(.up)), + colMax: Int((maxLon / cellLon).rounded(.down)) - 1 + )) + } + + // Groups are built on copies: the seeds may live in the octave cache and + // must not absorb their neighbours in place. let merged = mergeOverlapping( - Array(buckets.values), + inView, region: region, wraps: wraps, viewSize: viewSize @@ -363,13 +427,13 @@ final class MarkerRenderPipeline { private static let asyncThreshold = 500 static let liveRefreshInterval: TimeInterval = 0.1 - /// The inputs of one refresh: what to show for a viewport, diffed against - /// what is shown, and where to deliver the result. + /// The inputs of one refresh: the viewport to compute a target for, and + /// where to deliver it. The caller diffs the target against what is on the + /// map at delivery time, on the main thread. private struct RefreshParameters { - let displayedVersions: [MarkerRenderKey: Int] let region: MKCoordinateRegion let viewSize: CGSize - let apply: (MarkerRenderDiff) -> Void + let apply: ([MarkerRenderEntry]) -> Void } /// One viewport query, cluster or filter pass, and diff, computed off the @@ -378,6 +442,8 @@ final class MarkerRenderPipeline { let generation: Int let store: MarkerStore let clustering: Bool + let cache: ClusterOctaveCache + let datasetGeneration: Int let parameters: RefreshParameters } @@ -426,6 +492,9 @@ final class MarkerRenderPipeline { private var viewportRefreshWorkItem: DispatchWorkItem? /// Invalidates in-flight refresh results (viewport diffs). private var refreshGeneration = 0 + /// Bumped whenever the dataset or the clustering mode changes; keyed into the octave cache. + private var datasetGeneration = 0 + private var clusterCache = ClusterOctaveCache() private var clusteringEnabled = false private let refreshInbox = RefreshInbox() private let computeQueue = DispatchQueue( @@ -444,10 +513,12 @@ final class MarkerRenderPipeline { func attach(store: MarkerStore?) { self.store = store invalidate() + invalidateClusterCache() } func reset() { invalidate() + invalidateClusterCache() store = nil clusteringEnabled = false } @@ -458,19 +529,26 @@ final class MarkerRenderPipeline { } clusteringEnabled = enabled + invalidateClusterCache() return true } + /// Forgets cached cluster cells. The compute queue may still be inside a + /// refresh that holds the old cache, so a fresh object replaces it. + func invalidateClusterCache() { + datasetGeneration += 1 + clusterCache = ClusterOctaveCache() + } + /// Recomputes what is shown for the current dataset: synchronously for small - /// unclustered datasets, through the viewport pipeline otherwise. + /// unclustered datasets, through the viewport pipeline otherwise. `apply` + /// receives the target; the caller diffs it against what is on the map. func reapply( - displayedVersions: [MarkerRenderKey: Int], region: MKCoordinateRegion, viewSize: CGSize, - apply: @escaping (MarkerRenderDiff) -> Void + apply: @escaping ([MarkerRenderEntry]) -> Void ) { let parameters = RefreshParameters( - displayedVersions: displayedVersions, region: region, viewSize: viewSize, apply: apply @@ -484,15 +562,14 @@ final class MarkerRenderPipeline { let target: [MarkerRenderEntry] = store?.read { access in Self.materialize(access.aliveHandles().map { .single(handle: $0) }, access: access) } ?? [] - apply(Self.computeDiff(target: target, displayed: displayedVersions)) + apply(target) } func scheduleViewportRefresh( - displayedVersions: [MarkerRenderKey: Int], region: MKCoordinateRegion, viewSize: CGSize, immediate: Bool = false, - apply: @escaping (MarkerRenderDiff) -> Void + apply: @escaping ([MarkerRenderEntry]) -> Void ) { guard usesViewportPipeline else { return @@ -506,7 +583,6 @@ final class MarkerRenderPipeline { return } self.refreshNow( - displayedVersions: displayedVersions, region: region, viewSize: viewSize, apply: apply @@ -522,17 +598,15 @@ final class MarkerRenderPipeline { } func refreshNow( - displayedVersions: [MarkerRenderKey: Int], region: MKCoordinateRegion, viewSize: CGSize, - apply: @escaping (MarkerRenderDiff) -> Void + apply: @escaping ([MarkerRenderEntry]) -> Void ) { guard usesViewportPipeline else { return } refreshNow(RefreshParameters( - displayedVersions: displayedVersions, region: region, viewSize: viewSize, apply: apply @@ -549,6 +623,8 @@ final class MarkerRenderPipeline { generation: refreshGeneration, store: store, clustering: clusteringEnabled, + cache: clusterCache, + datasetGeneration: datasetGeneration, parameters: parameters ) guard refreshInbox.post(request) else { @@ -562,12 +638,12 @@ final class MarkerRenderPipeline { return } - let diff = Self.computeViewportDiff(request, clusterCellPoints: clusterCellPoints) + let target = Self.computeViewportTarget(request, clusterCellPoints: clusterCellPoints) DispatchQueue.main.async { [weak self] in guard let self, request.generation == self.refreshGeneration else { return } - request.parameters.apply(diff) + request.parameters.apply(target) } } } @@ -579,10 +655,14 @@ final class MarkerRenderPipeline { refreshInbox.discardPending() } - private static func computeViewportDiff( + /// The index query, the cluster or LOD pass, and the materialized elements + /// for one viewport. Diffing happens on the main thread against what is on + /// the map at that moment, because the frame scheduler may have applied adds + /// from the previous diff while this ran. + private static func computeViewportTarget( _ request: ViewportRefreshRequest, clusterCellPoints: Double - ) -> MarkerRenderDiff { + ) -> [MarkerRenderEntry] { let signpost = MapTrace.begin("computeViewportDiff") defer { MapTrace.end("computeViewportDiff", signpost) } let parameters = request.parameters @@ -591,7 +671,12 @@ final class MarkerRenderPipeline { // 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) + ( + access.index.candidates(in: parameters.region, padding: MarkerClusterEngine.candidatePadding), + access.latitudes, + access.longitudes, + access.flags + ) } let elements: [MarkerClusterEngine.Element] @@ -603,7 +688,9 @@ final class MarkerRenderPipeline { flags: flags, region: parameters.region, viewSize: parameters.viewSize, - cellPoints: clusterCellPoints + cellPoints: clusterCellPoints, + cache: request.cache, + generation: request.datasetGeneration ) } else { elements = MarkerViewportFilter @@ -616,8 +703,7 @@ final class MarkerRenderPipeline { .map { .single(handle: $0) } } - let target = store.read { access in materialize(elements, access: access) } - return computeDiff(target: target, displayed: parameters.displayedVersions) + return store.read { access in materialize(elements, access: access) } } /// Turns handles into render entries with their descriptors and versions. diff --git a/package/ios/NitroFlatPinAnnotationView.swift b/package/ios/NitroFlatPinAnnotationView.swift new file mode 100644 index 0000000..aec3944 --- /dev/null +++ b/package/ios/NitroFlatPinAnnotationView.swift @@ -0,0 +1,98 @@ +import MapKit +import UIKit + +/// Single-layer pin: one pre-rendered image on an `MKAnnotationView`. +/// +/// `MKMarkerAnnotationView` is a small view tree with its own layout, +/// selection animations and balloon; MapKit repositions every one of them on +/// the main thread per frame, which is what caps how many markers a 120 Hz +/// map can hold. This view is one image layer, and the image is drawn once per +/// screen scale. +final class NitroFlatPinAnnotationView: MKAnnotationView { + static let reuseIdentifier = "NitroFlatPin" + + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + canShowCallout = false + collisionMode = .circle + displayPriority = .required + image = PinImageRenderer.pin(scale: traitCollection.displayScale) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(for marker: MapMarkerAnnotation) { + layer.removeAllAnimations() + transform = .identity + annotation = marker + isDraggable = marker.draggable + canShowCallout = marker.title != nil || marker.subtitle != nil + displayPriority = .required + alpha = marker.opacity + + let pinSize = image?.size ?? PinImageRenderer.pinSize + centerOffset = marker.centerOffset(forImageSize: pinSize) + + let rotation = marker.rotation ?? 0 + transform = marker.flat != true && rotation != 0 + ? CGAffineTransform(rotationAngle: rotation * .pi / 180) + : .identity + } +} + +/// Draws the default pin once per screen scale. +enum PinImageRenderer { + static let pinSize = CGSize(width: 30, height: 42) + private static var cache: [CGFloat: UIImage] = [:] + + static func pin(scale: CGFloat) -> UIImage { + let key = scale > 0 ? scale : UIScreen.main.scale + if let cached = cache[key] { + return cached + } + + let format = UIGraphicsImageRendererFormat.default() + format.scale = key + let image = UIGraphicsImageRenderer(size: pinSize, format: format).image { context in + let cg = context.cgContext + let headCenter = CGPoint(x: pinSize.width / 2, y: 14) + let headRadius: CGFloat = 12 + + // Teardrop: the head circle joined to a tail that ends at the coordinate. + let body = UIBezierPath() + body.addArc( + withCenter: headCenter, + radius: headRadius, + startAngle: .pi * 0.85, + endAngle: .pi * 0.15, + clockwise: true + ) + body.addLine(to: CGPoint(x: pinSize.width / 2, y: pinSize.height - 1)) + body.close() + + cg.saveGState() + cg.setShadow(offset: CGSize(width: 0, height: 1), blur: 2, color: UIColor.black.withAlphaComponent(0.35).cgColor) + UIColor.systemRed.setFill() + body.fill() + cg.restoreGState() + + UIColor.white.withAlphaComponent(0.9).setStroke() + body.lineWidth = 1 + body.stroke() + + UIColor.white.setFill() + UIBezierPath( + arcCenter: headCenter, + radius: 4.5, + startAngle: 0, + endAngle: .pi * 2, + clockwise: true + ).fill() + } + cache[key] = image + return image + } +} diff --git a/package/ios/NitroPinAnnotationView.swift b/package/ios/NitroPinAnnotationView.swift index b13a5b6..362ad29 100644 --- a/package/ios/NitroPinAnnotationView.swift +++ b/package/ios/NitroPinAnnotationView.swift @@ -24,7 +24,7 @@ final class NitroPinAnnotationView: MKMarkerAnnotationView { alpha = 1 transform = .identity annotation = marker - animatesWhenAdded = marker.enteringAnimation.kind == .system + animatesWhenAdded = marker.enteringAnimation.kind == .system && !marker.suppressesNextEnteringAnimation isDraggable = marker.draggable canShowCallout = marker.title != nil || marker.subtitle != nil displayPriority = .required diff --git a/package/src/components/MapView.tsx b/package/src/components/MapView.tsx index 70f25b6..448dbe9 100644 --- a/package/src/components/MapView.tsx +++ b/package/src/components/MapView.tsx @@ -84,6 +84,7 @@ export function MapView({ followsUserLocation, showsCompass, showsScale, + pinStyle, customMapStyle, clusteringEnabled, mapPadding, @@ -369,6 +370,7 @@ export function MapView({ followsUserLocation={followsUserLocation} showsCompass={showsCompass} showsScale={showsScale} + pinStyle={pinStyle} customMapStyle={customMapStyle} clusteringEnabled={clusteringEnabled} mapPadding={stableMapPadding} diff --git a/package/src/index.ts b/package/src/index.ts index a8b7f33..656aa2c 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -29,6 +29,7 @@ export type { MarkerImage, MarkerImageSource, MarkerDescriptor, + MarkerPinStyle, MarkerPoint, MarkerProps, OverlayEnteringAnimation, diff --git a/package/src/native/specs/MapView.nitro.ts b/package/src/native/specs/MapView.nitro.ts index 82a2add..bd4e208 100644 --- a/package/src/native/specs/MapView.nitro.ts +++ b/package/src/native/specs/MapView.nitro.ts @@ -93,6 +93,13 @@ export type ApplePoiCategory = | 'zoo' | 'unknown'; +/** + * How Apple MapKit draws markers that have no image. `flat` is one pre-rendered + * image per pin, `system` is `MKMarkerAnnotationView` with its balloon and + * selection animation. + */ +export type MarkerPinStyle = 'flat' | 'system'; + export interface NativePoiPressEvent { provider: MapProvider; coordinate: Coordinate; @@ -182,6 +189,9 @@ export interface MapViewProps extends HybridViewProps { /** Entering animation for marker clusters. */ clusterEnteringAnimation?: OverlayEnteringAnimationDescriptor; + /** Apple MapKit pin rendering for markers without an image. */ + pinStyle?: MarkerPinStyle; + /** Called once when a user-initiated region change begins. */ onRegionChange?: (region: Region) => void; diff --git a/package/src/types/index.ts b/package/src/types/index.ts index 14a458c..55d9948 100644 --- a/package/src/types/index.ts +++ b/package/src/types/index.ts @@ -1,7 +1,10 @@ export type { Coordinate } from './coordinate'; export type { Camera } from './camera'; export type { Region, EdgePadding, VisibleRegion } from './region'; -export type { ApplePoiCategory } from '../native/specs/MapView.nitro'; +export type { + ApplePoiCategory, + MarkerPinStyle, +} from '../native/specs/MapView.nitro'; export type { ApplePoiPressEvent, ClusterPressEvent, diff --git a/package/src/types/map.ts b/package/src/types/map.ts index ef97613..17c10e1 100644 --- a/package/src/types/map.ts +++ b/package/src/types/map.ts @@ -7,7 +7,10 @@ import type { PolygonDescriptor, PolylineDescriptor, } from '../native/specs/overlays'; -import type { ApplePoiCategory } from '../native/specs/MapView.nitro'; +import type { + ApplePoiCategory, + MarkerPinStyle, +} from '../native/specs/MapView.nitro'; import type { MarkerCollection } from '../markers/MarkerCollection'; import type { MarkerDescriptor, OverlayEnteringAnimation } from './overlays'; import type { EdgePadding, Region } from './region'; @@ -167,6 +170,14 @@ interface ExistingDefaultProviderProps extends BaseMapViewProps { /** Whether to show the scale control (supported by Apple MapKit). */ showsScale?: boolean; + /** + * How Apple MapKit draws markers without an image. `flat` (default) is a + * single pre-rendered image per pin and keeps the main thread cheap at + * hundreds of visible markers; `system` is MapKit's balloon marker with its + * drop and selection animations. Google Maps draws its own default marker. + */ + pinStyle?: MarkerPinStyle; + /** Custom map style as a JSON string (full support on Google Maps; curated subset on Apple MapKit iOS 16+). */ customMapStyle?: string; @@ -186,6 +197,13 @@ interface AppleMapViewProps extends BaseMapViewProps { /** Whether to show the scale control. */ showsScale?: boolean; + /** + * How markers without an image are drawn: `flat` (default) is a single + * pre-rendered image per pin, `system` is MapKit's balloon marker with its + * drop and selection animations. + */ + pinStyle?: MarkerPinStyle; + /** Custom map style as a JSON string. Apple MapKit applies a curated subset on iOS 16+. */ customMapStyle?: string; @@ -205,6 +223,9 @@ interface GoogleMapViewProps extends BaseMapViewProps { /** Google Maps SDK has no native scale control. */ showsScale?: never; + /** Google Maps draws its own default marker. */ + pinStyle?: never; + /** Custom Google Maps style JSON. */ customMapStyle?: string; @@ -219,6 +240,7 @@ interface OpenStreetMapViewProps extends BaseMapViewProps { provider: 'openstreetmap'; googleMapId?: never; showsScale?: never; + pinStyle?: never; customMapStyle?: never; clusteringEnabled?: never; clusterEnteringAnimation?: never; @@ -229,6 +251,7 @@ interface MapboxMapViewProps extends BaseMapViewProps { provider: 'mapbox'; googleMapId?: never; showsScale?: never; + pinStyle?: never; customMapStyle?: never; clusteringEnabled?: never; clusterEnteringAnimation?: never;