diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b55f0..a72380b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ frames after the inner ones; no frame waits for all of them. stream into a Reanimated shared value; `react-native-reanimated` is an optional peer dependency. - `pinStyle` prop (`'flat' | 'system'`) for the Apple provider. +- `markerRendering` prop (`'views' | 'sprites'`) for the Apple provider. `sprites` draws the + displayed markers and cluster badges into map tiles through an `MKOverlayRenderer` + instead of one annotation view per marker; taps and callouts still work, draggable + markers stay views. - `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 diff --git a/README.md b/README.md index 10c1802..4df7147 100644 --- a/README.md +++ b/README.md @@ -619,6 +619,18 @@ Markers without an `image` are drawn by MapKit. By default they are `flat` pins: 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. +## Sprites on Apple Maps + +Even flat pins are one `MKAnnotationView` each, and MapKit lays every one of them out on the main thread when the set on screen changes. `markerRendering="sprites"` takes the views out of the picture: the displayed markers and cluster badges are drawn into the map's tiles through an `MKOverlayRenderer`, one bitmap per tile, on MapKit's own threads. + +```tsx + +``` + +What stays the same: which markers are shown (the viewport filter and the clustering are the same pipeline), marker images, anchors, offsets, rotation and opacity, `onMarkerPress` and `onClusterPress`, and `getClusterMembers`. A tapped marker with a `title` or `subtitle` is promoted to a real annotation view while its callout is open, and draggable markers are always views. What changes: sprites do not run entering animations, `pinStyle` does not apply to them (they draw the flat pin), and during a pinch MapKit scales the tiles it has until it has drawn new ones, so pins grow or shrink for a moment, as every overlay renderer's content does. + +Reach for it when a viewport holds hundreds of markers and the zoom sweeps drop frames; below that, views are fine and animate. + ## 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: @@ -663,6 +675,7 @@ setMarkers((current) => | Marker collections (deltas) | Supported | Supported | Supported | | Camera stream (`onCameraMove`) | Supported | Supported | Supported | | Pin style | `flat` (default) or `system` | Google default marker | Google default marker | +| Marker rendering | `views` (default) or `sprites` | Views | Views | | Custom marker images | Supported | Supported | Supported | | Marker callouts / dragging | Supported | Supported | Supported | | Overlay press events | Supported | Supported | Supported | @@ -707,6 +720,7 @@ setMarkers((current) => | `MapType` | `'standard' \| 'satellite' \| 'hybrid' \| 'terrain'` | | `MapProvider` | `'apple' \| 'google' \| 'openstreetmap' \| 'mapbox'` | | `MarkerPinStyle` | `'flat' \| 'system'`, Apple MapKit pin rendering | +| `MarkerRendering` | `'views' \| 'sprites'`, Apple MapKit marker rendering path | | `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/0008-mapkit-sprite-layer.md b/docs/adr/0008-mapkit-sprite-layer.md new file mode 100644 index 0000000..84e48b1 --- /dev/null +++ b/docs/adr/0008-mapkit-sprite-layer.md @@ -0,0 +1,62 @@ +# ADR 0008: MapKit sprite layer + +## Status + +Accepted + +## Context + +After the frame-budgeted pipeline (ADR 0006) the frames MapKit still drops come from +annotation views: at an octave crossing of a zoom sweep the set on screen changes +wholesale, and even with adds spread over frames and one image layer per pin, MapKit +lays every new `MKAnnotationView` out on the main thread. The signposts in ADR 0007 put +the compute side under a frame on the background queue while the main-thread apply of +scenario N reached 15 ms inside MapKit. The audit's answer was a bulk rendering path +through `MKOverlayRenderer`, left for later; this is it. + +## Decision + +- **`markerRendering="sprites"` on the Apple provider.** The displayed markers and cluster + badges are drawn into map tiles by `MarkerSpriteRenderer`, an `MKOverlayRenderer` on a + world-sized overlay above the labels. MapKit calls it per tile on its own threads and + composites the tiles on the GPU. What the main thread keeps is the sprite publish, a + sort and a snapshot swap that stays under 1.5 ms at 2,000 sprites; what it loses is the + annotation-view layout of every viewport change. The zoom sweeps and the 100,000-marker + scenario gain from that; a plain pan, which only touches edge tiles, measures the same as + views (see `docs/benchmarks.md`). +- **Same pipeline, different apply.** The store, index, viewport filter, clustering and + diffing are untouched; sprite mode changes only what the controller does with a diff. + Sprites are applied at once (a dictionary update, no frame budget needed) and published + as an immutable snapshot the renderer reads under a lock. Draggable markers, and the + marker whose callout is open, go through the annotation-view path and its scheduler. +- **Interaction stays.** Taps are hit-tested against the snapshot, topmost sprite first. + A marker without a title fires `onMarkerPress` directly; one with a title or subtitle is + promoted to a selected annotation view so MapKit shows its callout, and its sprite comes + back when the callout closes. Cluster taps fire `onClusterPress` and zoom to the cluster. +- **Opt-in, not automatic.** Sprites do not run entering animations, ignore `pinStyle`, + and scale with the tiles during a pinch until MapKit has drawn new ones. Switching paths + by visible count would make those differences appear and disappear mid-session, so the + app chooses once. + +## Consequences + +- One more rendering path on MapKit to keep in step with the view path: geometry + (`MapMarkerAnnotation.centerOffset`), the pin image and the cluster badge are shared + code, so a change to the look reaches both. +- The pinch artifact is MapKit's: every overlay renderer's content, including polylines, + scales with the tiles until the re-render lands. It is documented rather than worked + around; a screen-space sprite view would avoid it at the cost of per-frame main-thread + repositioning, which is the cost the sprite layer removes. +- Callout promotion adds a view for one marker at a time, which is the annotation model's + ordinary cost. + +## Alternatives considered + +- **A `CALayer` per sprite in a view above the map.** Cheap to add, but every layer has to + be repositioned on the main thread on every frame of a gesture, which is the class of + work MapKit already does for annotation views. +- **Automatic bulk mode above a visible-count threshold.** Rejected for the mid-session + behavior change described above. +- **Drawing every marker in a tile from the spatial index, skipping the viewport filter.** + Would draw all 10,000 pins of scenario N at once; the filter exists for legibility as much + as for cost, and keeping it makes sprite mode show exactly what view mode shows. diff --git a/docs/architecture.md b/docs/architecture.md index 35dfe70..9a5503f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,7 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C | `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`. | +| `markerRendering` | Apple MapKit only. `views` (default) is one annotation view per displayed marker; `sprites` draws the displayed markers and cluster badges into map tiles through `MarkerSpriteRenderer`, an `MKOverlayRenderer`. Draggable markers and the marker whose callout is open stay views. | | `getClusterMembers(clusterId)` | Imperative ref method; resolves the marker ids inside a displayed cluster. | ### Platform gaps (Phase 8) @@ -115,6 +116,8 @@ The diff does not reach the map SDK in one pass. A per-map scheduler driven by ` The camera reaches JS through two events per gesture, `onRegionChange` when it begins and `onRegionChangeComplete` when it ends, which suits data loading. Overlays that must track the map while it moves opt into `onCameraMove`: MapKit samples `MKMapView.camera` on a display link that runs only between `regionWillChange` and `regionDidChange`, the Google SDKs report the camera every frame and the adapter throttles it to `cameraMoveThrottleMs`, and every adapter emits the final camera once the move ends. The `react-native-better-maps/reanimated` entry point turns that stream into a Reanimated shared value so overlays follow the camera on the UI thread without a React render per update. See [ADR 0007](adr/0007-camera-stream-and-cpp-core.md). +With `markerRendering="sprites"` the MapKit controller keeps the same pipeline and diffs but applies the sprite part of each diff at once: a dictionary of sprites (coordinate, bitmap, size, offset, rotation, opacity) becomes an immutable snapshot that a world-sized `MKOverlay`'s renderer draws per tile on MapKit's threads, above the labels. A viewport change is then one snapshot swap and a background re-render instead of annotation-view layout on the main thread. Taps are hit-tested against the snapshot; a marker with a title or subtitle is promoted to a selected annotation view for its callout and demoted when the callout closes; draggable markers always take the view path through the frame scheduler. See [ADR 0008](adr/0008-mapkit-sprite-layer.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 d1a89be..d35a43d 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -35,24 +35,28 @@ They are implemented in `benchmark/thresholds.ts` and unit-tested with ## Scenarios -| ID | Setup | Script | -| --- | ---------------------------------- | ------------------------------------------------------------------------------------ | -| A | empty map | 3 s idle, short pan | -| B | 100 markers | pan | -| C | 1,000 markers | pan | -| D | 10,000 markers | pan | -| E | 10,000 markers, clustering on | zoom sweep across five levels, then pan | -| F | 10,000 markers | ten-leg pan | -| G | 10,000 markers | zoom sweep | -| H | 10,000 markers | four heading changes | -| I | 1,000 markers in a collection | 100 of them move at 10 Hz for 5 s through `updatePositions`; JS lag is checked | -| I2 | 1,000 markers | 100 of them move at 10 Hz for 5 s through new `markers` arrays; JS lag is checked | -| K | 5,000-point route and 200 polygons | five style changes, then pan | -| L | 10,000 markers | three pan legs, then 5 s idle | -| M | 10,000 markers in a collection | one marker is upserted every 100 ms for 3 s; JS lag is checked | -| N | 10,000 markers inside the viewport | street-level zoom sweep, where the LOD cap allows 2,000 markers on screen | -| O | 10,000 markers | pan while `onCameraMove` feeds a shared value at a 16 ms throttle; JS lag is checked | -| P | 100,000 markers, clustering on | zoom sweep across five levels, then pan | +| ID | Setup | Script | +| --- | ----------------------------------- | ------------------------------------------------------------------------------------ | +| A | empty map | 3 s idle, short pan | +| B | 100 markers | pan | +| C | 1,000 markers | pan | +| D | 10,000 markers | pan | +| E | 10,000 markers, clustering on | zoom sweep across five levels, then pan | +| F | 10,000 markers | ten-leg pan | +| F2 | 10,000 markers, sprites | F with `markerRendering="sprites"` (Apple Maps only; skipped on Android) | +| G | 10,000 markers | zoom sweep | +| G2 | 10,000 markers, sprites | G with `markerRendering="sprites"` (Apple Maps only; skipped on Android) | +| H | 10,000 markers | four heading changes | +| I | 1,000 markers in a collection | 100 of them move at 10 Hz for 5 s through `updatePositions`; JS lag is checked | +| I2 | 1,000 markers | 100 of them move at 10 Hz for 5 s through new `markers` arrays; JS lag is checked | +| K | 5,000-point route and 200 polygons | five style changes, then pan | +| L | 10,000 markers | three pan legs, then 5 s idle | +| M | 10,000 markers in a collection | one marker is upserted every 100 ms for 3 s; JS lag is checked | +| N | 10,000 markers inside the viewport | street-level zoom sweep, where the LOD cap allows 2,000 markers on screen | +| N2 | 10,000 markers inside, sprites | N with `markerRendering="sprites"` (Apple Maps only; skipped on Android) | +| O | 10,000 markers | pan while `onCameraMove` feeds a shared value at a 16 ms throttle; JS lag is checked | +| P | 100,000 markers, clustering on | zoom sweep across five levels, then pan | +| P2 | 100,000 markers, clustered, sprites | P with `markerRendering="sprites"` (Apple Maps only; skipped on Android) | 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. @@ -446,6 +450,68 @@ O. - M-one-of-10k: JS lag p95 18.79 ms > budget 17.50 ms - O-camera-stream: JS lag p95 18.07 ms > budget 17.50 ms +### Sprite layer runs (not a device baseline) + +The same simulator after ADR 0008, with the four sprite scenarios next to the +view-mode scenarios they copy. Sprite mode changes nothing about which markers are shown; it changes who draws the eligible ones, since draggable markers and the marker with an open callout stay annotation views. + +**iOS**, iPhone 17 Pro simulator, release build, MapKit, 60 Hz, started by +hand, recorded 2026-09-08. The zoom sweeps are where the sprites earn their +keep: G2 against G goes from 4.4 % jank to 1.6 % with a 33 ms p99 in place of +36 ms, N2 against N from 4.1 % to 1.6 % with a 28 ms p99 in place of 38 ms and +a 40 ms worst frame in place of 46 ms, and P2 passes outright where P holds a +33 ms p99 and 1.7 % jank with 100,000 clustered markers. The ten-leg pan is a +wash: F2 sits a hair over the p99 threshold that F sits a hair under, because +a pan only touches the edge tiles and the view path is already cheap there. +What is left in G2 and N2 is MapKit's own overlay tile pipeline at an octave +crossing; K, which has no markers and restyles a route and 200 polygons, +shows the same two-frame p99. The 17 ms JS-lag column from L onward is host +contention during that stretch of the run (it fails M, a scenario that does +not touch the sprite layer) and does not follow the sprite scenarios. + +| 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 | 47 ms | 0.6 % | 1.0 ms | +72 MB | +| B-markers-100 | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 45 ms | 0.3 % | 1.1 ms | +71 MB | +| C-markers-1k | pass | 59 | 16.7 ms | 16.7 ms | 21.5 ms | 45 ms | 1.0 % | 1.1 ms | +66 MB | +| D-markers-10k | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 45 ms | 1.7 % | 1.1 ms | +88 MB | +| E-clustered-10k | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 34 ms | 1.0 % | 1.0 ms | +90 MB | +| F-pan-10k | pass | 59 | 16.7 ms | 16.7 ms | 23.1 ms | 44 ms | 1.0 % | 1.0 ms | +88 MB | +| F2-pan-10k-sprites | fail (2) | 59 | 16.7 ms | 16.7 ms | 27.8 ms | 46 ms | 1.2 % | 1.1 ms | +70 MB | +| G-zoom-10k | fail (2) | 57 | 16.7 ms | 16.7 ms | 35.8 ms | 38 ms | 4.4 % | 1.1 ms | +66 MB | +| G2-zoom-10k-sprites | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 37 ms | 1.6 % | 1.4 ms | +97 MB | +| H-rotate-10k | fail (3) | 58 | 16.7 ms | 16.7 ms | 41.9 ms | 79 ms | 1.6 % | 1.1 ms | +53 MB | +| I-animated-collection | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.9 % | 1.4 ms | -1 MB | +| I2-animated-prop | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 1.0 ms | -1 MB | +| K-shapes | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 44 ms | 1.1 % | 1.1 ms | +61 MB | +| L-idle-after-pan | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 42 ms | 0.6 % | 17.5 ms | +52 MB | +| M-one-of-10k | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 17.7 ms | -0 MB | +| O-camera-stream | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 45 ms | 0.7 % | 17.3 ms | +82 MB | +| P-clustered-100k | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 41 ms | 1.7 % | 17.3 ms | +131 MB | +| P2-clustered-100k-sprites | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 34 ms | 0.6 % | 17.1 ms | +174 MB | +| N-dense-10k | fail (2) | 57 | 16.7 ms | 16.7 ms | 38.1 ms | 46 ms | 4.1 % | 17.0 ms | +104 MB | +| N2-dense-10k-sprites | fail (2) | 59 | 16.7 ms | 16.7 ms | 28.5 ms | 40 ms | 1.6 % | 17.7 ms | +86 MB | + +- D-markers-10k: p99 33.33 ms > 25.00 ms; jank 1.66% > 1% +- F2-pan-10k-sprites: p99 27.82 ms > 25.00 ms; jank 1.21% > 1% +- G-zoom-10k: p99 35.76 ms > 25.00 ms; jank 4.39% > 1% +- G2-zoom-10k-sprites: p99 33.33 ms > 25.00 ms; jank 1.64% > 1% +- H-rotate-10k: p99 41.93 ms > 25.00 ms; worst frame 79.39 ms > 50.00 ms; jank 1.55% > 1% +- K-shapes: p99 33.33 ms > 25.00 ms; jank 1.15% > 1% +- M-one-of-10k: JS lag p95 17.74 ms > budget 17.50 ms +- P-clustered-100k: p99 33.33 ms > 25.00 ms; jank 1.73% > 1% +- N-dense-10k: p99 38.07 ms > 25.00 ms; jank 4.05% > 1% +- N2-dense-10k-sprites: p99 28.51 ms > 25.00 ms; jank 1.64% > 1% + +The signposts recorded during the same run show what the main thread stops +doing. In N the main-thread apply (`applyMarkerDiff`, MapKit adding and +removing annotation views under the frame budget) ran 120 times at a p95 of +7.4 ms and a maximum of 13.9 ms; in N2 the sprite publish (`publishSprites`, +a dictionary sort and a snapshot swap) ran 46 times at a p95 of 1.1 ms and a +maximum of 1.4 ms, and there was no view apply at all. In G2 and P2 the +publish stays under 0.5 ms. The compute side is the same in both modes, as it +should be, since sprite mode only changes what happens with the diff. + ## Profiling markers The library emits `os_signpost` intervals (iOS, subsystem `com.nitromaps`, diff --git a/example/App.tsx b/example/App.tsx index 785f741..fd8ecc2 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -46,6 +46,7 @@ import { type Coordinate, type EdgePadding, type MapProvider, + type MarkerRendering, type MapType, type MapViewRef, type OverlayEnteringAnimation, @@ -313,6 +314,8 @@ type ScenarioDockProps = { providerLabel: string; animationOptionId: AnimationOptionId; canCycleProvider: boolean; + markerRendering: MarkerRendering | null; + onToggleMarkerRendering: () => void; onToggleExpanded: () => void; onSelect: (index: number) => void; onSelectAnimation: (animation: AnimationOptionId) => void; @@ -335,6 +338,8 @@ const ScenarioDock = memo(function ScenarioDock({ providerLabel, animationOptionId, canCycleProvider, + markerRendering, + onToggleMarkerRendering, onToggleExpanded, onSelect, onSelectAnimation, @@ -461,6 +466,17 @@ const ScenarioDock = memo(function ScenarioDock({ {providerLabel} ) : null} + {markerRendering != null ? ( + + + + {markerRendering === 'sprites' ? 'Sprites' : 'Views'} + + + ) : null} {scenario.id === CUSTOM_MARKER_IMAGES_SCENARIO_ID ? ( @@ -528,6 +544,7 @@ type MapSceneProps = { mapType: MapType; mapPadding?: EdgePadding; animationOption: AnimationOption; + markerRendering: MarkerRendering; onMapReady: () => void; onClusterPress: (event: ClusterPressEvent) => void; onMarkerPress: (id: string) => void; @@ -548,6 +565,7 @@ const MapScene = memo(function MapScene({ mapType, mapPadding, animationOption, + markerRendering, onMapReady, onClusterPress, onMarkerPress, @@ -601,6 +619,7 @@ const MapScene = memo(function MapScene({ {...commonMapProps} provider="apple" showsScale={scenario.advanced?.showsScale} + markerRendering={markerRendering} /> ); } @@ -692,6 +711,8 @@ export default function App() { const [dockExpanded, setDockExpanded] = useState(false); const [customMarkerRotation, setCustomMarkerRotation] = useState(45); const [customMarkerFlat, setCustomMarkerFlat] = useState(true); + const [markerRendering, setMarkerRendering] = + useState('views'); const baseScenario = MAP_SCENARIOS[scenarioIndex]; const scenario = useMemo(() => { @@ -758,19 +779,25 @@ export default function App() { setCustomMarkerFlat((current) => !current); }, []); + const toggleMarkerRendering = useCallback(() => { + setMarkerRendering((current) => + current === 'sprites' ? 'views' : 'sprites', + ); + }, []); + const cycleProvider = useCallback(() => { - setProviderIndex((current) => { - if (SUPPORTED_MAP_PROVIDERS.length <= 1) { - setStatus(PROVIDER_LABELS[provider]); - return current; - } + // The updater stays pure: React may run it twice, so the status and the + // ready flag are set from the handler with the index it computed. + if (SUPPORTED_MAP_PROVIDERS.length <= 1) { + setStatus(PROVIDER_LABELS[provider]); + return; + } - const next = (current + 1) % SUPPORTED_MAP_PROVIDERS.length; - setMapReady(false); - setStatus(PROVIDER_LABELS[SUPPORTED_MAP_PROVIDERS[next] ?? provider]); - return next; - }); - }, [provider]); + const next = (providerIndex + 1) % SUPPORTED_MAP_PROVIDERS.length; + setProviderIndex(next); + setMapReady(false); + setStatus(PROVIDER_LABELS[SUPPORTED_MAP_PROVIDERS[next] ?? provider]); + }, [provider, providerIndex]); const selectScenario = useCallback( (index: number) => { @@ -907,6 +934,7 @@ export default function App() { mapType={MAP_TYPES[mapTypeIndex]} mapPadding={mapPadding} animationOption={animationOption} + markerRendering={markerRendering} onMapReady={handleMapReady} onClusterPress={handleClusterPress} onMarkerPress={handleMarkerPress} @@ -938,6 +966,8 @@ export default function App() { providerLabel={PROVIDER_LABELS[provider]} animationOptionId={animationOption.id} canCycleProvider={SUPPORTED_MAP_PROVIDERS.length > 1} + markerRendering={provider === 'apple' ? markerRendering : null} + onToggleMarkerRendering={toggleMarkerRendering} onToggleExpanded={toggleDockExpanded} onSelect={selectScenario} onSelectAnimation={selectAnimation} diff --git a/example/benchmark/BenchmarkApp.tsx b/example/benchmark/BenchmarkApp.tsx index 13b2ebb..c8ce62a 100644 --- a/example/benchmark/BenchmarkApp.tsx +++ b/example/benchmark/BenchmarkApp.tsx @@ -42,6 +42,14 @@ type BenchmarkProvider = Extract; const PROVIDERS: BenchmarkProvider[] = Platform.OS === 'ios' ? ['apple', 'google'] : ['google']; +/** The scenarios that mean something on this platform, in run order. */ +const RUNNABLE_SCENARIOS = SCENARIOS.filter( + (scenario) => + scenario.platforms == null || + scenario.platforms.includes(Platform.OS as 'ios' | 'android'), +); +const SKIPPED_HERE = SCENARIOS.length - RUNNABLE_SCENARIOS.length; + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -68,7 +76,7 @@ export default function BenchmarkApp() { const [manualActive, setManualActive] = useState(false); const [scenarioIndex, setScenarioIndex] = useState(0); const [mapProps, setMapProps] = useState(() => - SCENARIOS[0].props(), + RUNNABLE_SCENARIOS[0].props(), ); const [mapKey, setMapKey] = useState(0); const [results, setResults] = useState([]); @@ -81,7 +89,7 @@ export default function BenchmarkApp() { beforeBytes: number; } | null>(null); - const scenario = SCENARIOS[scenarioIndex]; + const scenario = RUNNABLE_SCENARIOS[scenarioIndex]; useEffect(() => { displayRefreshRateHz() @@ -129,9 +137,9 @@ export default function BenchmarkApp() { setRunning(true); setResults([]); try { - for (let index = 0; index < SCENARIOS.length; index += 1) { + for (let index = 0; index < RUNNABLE_SCENARIOS.length; index += 1) { setScenarioIndex(index); - const result = await runScenario(SCENARIOS[index], { + const result = await runScenario(RUNNABLE_SCENARIOS[index], { provider, mount, context, @@ -213,7 +221,7 @@ export default function BenchmarkApp() { return; } setScenarioIndex(index); - setMapProps(SCENARIOS[index].props()); + setMapProps(RUNNABLE_SCENARIOS[index].props()); setMapKey((key) => key + 1); }, [running], @@ -263,6 +271,7 @@ export default function BenchmarkApp() { key={`apple:${mapKey}`} ref={mapRef} provider="apple" + markerRendering={mapProps.markerRendering} {...commonMapProps} /> )} @@ -281,7 +290,7 @@ export default function BenchmarkApp() { - {SCENARIOS.map((item, index) => ( + {RUNNABLE_SCENARIOS.map((item, index) => ( {results.length > 0 ? `${passed}/${results.length} passed` - : `${SCENARIOS.length} scenarios · ${SKIPPED_SCENARIOS.length} skipped`} + : `${RUNNABLE_SCENARIOS.length} scenarios · ${SKIPPED_SCENARIOS.length + SKIPPED_HERE} skipped`} void; cameraMoveThrottleMs?: number; + markerRendering?: MarkerRendering; } /** Where scenario O parks the camera stream: a shared value, as an overlay would. */ @@ -65,6 +67,8 @@ export interface BenchmarkScenario { settleMs?: number; /** Also fail the scenario when the JS thread cannot keep up with the frame budget. */ checkJsLag?: boolean; + /** Platforms the scenario means something on; elsewhere it is skipped. */ + platforms?: ('ios' | 'android')[]; run(context: ScenarioContext): Promise; } @@ -217,6 +221,20 @@ export const SCENARIOS: BenchmarkScenario[] = [ settleMs: 2500, run: (context) => pan(context, WARSAW_REGION, 10, 0.02, 500), }, + { + id: 'F2-pan-10k-sprites', + name: 'F2 · Ten-leg pan, sprites', + description: + 'F with markerRendering="sprites": the same 10,000 markers drawn into map tiles. Apple Maps only.', + props: () => ({ + region: WARSAW_REGION, + markers: markers(10_000), + markerRendering: 'sprites', + }), + settleMs: 2500, + platforms: ['ios'], + run: (context) => pan(context, WARSAW_REGION, 10, 0.02, 500), + }, { id: 'G-zoom-10k', name: 'G · Zoom sweep', @@ -225,6 +243,20 @@ export const SCENARIOS: BenchmarkScenario[] = [ settleMs: 2500, run: (context) => zoomSweep(context, WARSAW_REGION), }, + { + id: 'G2-zoom-10k-sprites', + name: 'G2 · Zoom sweep, sprites', + description: + 'G with markerRendering="sprites": the same 10,000 markers drawn into map tiles. Apple Maps only.', + props: () => ({ + region: WARSAW_REGION, + markers: markers(10_000), + markerRendering: 'sprites', + }), + settleMs: 2500, + platforms: ['ios'], + run: (context) => zoomSweep(context, WARSAW_REGION), + }, { id: 'H-rotate-10k', name: 'H · Rotation', @@ -366,6 +398,24 @@ SCENARIOS.push( await pan(context, POLAND_REGION, 4, 0.4); }, }, + { + id: 'P2-clustered-100k-sprites', + name: 'P2 · 100,000 clustered, sprites', + description: + 'P with markerRendering="sprites": cluster badges and pins drawn into map tiles. Apple Maps only.', + props: () => ({ + region: POLAND_REGION, + markers: markers(100_000), + clusteringEnabled: true, + markerRendering: 'sprites', + }), + settleMs: 6000, + platforms: ['ios'], + async run(context) { + await zoomSweep(context, POLAND_REGION); + await pan(context, POLAND_REGION, 4, 0.4); + }, + }, ); SCENARIOS.push({ @@ -378,6 +428,21 @@ SCENARIOS.push({ run: (context) => zoomSweep(context, WARSAW_REGION, [13, 14, 12, 15, 11]), }); +SCENARIOS.push({ + id: 'N2-dense-10k-sprites', + name: 'N2 · Dense 10,000, sprites', + description: + 'N with markerRendering="sprites": up to 2,000 pins on screen drawn into map tiles. Apple Maps only.', + props: () => ({ + region: WARSAW_REGION, + markers: denseMarkers(10_000), + markerRendering: 'sprites', + }), + settleMs: 2500, + platforms: ['ios'], + 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 9116593..91cd317 100644 --- a/example/maestro/benchmark-run-all.yaml +++ b/example/maestro/benchmark-run-all.yaml @@ -12,8 +12,10 @@ appId: com.nitromaps.example timeout: 60000 - tapOn: id: 'benchmark-run-all' -# The summary reads "/16 passed" once every scenario has a result; the -# last result row can sit below the fold of the results list. +# The summary reads "/ passed" once every scenario has a result, +# where n counts the scenarios that run on this platform: the four sprite +# scenarios are Apple Maps only, so Android runs 16 of the 20. The last result +# row can sit below the fold of the results list. - extendedWaitUntil: visible: text: '.*/16 passed' 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 7ae5581..bd13f12 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 @@ -44,6 +44,9 @@ class HybridMapView(private val context: ThemedReactContext) : /** Apple-only: Google Maps draws its own default marker. Stored so the prop round-trips. */ override var pinStyle: MarkerPinStyle? = null + /** Apple-only: the sprite layer is a MapKit rendering path. Stored so the prop round-trips. */ + override var markerRendering: MarkerRendering? = null + override var provider: MapProvider? get() = _provider set(value) { @@ -352,6 +355,7 @@ class HybridMapView(private val context: ThemedReactContext) : _markerEnteringAnimation = null _clusterEnteringAnimation = null pinStyle = null + markerRendering = null onRegionChange = null onRegionChangeComplete = null onCameraMove = null diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index 0ada0cd..7d51364 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -165,6 +165,15 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } } + var markerRendering: MarkerRendering? { + didSet { + guard markerRendering != oldValue else { + return + } + overlayController.setMarkerRendering(markerRendering ?? .views) + } + } + var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? var onCameraMove: ((Camera) -> Void)? { @@ -453,6 +462,36 @@ final class AppleMapProviderAdapter: MapProviderAdapter { overlayController.renderer(for: overlay) } + /// A tap on the sprite layer: marker presses fire here, once, whether or not + /// the marker is promoted to a selected annotation view for its callout; + /// cluster presses zoom like a cluster view would. + func notifySpritePress(at point: CGPoint) -> Bool { + guard let press = overlayController.pressSprite(at: point) else { + return false + } + + switch press { + case let .marker(id), let .promoted(id): + onMarkerPress?(id) + case let .cluster(id, coordinate, count, region): + onClusterPress?(NativeClusterPressEvent( + clusterId: id, + count: Double(count), + coordinate: Coordinate(latitude: coordinate.latitude, longitude: coordinate.longitude) + )) + animateToClusterRegion(region) + } + return true + } + + func handleAnnotationDeselect(_ annotation: MKAnnotation?) { + overlayController.demoteSprite(matching: annotation) + } + + func isPromotedSprite(_ annotation: MKAnnotation?) -> Bool { + overlayController.isPromotedSprite(annotation) + } + func prepareForRecycle() { cameraStreamClock.stop() isUserRegionChange = false @@ -477,6 +516,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { polygons = nil circles = nil overlayController.reset() + markerRendering = nil mapType = .standard region = nil camera = nil diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift index f8a5a4a..5d33dc8 100644 --- a/package/ios/GoogleMapProviderAdapter.swift +++ b/package/ios/GoogleMapProviderAdapter.swift @@ -175,6 +175,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { /// Google Maps draws its own default marker; the Apple pin style does not apply. var pinStyle: MarkerPinStyle? + var markerRendering: MarkerRendering? var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? @@ -318,6 +319,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { markerEnteringAnimation = nil clusterEnteringAnimation = nil pinStyle = nil + markerRendering = nil } private func applyRegion(_ region: Region, animated: Bool = false) { diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift index cd15c59..6448804 100644 --- a/package/ios/HybridMapView.swift +++ b/package/ios/HybridMapView.swift @@ -167,6 +167,11 @@ final class HybridMapView: HybridMapViewSpec { set { setBackedOnMain(newValue, store: \.pinStyle) { $0.pinStyle = $1 } } } + var markerRendering: MarkerRendering? { + get { getBacked(\.markerRendering) } + set { setBackedOnMain(newValue, store: \.markerRendering) { $0.markerRendering = $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 c1279e7..a3d96af 100644 --- a/package/ios/HybridMapViewDelegate.swift +++ b/package/ios/HybridMapViewDelegate.swift @@ -38,6 +38,10 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni return } + if parent.notifySpritePress(at: point) { + return + } + DispatchQueue.main.async { [weak self, weak parent] in guard let self, let parent else { return @@ -194,12 +198,19 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni return } - parent?.onMarkerPress?(marker.id) + // A sprite promoted for its callout reported its press when it was tapped. + if parent?.isPromotedSprite(marker) != true { + parent?.onMarkerPress?(marker.id) + } if marker.title == nil && marker.subtitle == nil { mapView.deselectAnnotation(view.annotation, animated: true) } } + func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) { + parent?.handleAnnotationDeselect(view.annotation) + } + func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) { if #available(iOS 16.0, *), let mapFeature = annotation as? MKMapFeatureAnnotation { diff --git a/package/ios/MapMarkerAnnotation.swift b/package/ios/MapMarkerAnnotation.swift index aa6850d..e0d1377 100644 --- a/package/ios/MapMarkerAnnotation.swift +++ b/package/ios/MapMarkerAnnotation.swift @@ -104,6 +104,15 @@ final class MapMarkerAnnotation: NSObject, MKAnnotation { } func centerOffset(forImageSize imageSize: CGSize) -> CGPoint { + Self.centerOffset(anchor: anchor, centerOffset: centerOffset, imageSize: imageSize) + } + + /// Where the image centre sits relative to the coordinate, in points, y down. + static func centerOffset( + anchor: MarkerAnchor?, + centerOffset: MarkerPoint?, + imageSize: CGSize + ) -> CGPoint { let anchorX = anchor?.x ?? 0.5 let anchorY = anchor?.y ?? 1.0 var offsetX = (0.5 - anchorX) * imageSize.width diff --git a/package/ios/MapOverlayController.swift b/package/ios/MapOverlayController.swift index 100bb85..11211d9 100644 --- a/package/ios/MapOverlayController.swift +++ b/package/ios/MapOverlayController.swift @@ -18,10 +18,31 @@ final class MapOverlayController { let tappable: Bool } + /// What a tap on the sprite layer hit, and what the adapter should do about it. + enum SpritePress { + case marker(id: String) + /// The marker was promoted to a selected annotation view for its callout. + /// The press fires now, like `marker`; the annotation's later `didSelect` + /// is not a second press. + case promoted(id: String) + case cluster(id: String, coordinate: CLLocationCoordinate2D, count: Int, region: MKCoordinateRegion) + } + private weak var mapView: MKMapView? - /// All currently shown annotations (singles and clusters), keyed by render key. + /// Annotations on the map (singles and clusters), keyed by render key. private var displayedAnnotations: [MarkerRenderKey: MKAnnotation] = [:] + /// Versions of everything shown, annotations and sprites alike; the pipeline diffs against it. private var displayedAnnotationVersions: [MarkerRenderKey: Int] = [:] + /// What the sprite layer draws, keyed like the annotations. + private var displayedSprites: [MarkerRenderKey: MarkerSprite] = [:] + private let spriteOverlay = MarkerSpriteOverlay() + private weak var spriteRenderer: MarkerSpriteRenderer? + private var isSpritePublishScheduled = false + /// One image load per image key, fanned out to every sprite waiting for it. + private var pendingSpriteImageLoads: [NSString: [MarkerRenderKey]] = [:] + /// The sprite shown as a selected annotation view for its callout, if any. + private var promotedSprite: (entry: MarkerRenderEntry, annotation: MapMarkerAnnotation)? + private(set) var markerRendering: MarkerRendering = .views private let markerPipeline = MarkerRenderPipeline() private lazy var applyScheduler = MarkerApplyScheduler(sink: MarkerApplyScheduler.Sink( remove: { [weak self] keys in self?.applyRemovals(keys) }, @@ -59,12 +80,19 @@ final class MapOverlayController { liveRefreshClock.stop() markerPipeline.store?.removeListener(self) markerPipeline.reset() + promotedSprite = nil + pendingSpriteImageLoads.removeAll() + displayedSprites.removeAll() + spriteOverlay.snapshots.replace(.empty) guard let mapView else { return } mapView.removeAnnotations(Array(displayedAnnotations.values)) mapView.removeOverlays(Array(shapeOverlays.values)) + if markerRendering == .sprites { + mapView.removeOverlay(spriteOverlay) + } displayedAnnotations.removeAll() displayedAnnotationVersions.removeAll() shapeOverlays.removeAll() @@ -84,13 +112,46 @@ final class MapOverlayController { reapplyMarkers() } + /// Switches between annotation views and the sprite layer. Everything shown + /// is taken down and re-added through the new path. + func setMarkerRendering(_ mode: MarkerRendering) { + guard mode != markerRendering else { + return + } + markerRendering = mode + applyScheduler.cancel() + promotedSprite = nil + pendingSpriteImageLoads.removeAll() + displayedSprites.removeAll() + spriteOverlay.snapshots.replace(.empty) + guard let mapView else { + return + } + + mapView.removeAnnotations(Array(displayedAnnotations.values)) + displayedAnnotations.removeAll() + displayedAnnotationVersions.removeAll() + if mode == .sprites { + mapView.addOverlay(spriteOverlay, level: .aboveLabels) + } else { + mapView.removeOverlay(spriteOverlay) + } + reapplyMarkers() + } + /// Ids of the markers inside a displayed cluster; empty once it is gone. func clusterMembers(id: String) -> [String] { - guard let cluster = displayedAnnotations[.cluster(id: id)] as? MapClusterAnnotation, - let store = markerPipeline.store else { + guard let store = markerPipeline.store else { return [] } - return store.ids(for: cluster.memberHandles) + if let cluster = displayedAnnotations[.cluster(id: id)] as? MapClusterAnnotation { + return store.ids(for: cluster.memberHandles) + } + if let sprite = displayedSprites[.cluster(id: id)], + case let .cluster(_, _, _, memberHandles, _) = sprite.element { + return store.ids(for: memberHandles) + } + return [] } func reapplyMarkers() { @@ -190,14 +251,351 @@ final class MapOverlayController { guard let mapView else { return } + let viewDiff = markerRendering == .sprites ? applySpriteDiff(diff) : diff applyScheduler.schedule(PendingMarkerApply( - diff: diff, + diff: viewDiff, center: mapView.region.center, animateEntering: true, animationBudget: .max )) } + // MARK: - Sprite layer + + /// Applies the sprite part of a diff at once and returns what still needs an + /// annotation view: draggable markers, and the marker promoted for its + /// callout. Sprites are a dictionary update and one bitmap re-render off the + /// main thread, so they need no frame budget. + private func applySpriteDiff(_ diff: MarkerRenderDiff) -> MarkerRenderDiff { + var viewRemovals = Set() + var viewAdds: [MarkerRenderEntry] = [] + var viewRetained: [MarkerRenderEntry] = [] + var changed = false + // Union of the sprites that changed, so a pan re-renders the edge tiles only. + var dirty = MKMapRect.null + func touch(_ sprite: MarkerSprite) { + dirty = dirty.union(MKMapRect(x: sprite.mapPoint.x, y: sprite.mapPoint.y, width: 0, height: 0)) + } + + for key in diff.removedKeys { + if let removed = displayedSprites.removeValue(forKey: key) { + displayedAnnotationVersions.removeValue(forKey: key) + touch(removed) + changed = true + } else { + if promotedSprite?.entry.key == key { + promotedSprite = nil + } + viewRemovals.insert(key) + } + } + + for entry in diff.added { + if Self.needsAnnotationView(entry) { + viewAdds.append(entry) + } else { + let sprite = makeSprite(for: entry) + displayedSprites[entry.key] = sprite + displayedAnnotationVersions[entry.key] = entry.version + touch(sprite) + changed = true + } + } + + for entry in diff.retained { + if let current = displayedSprites[entry.key] { + touch(current) + if Self.needsAnnotationView(entry) { + displayedSprites.removeValue(forKey: entry.key) + displayedAnnotationVersions.removeValue(forKey: entry.key) + viewAdds.append(entry) + } else { + let sprite = makeSprite(for: entry, reusing: current) + displayedSprites[entry.key] = sprite + displayedAnnotationVersions[entry.key] = entry.version + touch(sprite) + } + changed = true + } else if promotedSprite?.entry.key == entry.key { + promotedSprite?.entry = entry + viewRetained.append(entry) + } else if Self.needsAnnotationView(entry) { + viewRetained.append(entry) + } else { + // No longer draggable: the view goes and a sprite takes its place. + applyRemovals([entry.key]) + let sprite = makeSprite(for: entry) + displayedSprites[entry.key] = sprite + displayedAnnotationVersions[entry.key] = entry.version + touch(sprite) + changed = true + } + } + + if changed { + publishSprites(invalidating: dirty) + } + return MarkerRenderDiff(removedKeys: viewRemovals, added: viewAdds, retained: viewRetained) + } + + /// Dragging needs touch handling that only an annotation view has. + private static func needsAnnotationView(_ entry: MarkerRenderEntry) -> Bool { + if case let .single(descriptor) = entry.element { + return descriptor.draggable == true + } + return false + } + + private func makeSprite(for entry: MarkerRenderEntry, reusing current: MarkerSprite? = nil) -> MarkerSprite { + let scale = mapView?.traitCollection.displayScale ?? UIScreen.main.scale + switch entry.element { + case let .single(descriptor): + let coordinate = descriptor.coordinate.toCLLocationCoordinate2D() + let rotation = descriptor.rotation ?? 0 + let radians: CGFloat = descriptor.flat != true && rotation != 0 ? CGFloat(rotation * .pi / 180) : 0 + var image: CGImage? + var size = CGSize.zero + if let imageDescriptor = descriptor.image { + let token = MarkerImageLoader.cacheKey(for: imageDescriptor) + if let current, + case let .single(previous) = current.element, + let previousImage = previous.image, + MarkerImageLoader.cacheKey(for: previousImage) == token, + let loaded = current.image { + image = loaded + size = current.size + } else if let cached = MarkerImageLoader.cachedImage(for: imageDescriptor) { + image = cached.cgImage + size = cached.size + } else { + loadSpriteImage(imageDescriptor, token: token, key: entry.key) + } + } else { + let pin = PinImageRenderer.pin(scale: scale) + image = pin.cgImage + size = pin.size + } + return MarkerSprite( + key: entry.key, + element: entry.element, + coordinate: coordinate, + mapPoint: MKMapPoint(coordinate), + image: image, + size: size, + hitSize: size, + centerOffset: MapMarkerAnnotation.centerOffset( + anchor: descriptor.anchor, + centerOffset: descriptor.centerOffset, + imageSize: size + ), + rotation: radians, + opacity: CGFloat(descriptor.opacity ?? 1) + ) + case let .cluster(_, coordinate, count, _, _): + let badge = ClusterBadgeImageRenderer.badge(count: count, scale: scale) + let diameter = ClusterBadgeMetrics.diameter(for: count) + return MarkerSprite( + key: entry.key, + element: entry.element, + coordinate: coordinate, + mapPoint: MKMapPoint(coordinate), + image: badge.cgImage, + size: badge.size, + hitSize: CGSize(width: diameter, height: diameter), + centerOffset: .zero, + rotation: 0, + opacity: 1 + ) + } + } + + private func loadSpriteImage(_ image: MarkerImage, token: NSString, key: MarkerRenderKey) { + if pendingSpriteImageLoads[token] != nil { + pendingSpriteImageLoads[token]?.append(key) + return + } + pendingSpriteImageLoads[token] = [key] + MarkerImageLoader.load(image) { [weak self] loaded in + guard let self else { + return + } + let keys = self.pendingSpriteImageLoads.removeValue(forKey: token) ?? [] + guard let loaded else { + return + } + var changed = false + for key in keys { + guard var sprite = self.displayedSprites[key], + case let .single(descriptor) = sprite.element, + let current = descriptor.image, + MarkerImageLoader.cacheKey(for: current) == token else { + continue + } + sprite.image = loaded.cgImage + sprite.size = loaded.size + sprite.hitSize = loaded.size + sprite.centerOffset = MapMarkerAnnotation.centerOffset( + anchor: descriptor.anchor, + centerOffset: descriptor.centerOffset, + imageSize: loaded.size + ) + self.displayedSprites[key] = sprite + changed = true + } + if changed { + self.scheduleSpritePublish() + } + } + } + + /// Hands the sprites to the renderer and asks MapKit for a redraw: of the + /// tiles around `dirty` when the change is local, of everything when it is + /// nil or covers most of the view. + private func publishSprites(invalidating dirty: MKMapRect? = nil) { + isSpritePublishScheduled = false + let signpost = MapTrace.begin("publishSprites") + defer { MapTrace.end("publishSprites", signpost) } + let snapshot = MarkerSpriteSnapshot.ordered(Array(displayedSprites.values)) + spriteOverlay.snapshots.replace(snapshot) + guard let renderer = spriteRenderer else { + return + } + guard let mapView, let dirty, !dirty.isNull, mapView.bounds.width > 0 else { + renderer.setNeedsDisplay() + return + } + // Sprites reach past their coordinate by `maxReach` points; pad in map points. + let mapPointsPerPoint = mapView.visibleMapRect.width / Double(mapView.bounds.width) + let padding = Double(snapshot.maxReach + 2) * mapPointsPerPoint + let area = dirty.insetBy(dx: -padding, dy: -padding) + let visible = mapView.visibleMapRect + if area.contains(visible) || area.intersection(visible).width * area.intersection(visible).height > visible.width * visible.height * 0.6 { + renderer.setNeedsDisplay() + } else { + renderer.setNeedsDisplay(area) + } + } + + /// Coalesces publishes from image loads that complete in the same turn. + private func scheduleSpritePublish() { + guard !isSpritePublishScheduled else { + return + } + isSpritePublishScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self, self.isSpritePublishScheduled else { + return + } + self.publishSprites() + } + } + + /// Hit-tests the sprites under a tap, topmost first. A marker with a title + /// or subtitle is promoted to an annotation view and selected so MapKit shows + /// its callout; its sprite comes back when the callout closes. + func pressSprite(at point: CGPoint) -> SpritePress? { + guard let mapView, markerRendering == .sprites else { + return nil + } + let snapshot = spriteOverlay.snapshots.current + let slop: CGFloat = 6 + let probeHalfSize = snapshot.maxReach + slop + let probe = mapView.convert( + CGRect(x: point.x - probeHalfSize, y: point.y - probeHalfSize, width: probeHalfSize * 2, height: probeHalfSize * 2), + toRegionFrom: mapView + ) + let probeBounds = MarkerViewportFilter.PaddedBounds(region: probe, padding: 0) + + for sprite in snapshot.sprites.reversed() { + guard sprite.image != nil, + probeBounds.contains(latitude: sprite.coordinate.latitude, longitude: sprite.coordinate.longitude) else { + continue + } + let center = mapView.convert(sprite.coordinate, toPointTo: mapView) + let frame = CGRect( + x: center.x + sprite.centerOffset.x - sprite.hitSize.width / 2 - slop, + y: center.y + sprite.centerOffset.y - sprite.hitSize.height / 2 - slop, + width: sprite.hitSize.width + slop * 2, + height: sprite.hitSize.height + slop * 2 + ) + guard frame.contains(point) else { + continue + } + switch sprite.element { + case let .single(descriptor): + guard descriptor.title != nil || descriptor.subtitle != nil else { + return .marker(id: descriptor.id) + } + promoteSprite(sprite) + return .promoted(id: descriptor.id) + case let .cluster(id, coordinate, count, _, region): + return .cluster(id: id, coordinate: coordinate, count: count, region: region) + } + } + return nil + } + + private func promoteSprite(_ sprite: MarkerSprite) { + guard let mapView, + case let .single(descriptor) = sprite.element, + let version = displayedAnnotationVersions[sprite.key] else { + return + } + demotePromotedSprite(restoringSprite: true) + + let annotation = MapMarkerAnnotation( + descriptor: descriptor, + enteringAnimation: OverlayEnteringAnimationResolver.resolve(nil) + ) + displayedSprites.removeValue(forKey: sprite.key) + displayedAnnotations[sprite.key] = annotation + promotedSprite = ( + entry: MarkerRenderEntry(key: sprite.key, element: sprite.element, version: version), + annotation: annotation + ) + publishSprites() + mapView.addAnnotation(annotation) + DispatchQueue.main.async { [weak self, weak mapView] in + guard let self, let mapView, self.promotedSprite?.annotation === annotation else { + return + } + mapView.selectAnnotation(annotation, animated: true) + } + } + + /// Whether `annotation` is the view standing in for a tapped sprite, whose + /// press has already been reported. + func isPromotedSprite(_ annotation: MKAnnotation?) -> Bool { + guard let promoted = promotedSprite, let marker = annotation as? MapMarkerAnnotation else { + return false + } + return marker === promoted.annotation + } + + /// Puts the promoted marker's sprite back once its callout is dismissed. + func demoteSprite(matching annotation: MKAnnotation?) { + guard let promoted = promotedSprite, + let marker = annotation as? MapMarkerAnnotation, + marker === promoted.annotation else { + return + } + demotePromotedSprite(restoringSprite: true) + } + + private func demotePromotedSprite(restoringSprite: Bool) { + guard let promoted = promotedSprite else { + return + } + promotedSprite = nil + displayedAnnotations.removeValue(forKey: promoted.entry.key) + mapView?.removeAnnotation(promoted.annotation) + guard restoringSprite, displayedAnnotationVersions[promoted.entry.key] != nil else { + return + } + displayedSprites[promoted.entry.key] = makeSprite(for: promoted.entry) + publishSprites() + } + private func applyRemovals(_ keys: [MarkerRenderKey]) { guard let mapView else { return @@ -341,6 +739,12 @@ final class MapOverlayController { } func renderer(for overlay: MKOverlay) -> MKOverlayRenderer? { + if overlay === spriteOverlay { + let renderer = MarkerSpriteRenderer(overlay: spriteOverlay) + spriteRenderer = renderer + return renderer + } + guard let style = overlayStyles[ObjectIdentifier(overlay)] else { return nil } diff --git a/package/ios/MapProviderAdapter.swift b/package/ios/MapProviderAdapter.swift index 2088a3d..11f4d83 100644 --- a/package/ios/MapProviderAdapter.swift +++ b/package/ios/MapProviderAdapter.swift @@ -22,6 +22,7 @@ protocol MapProviderAdapter: AnyObject { var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? { get set } var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? { get set } var pinStyle: MarkerPinStyle? { get set } + var markerRendering: MarkerRendering? { get set } var onRegionChange: ((Region) -> Void)? { get set } var onRegionChangeComplete: ((Region) -> Void)? { get set } @@ -75,6 +76,7 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter { var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? var pinStyle: MarkerPinStyle? + var markerRendering: MarkerRendering? var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? diff --git a/package/ios/MapViewState.swift b/package/ios/MapViewState.swift index 0ed5c34..b6b38b5 100644 --- a/package/ios/MapViewState.swift +++ b/package/ios/MapViewState.swift @@ -20,6 +20,7 @@ struct MapViewState { var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? var pinStyle: MarkerPinStyle? + var markerRendering: MarkerRendering? var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? var onCameraMove: ((Camera) -> Void)? @@ -58,6 +59,7 @@ struct MapViewState { adapter.markerEnteringAnimation = markerEnteringAnimation adapter.clusterEnteringAnimation = clusterEnteringAnimation adapter.pinStyle = pinStyle + adapter.markerRendering = markerRendering adapter.onRegionChange = onRegionChange adapter.onRegionChangeComplete = onRegionChangeComplete adapter.cameraMoveThrottleMs = cameraMoveThrottleMs diff --git a/package/ios/MarkerSpriteLayer.swift b/package/ios/MarkerSpriteLayer.swift new file mode 100644 index 0000000..dd08746 --- /dev/null +++ b/package/ios/MarkerSpriteLayer.swift @@ -0,0 +1,239 @@ +import MapKit +import UIKit + +/// One displayed element as the sprite renderer draws it. +struct MarkerSprite { + let key: MarkerRenderKey + let element: MarkerRenderElement + let coordinate: CLLocationCoordinate2D + let mapPoint: MKMapPoint + /// Bitmap at the screen scale; nil while a marker image is still loading. + var image: CGImage? + /// Drawn size, in points. + var size: CGSize + /// The extent that answers to taps, in points: the pin or image itself, or + /// a cluster's circle without the room its bitmap keeps for the shadow. + var hitSize: CGSize + /// Offset of the image centre from the coordinate, in points, y down. + var centerOffset: CGPoint + /// Radians, clockwise on screen. + let rotation: CGFloat + let opacity: CGFloat + + var isCluster: Bool { + if case .cluster = element { + return true + } + return false + } + + /// Distance from the coordinate to the farthest drawn pixel, in points. + var reach: CGFloat { + max(size.width, size.height) / 2 + (centerOffset.x * centerOffset.x + centerOffset.y * centerOffset.y).squareRoot() + } +} + +/// An immutable set of sprites in draw order: singles north to south, so the +/// southern pins overlap the northern ones the way pins stack, then cluster +/// badges on top. +final class MarkerSpriteSnapshot { + let sprites: [MarkerSprite] + /// The largest `reach` of any sprite, for hit-test pre-filtering. + let maxReach: CGFloat + + init(sprites: [MarkerSprite]) { + self.sprites = sprites + maxReach = sprites.reduce(0) { max($0, $1.reach) } + } + + static let empty = MarkerSpriteSnapshot(sprites: []) + + static func ordered(_ sprites: [MarkerSprite]) -> MarkerSpriteSnapshot { + MarkerSpriteSnapshot(sprites: sprites.sorted { lhs, rhs in + if lhs.isCluster != rhs.isCluster { + return !lhs.isCluster + } + return lhs.coordinate.latitude > rhs.coordinate.latitude + }) + } +} + +/// Hands the current snapshot from the main thread to MapKit's drawing threads. +final class MarkerSpriteSnapshotHolder { + private let lock = NSLock() + private var snapshot = MarkerSpriteSnapshot.empty + + var current: MarkerSpriteSnapshot { + lock.lock() + defer { lock.unlock() } + return snapshot + } + + func replace(_ next: MarkerSpriteSnapshot) { + lock.lock() + snapshot = next + lock.unlock() + } +} + +/// World-sized overlay whose renderer draws the marker sprites. +final class MarkerSpriteOverlay: NSObject, MKOverlay { + let snapshots = MarkerSpriteSnapshotHolder() + + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: 0, longitude: 0) + } + + var boundingMapRect: MKMapRect { + .world + } +} + +/// Draws marker pins, marker images and cluster badges into map tiles. +/// +/// MapKit calls `draw` per tile on its own threads and composites the tiles on +/// the GPU, so a pan moves the sprites at no main-thread cost and a viewport +/// change re-renders bitmaps instead of laying out views. Sprites keep the +/// screen size of the zoom scale a tile was drawn for; during a pinch MapKit +/// scales the tiles it has until it has drawn new ones, as it does for every +/// overlay renderer. +final class MarkerSpriteRenderer: MKOverlayRenderer { + private let snapshots: MarkerSpriteSnapshotHolder + + init(overlay: MarkerSpriteOverlay) { + snapshots = overlay.snapshots + super.init(overlay: overlay) + } + + override func canDraw(_ mapRect: MKMapRect, zoomScale: MKZoomScale) -> Bool { + !snapshots.current.sprites.isEmpty + } + + override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) { + guard zoomScale > 0 else { + return + } + let sprites = snapshots.current.sprites + // Map points per screen point at this tile's zoom scale. + let scale = 1 / CGFloat(zoomScale) + + for sprite in sprites { + guard let image = sprite.image else { + continue + } + let width = sprite.size.width * scale + let height = sprite.size.height * scale + let centerX = sprite.mapPoint.x + sprite.centerOffset.x * scale + let centerY = sprite.mapPoint.y + sprite.centerOffset.y * scale + // Cull on the rotated extent; a sprite that touches the tile draws whole. + let halfDiagonal = (width * width + height * height).squareRoot() / 2 + let reach = MKMapRect( + x: centerX - halfDiagonal, + y: centerY - halfDiagonal, + width: halfDiagonal * 2, + height: halfDiagonal * 2 + ) + guard mapRect.intersects(reach) else { + continue + } + + // Map points go through the renderer's own conversion into its drawing + // space, as every overlay renderer's content should. + let drawRect = rect(for: MKMapRect(x: centerX - width / 2, y: centerY - height / 2, width: width, height: height)) + context.saveGState() + context.translateBy(x: drawRect.midX, y: drawRect.midY) + if sprite.rotation != 0 { + context.rotate(by: sprite.rotation) + } + // The drawing space is y-down; CGImage drawing is y-up. + context.scaleBy(x: 1, y: -1) + context.setAlpha(sprite.opacity) + context.draw( + image, + in: CGRect(x: -drawRect.width / 2, y: -drawRect.height / 2, width: drawRect.width, height: drawRect.height) + ) + context.restoreGState() + } + } +} + +/// Draws the cluster badge once per count and screen scale: the look of +/// `NitroClusterAnnotationView` as a bitmap, with room for its shadow. +enum ClusterBadgeImageRenderer { + /// Room around the circle for the shadow. + static let margin: CGFloat = 5 + private static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 512 + return cache + }() + + static func badge(count: Int, scale: CGFloat) -> UIImage { + let renderScale = scale > 0 ? scale : UIScreen.main.scale + let key = "\(count)@\(renderScale)" as NSString + if let cached = cache.object(forKey: key) { + return cached + } + + let diameter = ClusterBadgeMetrics.diameter(for: count) + let side = diameter + margin * 2 + let format = UIGraphicsImageRendererFormat.default() + format.scale = renderScale + let image = UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format).image { context in + let cg = context.cgContext + let circle = CGRect(x: margin, y: margin, width: diameter, height: diameter) + let path = UIBezierPath(ovalIn: circle) + + cg.saveGState() + cg.setShadow( + offset: CGSize(width: 0, height: 1.5), + blur: 3, + color: UIColor.black.withAlphaComponent(0.28).cgColor + ) + UIColor(red: 0.04, green: 0.52, blue: 1.0, alpha: 1).setFill() + path.fill() + cg.restoreGState() + + cg.saveGState() + path.addClip() + let colors = [ + UIColor(red: 0.30, green: 0.62, blue: 1.0, alpha: 1).cgColor, + UIColor(red: 0.04, green: 0.52, blue: 1.0, alpha: 1).cgColor, + ] as CFArray + if let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors, locations: [0, 1]) { + cg.drawLinearGradient( + gradient, + start: CGPoint(x: circle.midX, y: circle.minY), + end: CGPoint(x: circle.midX, y: circle.maxY), + options: [] + ) + } + cg.restoreGState() + + let border = UIBezierPath(ovalIn: circle.insetBy(dx: 1, dy: 1)) + border.lineWidth = 2 + UIColor.white.setStroke() + border.stroke() + + let text = NitroClusterAnnotationView.format(count) as NSString + let maxWidth = diameter - 8 + var fontSize: CGFloat = 13 + var attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: fontSize, weight: .bold), + .foregroundColor: UIColor.white, + ] + var textSize = text.size(withAttributes: attributes) + if textSize.width > maxWidth { + fontSize = max(fontSize * 0.6, fontSize * maxWidth / textSize.width) + attributes[.font] = UIFont.systemFont(ofSize: fontSize, weight: .bold) + textSize = text.size(withAttributes: attributes) + } + text.draw( + at: CGPoint(x: circle.midX - textSize.width / 2, y: circle.midY - textSize.height / 2), + withAttributes: attributes + ) + } + cache.setObject(image, forKey: key) + return image + } +} diff --git a/package/ios/MarkerViewportFilter.swift b/package/ios/MarkerViewportFilter.swift index 3c284a8..78f4955 100644 --- a/package/ios/MarkerViewportFilter.swift +++ b/package/ios/MarkerViewportFilter.swift @@ -93,7 +93,7 @@ enum MarkerViewportFilter { /// The region grown by `padding` on each side, with longitudes wrapped into /// [-180, 180]: a region across the antimeridian ends up with `minLon` east /// of `maxLon`, and `contains` reads that as the two-piece range it is. - private struct PaddedBounds { + struct PaddedBounds { let minLat: Double let maxLat: Double let minLon: Double diff --git a/package/ios/NitroClusterAnnotationView.swift b/package/ios/NitroClusterAnnotationView.swift index 644ae0f..0ddd487 100644 --- a/package/ios/NitroClusterAnnotationView.swift +++ b/package/ios/NitroClusterAnnotationView.swift @@ -65,7 +65,7 @@ final class NitroClusterAnnotationView: MKAnnotationView { label.text = Self.format(count) } - private static func format(_ count: Int) -> String { + static func format(_ count: Int) -> String { if count >= 1000 { return String(format: "%.1fk", Double(count) / 1000) } diff --git a/package/src/components/MapView.tsx b/package/src/components/MapView.tsx index 89972b2..a1e2e68 100644 --- a/package/src/components/MapView.tsx +++ b/package/src/components/MapView.tsx @@ -85,6 +85,7 @@ export function MapView({ showsCompass, showsScale, pinStyle, + markerRendering, customMapStyle, clusteringEnabled, mapPadding, @@ -374,6 +375,7 @@ export function MapView({ showsCompass={showsCompass} showsScale={showsScale} pinStyle={pinStyle} + markerRendering={markerRendering} customMapStyle={customMapStyle} clusteringEnabled={clusteringEnabled} mapPadding={stableMapPadding} diff --git a/package/src/index.ts b/package/src/index.ts index 656aa2c..7cb1136 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -30,6 +30,7 @@ export type { MarkerImageSource, MarkerDescriptor, MarkerPinStyle, + MarkerRendering, MarkerPoint, MarkerProps, OverlayEnteringAnimation, diff --git a/package/src/native/specs/MapView.nitro.ts b/package/src/native/specs/MapView.nitro.ts index 3f644ea..944c8f4 100644 --- a/package/src/native/specs/MapView.nitro.ts +++ b/package/src/native/specs/MapView.nitro.ts @@ -100,6 +100,14 @@ export type ApplePoiCategory = */ export type MarkerPinStyle = 'flat' | 'system'; +/** + * How Apple MapKit puts markers on screen. `views` is one `MKAnnotationView` + * per displayed marker; `sprites` draws the displayed markers and cluster + * badges into map tiles through an `MKOverlayRenderer`, one bitmap per tile + * instead of one view per marker. + */ +export type MarkerRendering = 'views' | 'sprites'; + export interface NativePoiPressEvent { provider: MapProvider; coordinate: Coordinate; @@ -192,6 +200,9 @@ export interface MapViewProps extends HybridViewProps { /** Apple MapKit pin rendering for markers without an image. */ pinStyle?: MarkerPinStyle; + /** Apple MapKit marker rendering path: annotation views or the sprite layer. */ + markerRendering?: MarkerRendering; + /** 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 55d9948..91635c7 100644 --- a/package/src/types/index.ts +++ b/package/src/types/index.ts @@ -4,6 +4,7 @@ export type { Region, EdgePadding, VisibleRegion } from './region'; export type { ApplePoiCategory, MarkerPinStyle, + MarkerRendering, } from '../native/specs/MapView.nitro'; export type { ApplePoiPressEvent, diff --git a/package/src/types/map.ts b/package/src/types/map.ts index 4213e47..18a41a8 100644 --- a/package/src/types/map.ts +++ b/package/src/types/map.ts @@ -10,6 +10,7 @@ import type { import type { ApplePoiCategory, MarkerPinStyle, + MarkerRendering, } from '../native/specs/MapView.nitro'; import type { MarkerCollection } from '../markers/MarkerCollection'; import type { MarkerDescriptor, OverlayEnteringAnimation } from './overlays'; @@ -196,6 +197,16 @@ interface ExistingDefaultProviderProps extends BaseMapViewProps { */ pinStyle?: MarkerPinStyle; + /** + * How Apple MapKit puts markers on screen. `views` (default) is one + * annotation view per displayed marker, with callouts, dragging and + * entering animations. `sprites` draws the displayed markers and cluster + * badges into map tiles, one bitmap per tile, so a viewport change costs + * no main-thread layout; taps still work, a tapped marker with a title + * shows its callout, draggable markers stay views. Google Maps ignores it. + */ + markerRendering?: MarkerRendering; + /** Custom map style as a JSON string (full support on Google Maps; curated subset on Apple MapKit iOS 16+). */ customMapStyle?: string; @@ -222,6 +233,14 @@ interface AppleMapViewProps extends BaseMapViewProps { */ pinStyle?: MarkerPinStyle; + /** + * `views` (default) is one annotation view per displayed marker; `sprites` + * draws the displayed markers and cluster badges into map tiles through an + * overlay renderer: no per-marker views, taps and callouts still work, + * draggable markers stay views. + */ + markerRendering?: MarkerRendering; + /** Custom map style as a JSON string. Apple MapKit applies a curated subset on iOS 16+. */ customMapStyle?: string; @@ -244,6 +263,9 @@ interface GoogleMapViewProps extends BaseMapViewProps { /** Google Maps draws its own default marker. */ pinStyle?: never; + /** Google Maps draws markers its own way. */ + markerRendering?: never; + /** Custom Google Maps style JSON. */ customMapStyle?: string; @@ -259,6 +281,7 @@ interface OpenStreetMapViewProps extends BaseMapViewProps { googleMapId?: never; showsScale?: never; pinStyle?: never; + markerRendering?: never; customMapStyle?: never; clusteringEnabled?: never; clusterEnteringAnimation?: never; @@ -270,6 +293,7 @@ interface MapboxMapViewProps extends BaseMapViewProps { googleMapId?: never; showsScale?: never; pinStyle?: never; + markerRendering?: never; customMapStyle?: never; clusteringEnabled?: never; clusterEnteringAnimation?: never;