diff --git a/README.md b/README.md
index 68ae3d5..2026089 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,7 @@ Built with [Nitro Modules](https://nitro.margelo.com/) for high-performance nati
- [Map providers](#map-providers)
- [Native POI press events](#native-poi-press-events)
- [Custom marker images](#custom-marker-images)
+- [GeoJSON overlays](#geojson-overlays)
- [Google Maps setup](#google-maps-setup)
- [Marker entering animations](#marker-entering-animations)
- [Capability matrix](#capability-matrix)
@@ -48,7 +49,7 @@ Built with [Nitro Modules](https://nitro.margelo.com/) for high-performance nati
- **New Architecture native** - Built exclusively for React Native's New Architecture: Fabric + TurboModules.
- **Unified map API** - One typed React API for Apple MapKit and Google Maps SDK.
- **Provider-aware props** - TypeScript narrows provider-specific props with `MapViewPropsForProvider
`.
-- **Markers and overlays** - Markers with title/subtitle callouts and drag support, plus polylines, polygons, and circles.
+- **Markers and overlays** - Markers with title/subtitle callouts and drag support, plus polylines, polygons, circles, and GeoJSON FeatureCollections.
- **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.
@@ -300,11 +301,11 @@ Provider-owned points of interest are base-map features supplied by Apple Maps o
Provider-specific props narrow the callback payload:
-| Provider | Payload |
-| --- | --- |
-| `apple` | `{ provider: 'apple', coordinate, name?, category, rawCategory? }` |
-| `google` | `{ provider: 'google', coordinate, name, placeId }` |
-| omitted | `ApplePoiPressEvent \| GooglePoiPressEvent` because the runtime default depends on platform |
+| Provider | Payload |
+| -------- | ------------------------------------------------------------------------------------------- |
+| `apple` | `{ provider: 'apple', coordinate, name?, category, rawCategory? }` |
+| `google` | `{ provider: 'google', coordinate, name, placeId }` |
+| omitted | `ApplePoiPressEvent \| GooglePoiPressEvent` because the runtime default depends on platform |
## Custom marker images
@@ -359,7 +360,7 @@ Platform notes:
### react-native-maps migration (markers)
-| react-native-maps | react-native-better-maps |
+| react-native-maps | react-native-better-maps |
| ---------------------- | ---------------------------------- |
| `image={require(...)}` | `image={require(...)}` |
| `anchor={{ x, y }}` | `anchor={{ x, y }}` |
@@ -369,6 +370,59 @@ Platform notes:
| `opacity` | `opacity` |
| Custom RN child views | Not supported (use bitmap `image`) |
+## GeoJSON overlays
+
+`` converts a GeoJSON object (or JSON string) into the existing marker, polyline, and polygon overlay pipeline. There is no native GeoJSON parser — conversion happens in JavaScript so overlay diffing stays shared.
+
+```tsx
+import { MapView, Geojson, type GeojsonInput } from 'react-native-better-maps';
+
+export function DeliveryMap({
+ deliveryZones,
+}: {
+ deliveryZones: GeojsonInput;
+}) {
+ return (
+
+ console.log(feature.properties)}
+ />
+
+ );
+}
+```
+
+| GeoJSON type | Rendered as |
+| ------------------------------------------------------ | ------------------------------ |
+| `Point` / `MultiPoint` | Marker(s) |
+| `LineString` / `MultiLineString` | Polyline(s) |
+| `Polygon` / `MultiPolygon` | Polygon(s) |
+| `FeatureCollection` / `Feature` / `GeometryCollection` | Flattened into the types above |
+
+Per-feature style follows the [simplestyle](https://github.com/mapbox/simplestyle-spec) property names used by `react-native-maps`: `stroke`, `stroke-width`, `stroke-opacity`, `fill`, `fill-opacity`, and `marker-color`. Marker titles use `properties.title` or `properties.name`; `properties.zIndex` overrides the component-level drawing order.
+
+For large FeatureCollections, convert once with `geojsonToOverlayDescriptors` and pass the result to bulk `markers` / `polylines` / `polygons` props. Collections that expand to more than 1000 overlays log a development warning.
+
+Not supported today: custom marker views, TopoJSON, and altitude (Z is dropped). Invalid GeoJSON is skipped with a development warning instead of crashing.
+
+See [docs/geojson.md](docs/geojson.md) for the full geometry, style, and limit notes.
+
+### react-native-maps migration (GeoJSON)
+
+| react-native-maps | react-native-better-maps |
+| ---------------------------------- | ------------------------------------------ |
+| `` | Same |
+| `color` | `markerColor` |
+| `markerComponent` | Not supported (default markers) |
+| `lineDashPattern` | Not supported |
+| `zIndex` | Same |
+| `onPress` overlay event | `onPress(feature)` with the source Feature |
+| Polygon holes | Supported |
+
## Google Maps setup
Host apps must provide platform API keys for the Google Maps SDK.
@@ -474,6 +528,7 @@ On Google Maps providers, marker and cluster entering animations can reduce UI-t
| Custom marker images | Supported | Supported | Supported |
| Marker callouts / dragging | Supported | Supported | Supported |
| Overlay press events | Supported | Supported | Supported |
+| GeoJSON overlays | Supported (JS conversion) | Supported (JS conversion) | Supported (JS conversion) |
| Native POI press events | Supported on iOS 16+ | Supported | Supported |
| Marker entering animation | System + `fade`, `fade-scale` | System + `fade`; scale fallback | System + `fade`; scale fallback |
| Cluster entering animation | System + `fade`, `fade-scale` | System + `fade`; scale fallback | System + `fade`; scale fallback |
@@ -485,46 +540,51 @@ On Google Maps providers, marker and cluster entering animations can reduce UI-t
### Components
-| Component | Description |
-| ---------- | --------------------- |
-| `MapView` | Root map container |
-| `Marker` | Point annotation |
-| `Polyline` | Line overlay |
-| `Polygon` | Filled area overlay |
-| `Circle` | Circular area overlay |
+| Component | Description |
+| ---------- | --------------------------------- |
+| `MapView` | Root map container |
+| `Marker` | Point annotation |
+| `Polyline` | Line overlay |
+| `Polygon` | Filled area overlay |
+| `Circle` | Circular area overlay |
+| `Geojson` | GeoJSON FeatureCollection overlay |
### Types
-| Type | Description |
-| -------------------------- | ---------------------------------------------------- |
-| `Coordinate` | `{ latitude, longitude }` |
-| `Region` | Center + span |
-| `Camera` | Position, zoom, heading, pitch |
-| `MapType` | `'standard' \| 'satellite' \| 'hybrid' \| 'terrain'` |
-| `MapProvider` | `'apple' \| 'google' \| 'openstreetmap' \| 'mapbox'` |
-| `PoiPressEvent` | Provider-discriminated native POI press payload |
-| `ApplePoiPressEvent` | Apple Maps POI payload with category |
-| `GooglePoiPressEvent` | Google Maps POI payload with place ID |
-| `ApplePoiCategory` | Known MapKit POI categories plus `unknown` |
-| `MapViewRef` | Imperative handle for camera control |
-| `MapViewProps` | Props for `MapView` |
-| `MapViewPropsForProvider` | Provider-specific `MapView` props |
-| `MarkerDescriptor` | Bulk marker descriptor |
-| `MarkerProps` | Props for `Marker` |
-| `MarkerImage` | Resolved marker image descriptor |
-| `MarkerAnchor` | Anchor point on marker image (0..1) |
-| `MarkerPoint` | Point offset in dp |
-| `OverlayEnteringAnimation` | Marker / marker-cluster entering animation config |
-| `PolylineProps` | Props for `Polyline` |
-| `PolygonProps` | Props for `Polygon` |
-| `CircleProps` | Props for `Circle` |
+| Type | Description |
+| --------------------------- | ---------------------------------------------------- |
+| `Coordinate` | `{ latitude, longitude }` |
+| `Region` | Center + span |
+| `Camera` | Position, zoom, heading, pitch |
+| `MapType` | `'standard' \| 'satellite' \| 'hybrid' \| 'terrain'` |
+| `MapProvider` | `'apple' \| 'google' \| 'openstreetmap' \| 'mapbox'` |
+| `PoiPressEvent` | Provider-discriminated native POI press payload |
+| `ApplePoiPressEvent` | Apple Maps POI payload with category |
+| `GooglePoiPressEvent` | Google Maps POI payload with place ID |
+| `ApplePoiCategory` | Known MapKit POI categories plus `unknown` |
+| `MapViewRef` | Imperative handle for camera control |
+| `MapViewProps` | Props for `MapView` |
+| `MapViewPropsForProvider` | Provider-specific `MapView` props |
+| `MarkerDescriptor` | Bulk marker descriptor |
+| `MarkerProps` | Props for `Marker` |
+| `MarkerImage` | Resolved marker image descriptor |
+| `MarkerAnchor` | Anchor point on marker image (0..1) |
+| `MarkerPoint` | Point offset in dp |
+| `OverlayEnteringAnimation` | Marker / marker-cluster entering animation config |
+| `PolylineProps` | Props for `Polyline` |
+| `PolygonProps` | Props for `Polygon` |
+| `CircleProps` | Props for `Circle` |
+| `GeojsonProps` | Props for `Geojson` |
+| `GeojsonFeature` | Feature passed to `Geojson` `onPress` |
+| `GeojsonOverlayDescriptors` | Result of `geojsonToOverlayDescriptors` |
### Utilities
-| Function | Description |
-| --------------------------------------------------- | ----------------------------------- |
-| `regionFromCoordinate(coord, latDelta?, lonDelta?)` | Create a `Region` from a coordinate |
-| `distanceBetween(a, b)` | Haversine distance in meters |
+| Function | Description |
+| --------------------------------------------------- | --------------------------------------------- |
+| `regionFromCoordinate(coord, latDelta?, lonDelta?)` | Create a `Region` from a coordinate |
+| `distanceBetween(a, b)` | Haversine distance in meters |
+| `geojsonToOverlayDescriptors(geojson, options?)` | Convert GeoJSON into bulk overlay descriptors |
## Example app
@@ -533,7 +593,7 @@ bun install
bun run example start
```
-The example app lives in [example](example). It demonstrates provider switching, overlays, clustering, Google Map IDs, entering animation presets, and native POI tap logging.
+The example app lives in [example](example). It demonstrates provider switching, overlays, GeoJSON FeatureCollections, clustering, Google Map IDs, entering animation presets, and native POI tap logging.
For Google Maps in the example app, configure one shared key or platform-specific keys:
@@ -549,6 +609,7 @@ See [example/.env.example](example/.env.example) for the supported environment v
- [Expo setup](docs/expo-setup.md)
- [Architecture](docs/architecture.md)
+- [GeoJSON overlays](docs/geojson.md)
- [Roadmap](docs/roadmap.md)
- [Contributing](CONTRIBUTING.md)
- [ADRs](docs/adr)
diff --git a/docs/architecture.md b/docs/architecture.md
index 8242f59..90e0b48 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -9,7 +9,8 @@
```
┌─────────────────────────────────────────────────┐
│ Public API (TypeScript / React) │
-│ MapView, Marker, Polyline, Polygon, Circle │
+│ MapView, Marker, Polyline, Polygon, Circle, │
+│ Geojson │
│ Types: Coordinate, Region, Camera, MapViewRef │
├─────────────────────────────────────────────────┤
│ Nitro Layer │
@@ -101,7 +102,7 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C
### Overlay components
-`Marker`, `Polyline`, `Polygon`, and `Circle` are overlay components that compose inside `MapView`. Overlay props are collected on the JS side and serialized into descriptor structs passed to the native `HybridMapView` (data-driven architecture).
+`Marker`, `Polyline`, `Polygon`, `Circle`, and `Geojson` are overlay components that compose inside `MapView`. Overlay props are collected on the JS side and serialized into descriptor structs passed to the native `HybridMapView` (data-driven architecture). `Geojson` is converted into marker, polyline, and polygon descriptors before that native pass; invalid GeoJSON is skipped with a development warning.
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.
@@ -112,7 +113,7 @@ Google Maps SDKs are sensitive to marker animation churn. Large viewport refresh
```
User interaction
↓
-React component tree ()
+React component tree ()
↓
MapView collects overlay descriptors + props
↓
diff --git a/docs/geojson.md b/docs/geojson.md
new file mode 100644
index 0000000..c6cce78
--- /dev/null
+++ b/docs/geojson.md
@@ -0,0 +1,72 @@
+# GeoJSON overlays
+
+`Geojson` renders a GeoJSON object as existing map overlays. Conversion happens in JavaScript and reuses the marker, polyline, and polygon descriptor pipeline on iOS and Android.
+
+## Supported geometry
+
+| GeoJSON type | Overlay |
+| -------------------- | ----------------------------- |
+| `Point` | `Marker` |
+| `MultiPoint` | One `Marker` per position |
+| `LineString` | `Polyline` |
+| `MultiLineString` | One `Polyline` per line |
+| `Polygon` | `Polygon` with interior holes |
+| `MultiPolygon` | One `Polygon` per part |
+| `Feature` | Inner geometry |
+| `FeatureCollection` | Each feature |
+| `GeometryCollection` | Each nested geometry |
+
+Coordinates are `[longitude, latitude]`. A third value (altitude) is ignored.
+
+## Style
+
+Component props supply defaults. Feature `properties` override them using simplestyle names:
+
+| Property | Applies to | Notes |
+| ---------------- | ------------- | ------------------------------ |
+| `stroke` | Line, polygon | Hex color |
+| `stroke-width` | Line, polygon | Density-independent pixels |
+| `stroke-opacity` | Line, polygon | Replaces alpha on hex `stroke` |
+| `fill` | Polygon | Hex color |
+| `fill-opacity` | Polygon | Replaces alpha on hex `fill` |
+| `marker-color` | Point | Default marker color |
+| `title` / `name` | Point | Marker title |
+| `zIndex` | All overlays | Drawing order |
+
+Colors follow the library-wide format: `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA` with alpha last. Opacity properties replace the color's alpha, so `fill: '#34C75980'` with `fill-opacity: 0.25` becomes `#34C75940`. Non-hex colors are left unchanged.
+
+Component-level `markerColor` and `zIndex` provide defaults. Feature properties take precedence. `zIndex` applies to every generated Google Maps overlay and to Apple Maps markers; MapKit does not expose shape overlay z-ordering.
+
+`onPress` receives the original `GeojsonFeature`, including `properties`.
+
+## Limits
+
+- Prefer `geojsonToOverlayDescriptors` plus bulk `MapView` overlay props above about 1000 generated overlays.
+- Point styling supports title text and default marker color; custom marker views are not applied.
+- TopoJSON is not parsed. Convert it to GeoJSON first.
+- Invalid GeoJSON does not throw. It is skipped with a development warning.
+
+## Bulk conversion
+
+```tsx
+import { MapView, geojsonToOverlayDescriptors } from 'react-native-better-maps';
+
+const overlays = geojsonToOverlayDescriptors(deliveryZones, {
+ strokeColor: '#FF3B30',
+ fillColor: '#FF3B3044',
+ strokeWidth: 2,
+});
+
+export function DeliveryZonesMap() {
+ return (
+ {
+ console.log(overlays.featuresByOverlayId[id]?.properties);
+ }}
+ />
+ );
+}
+```
diff --git a/example/App.tsx b/example/App.tsx
index 37918fb..09f74a2 100644
--- a/example/App.tsx
+++ b/example/App.tsx
@@ -52,6 +52,7 @@ import {
MAP_SCENARIOS,
type MapScenario,
createCustomMarkerImagesScenario,
+ createScenarioOverlayProps,
CUSTOM_MARKER_IMAGES_SCENARIO_ID,
} from './examples';
@@ -541,20 +542,17 @@ const MapScene = memo(function MapScene({
clusterEnteringAnimation: scenario.advanced?.clusteringEnabled
? animationOption.value
: undefined,
- markers: scenario.markers,
- polylines: scenario.polylines,
- polygons: scenario.polygons,
- circles: scenario.circles,
+ ...createScenarioOverlayProps(
+ scenario,
+ onMarkerPress,
+ onMarkerDragEnd,
+ onOverlayPress,
+ ),
onMapReady,
onClusterPress,
- onMarkerPress,
- onMarkerDragEnd,
onPress,
onPoiPress,
onLongPress,
- onPolylinePress: onOverlayPress,
- onPolygonPress: onOverlayPress,
- onCirclePress: onOverlayPress,
onRegionChange,
onRegionChangeComplete,
};
diff --git a/example/examples/data/delivery-zones.json b/example/examples/data/delivery-zones.json
new file mode 100644
index 0000000..295d20a
--- /dev/null
+++ b/example/examples/data/delivery-zones.json
@@ -0,0 +1,96 @@
+{
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "id": "srodmiescie",
+ "properties": {
+ "name": "Śródmieście",
+ "fill": "#34C759",
+ "fill-opacity": 0.25,
+ "stroke": "#34C759",
+ "stroke-width": 2
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [21.0, 52.238],
+ [21.015, 52.242],
+ [21.028, 52.235],
+ [21.025, 52.225],
+ [21.008, 52.22],
+ [21.0, 52.238]
+ ]
+ ]
+ }
+ },
+ {
+ "type": "Feature",
+ "id": "wola",
+ "properties": {
+ "name": "Wola",
+ "fill": "#007AFF",
+ "fill-opacity": 0.22,
+ "stroke": "#007AFF",
+ "stroke-width": 2
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [20.978, 52.236],
+ [20.995, 52.24],
+ [21.0, 52.232],
+ [20.992, 52.222],
+ [20.976, 52.226],
+ [20.978, 52.236]
+ ]
+ ]
+ }
+ },
+ {
+ "type": "Feature",
+ "id": "courier-run",
+ "properties": {
+ "name": "Courier run",
+ "stroke": "#FF3B30",
+ "stroke-width": 4
+ },
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [20.985, 52.23],
+ [21.0, 52.229],
+ [21.0122, 52.2297],
+ [21.022, 52.232]
+ ]
+ }
+ },
+ {
+ "type": "Feature",
+ "id": "hub",
+ "properties": {
+ "name": "Bistro Central"
+ },
+ "geometry": {
+ "type": "Point",
+ "coordinates": [21.0122, 52.2297]
+ }
+ },
+ {
+ "type": "Feature",
+ "id": "pickups",
+ "properties": {
+ "name": "Pickup"
+ },
+ "geometry": {
+ "type": "MultiPoint",
+ "coordinates": [
+ [20.985, 52.23],
+ [21.022, 52.232]
+ ]
+ }
+ }
+ ]
+}
diff --git a/example/examples/geojson.ts b/example/examples/geojson.ts
new file mode 100644
index 0000000..2720653
--- /dev/null
+++ b/example/examples/geojson.ts
@@ -0,0 +1,24 @@
+import type { GeojsonFeatureCollection } from 'react-native-better-maps';
+import deliveryZonesJson from './data/delivery-zones.json';
+import type { MapScenario } from './types';
+
+const deliveryZones = deliveryZonesJson as GeojsonFeatureCollection;
+
+/** Mixed GeoJSON FeatureCollection: polygons, a route, and pickup points. */
+export const geojsonScenario: MapScenario = {
+ id: 'geojson',
+ name: 'GeoJSON',
+ description: 'Delivery zones from a bundled FeatureCollection',
+ region: {
+ latitude: 52.2297,
+ longitude: 21.0,
+ latitudeDelta: 0.06,
+ longitudeDelta: 0.06,
+ },
+ geojson: deliveryZones,
+ geojsonStyle: {
+ strokeColor: '#FF3B30',
+ fillColor: '#FF3B3044',
+ strokeWidth: 2,
+ },
+};
diff --git a/example/examples/index.ts b/example/examples/index.ts
index c7d8074..5de4ee3 100644
--- a/example/examples/index.ts
+++ b/example/examples/index.ts
@@ -6,13 +6,16 @@ import {
CUSTOM_MARKER_IMAGES_SCENARIO_ID,
} from './customMarkerImages';
import { deliveryZoneScenario } from './deliveryZone';
+import { geojsonScenario } from './geojson';
import { landmarksScenario } from './landmarks';
+import { createScenarioOverlayProps } from './overlaySource';
import { riverRouteScenario } from './riverRoute';
import type { MapScenario } from './types';
export type { MapScenario } from './types';
export {
createCustomMarkerImagesScenario,
+ createScenarioOverlayProps,
CUSTOM_MARKER_IMAGES_SCENARIO_ID,
};
@@ -22,5 +25,6 @@ export const MAP_SCENARIOS: MapScenario[] = [
customMarkerImagesScenario,
riverRouteScenario,
deliveryZoneScenario,
+ geojsonScenario,
advancedFeaturesScenario,
];
diff --git a/example/examples/overlaySource.tsx b/example/examples/overlaySource.tsx
new file mode 100644
index 0000000..7c0fd43
--- /dev/null
+++ b/example/examples/overlaySource.tsx
@@ -0,0 +1,55 @@
+import {
+ Geojson,
+ type Coordinate,
+ type MapViewProps,
+} from 'react-native-better-maps';
+import type { MapScenario } from './types';
+
+type ScenarioOverlayProps = Pick<
+ MapViewProps,
+ | 'markers'
+ | 'polylines'
+ | 'polygons'
+ | 'circles'
+ | 'children'
+ | 'onMarkerPress'
+ | 'onMarkerDragEnd'
+ | 'onPolylinePress'
+ | 'onPolygonPress'
+ | 'onCirclePress'
+>;
+
+export function createScenarioOverlayProps(
+ scenario: MapScenario,
+ onMarkerPress: (id: string) => void,
+ onMarkerDragEnd: (id: string, coordinate: Coordinate) => void,
+ onOverlayPress: (label: string) => void,
+): ScenarioOverlayProps {
+ const geojson = scenario.geojson;
+
+ return {
+ markers: scenario.markers,
+ polylines: scenario.polylines,
+ polygons: scenario.polygons,
+ circles: scenario.circles,
+ children:
+ geojson == null ? undefined : (
+ {
+ const name = feature.properties?.name;
+ onOverlayPress(typeof name === 'string' ? name : 'GeoJSON feature');
+ }}
+ />
+ ),
+ onMarkerPress: scenario.markers != null ? onMarkerPress : undefined,
+ onMarkerDragEnd: scenario.markers != null ? onMarkerDragEnd : undefined,
+ onPolylinePress: scenario.polylines != null ? onOverlayPress : undefined,
+ onPolygonPress: scenario.polygons != null ? onOverlayPress : undefined,
+ onCirclePress: scenario.circles != null ? onOverlayPress : undefined,
+ };
+}
diff --git a/example/examples/types.ts b/example/examples/types.ts
index 8efb2f3..b00afe8 100644
--- a/example/examples/types.ts
+++ b/example/examples/types.ts
@@ -1,4 +1,10 @@
-import type { EdgePadding, MapViewProps, Region } from 'react-native-better-maps';
+import type {
+ EdgePadding,
+ GeojsonInput,
+ GeojsonProps,
+ MapViewProps,
+ Region,
+} from 'react-native-better-maps';
export interface MapScenarioAdvancedOptions {
clusteringEnabled?: boolean;
@@ -20,5 +26,10 @@ export interface MapScenario {
polylines?: NonNullable;
polygons?: NonNullable;
circles?: NonNullable;
+ geojson?: GeojsonInput;
+ geojsonStyle?: Pick<
+ GeojsonProps,
+ 'strokeColor' | 'fillColor' | 'strokeWidth'
+ >;
advanced?: MapScenarioAdvancedOptions;
}
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt
index 7477a47..84ca771 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerDescriptor+DisplayedIdentity.kt
@@ -14,6 +14,7 @@ internal fun MarkerDescriptor.displayedIdentityVersion(): Long =
image?.width,
image?.height,
image?.scale,
+ markerColor,
anchor?.x,
anchor?.y,
centerOffset?.x,
@@ -21,4 +22,5 @@ internal fun MarkerDescriptor.displayedIdentityVersion(): Long =
rotation,
flat,
opacity,
+ zIndex,
)
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt
index 8cd2a94..6249df6 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt
@@ -3,6 +3,7 @@ package com.margelo.nitro.nitromaps
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
+import android.graphics.Color
import android.os.Handler
import android.os.Looper
import android.util.Log
@@ -48,10 +49,12 @@ internal class MarkerIconFactory(
marker.rotation = descriptor.rotation?.toFloat() ?: 0f
marker.isFlat = descriptor.flat == true
marker.alpha = descriptor.opacity?.toFloat() ?: 1f
+ marker.zIndex = descriptor.zIndex?.toFloat() ?: 0f
applyIcon(
marker = marker,
image = descriptor.image,
+ markerColor = descriptor.markerColor,
isMarkerActive = { isMarkerCurrent(key, marker) },
onIconApplied = { applyAnchor(descriptor, marker) },
)
@@ -73,15 +76,24 @@ internal class MarkerIconFactory(
private fun applyIcon(
marker: Marker,
image: MarkerImage?,
+ markerColor: String?,
isMarkerActive: () -> Boolean,
onIconApplied: () -> Unit,
) {
if (image == null) {
- if (isIconApplied(marker, DEFAULT_ICON_KEY)) {
+ val iconKey = markerColor?.let { "$DEFAULT_ICON_KEY:$it" } ?: DEFAULT_ICON_KEY
+ if (isIconApplied(marker, iconKey)) {
return
}
- marker.setIcon(BitmapDescriptorFactory.defaultMarker())
- setApplied(marker, DEFAULT_ICON_KEY)
+ val icon = if (markerColor == null) {
+ BitmapDescriptorFactory.defaultMarker()
+ } else {
+ val hsv = FloatArray(3)
+ Color.colorToHSV(markerColor.toColorInt(Color.RED), hsv)
+ BitmapDescriptorFactory.defaultMarker(hsv[0])
+ }
+ marker.setIcon(icon)
+ setApplied(marker, iconKey)
onIconApplied()
return
}
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/PolygonDescriptor+PolygonOptions.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/PolygonDescriptor+PolygonOptions.kt
index fd9ce29..1219aba 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/PolygonDescriptor+PolygonOptions.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/PolygonDescriptor+PolygonOptions.kt
@@ -7,8 +7,12 @@ fun PolygonDescriptor.toPolygonOptions(): PolygonOptions {
val options = PolygonOptions()
.addAll(coordinates.map { LatLng(it.latitude, it.longitude) })
.strokeWidth((strokeWidth ?: 2.0).toFloat())
+ .zIndex((zIndex ?: 0.0).toFloat())
.clickable(tappable == true)
+ holes?.forEach { hole ->
+ options.addHole(hole.map { LatLng(it.latitude, it.longitude) })
+ }
strokeColor?.let { options.strokeColor(it.toColorInt()) }
fillColor?.let { options.fillColor(it.toColorInt()) }
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/PolylineDescriptor+PolylineOptions.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/PolylineDescriptor+PolylineOptions.kt
index ca7001e..054b3c7 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/PolylineDescriptor+PolylineOptions.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/PolylineDescriptor+PolylineOptions.kt
@@ -7,6 +7,7 @@ fun PolylineDescriptor.toPolylineOptions(): PolylineOptions {
val options = PolylineOptions()
.addAll(coordinates.map { LatLng(it.latitude, it.longitude) })
.width((strokeWidth ?: 4.0).toFloat())
+ .zIndex((zIndex ?: 0.0).toFloat())
.clickable(tappable == true)
strokeColor?.let { options.color(it.toColorInt()) }
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/String+ColorInt.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/String+ColorInt.kt
index ff3bc25..caf4566 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/String+ColorInt.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/String+ColorInt.kt
@@ -1,17 +1,29 @@
package com.margelo.nitro.nitromaps
-import android.graphics.Color
+/**
+ * Parses a hex color string into an Android color int.
+ * Supports `#RGB`, `#RGBA`, `#RRGGBB`, and `#RRGGBBAA`.
+ */
+fun String.toColorInt(fallback: Int = 0xFF000000.toInt()): Int {
+ val digits = trim().removePrefix("#")
-/** Parses a hex color string into an Android color int. */
-fun String.toColorInt(fallback: Int = Color.BLACK): Int {
- val trimmed = trim()
- if (trimmed.isEmpty()) {
- return fallback
+ val expanded = when (digits.length) {
+ 3, 4 -> digits.map { "$it$it" }.joinToString("")
+ 6, 8 -> digits
+ else -> return fallback
}
- return try {
- Color.parseColor(trimmed)
- } catch (_: IllegalArgumentException) {
- fallback
+ val rgba = if (expanded.length == 6) "${expanded}FF" else expanded
+ if (!rgba.all { it.digitToIntOrNull(16) != null }) {
+ return fallback
}
+
+ val value = rgba.toLong(16)
+
+ val alpha = (value and 0xFF).toInt()
+ val red = ((value shr 24) and 0xFF).toInt()
+ val green = ((value shr 16) and 0xFF).toInt()
+ val blue = ((value shr 8) and 0xFF).toInt()
+
+ return (alpha shl 24) or (red shl 16) or (green shl 8) or blue
}
diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDescriptorFixture.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDescriptorFixture.kt
index d2e8f6f..34d8aa7 100644
--- a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDescriptorFixture.kt
+++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDescriptorFixture.kt
@@ -3,11 +3,13 @@ package com.margelo.nitro.nitromaps
internal fun marker(
id: String = "marker-1",
image: MarkerImage? = null,
+ markerColor: String? = null,
anchor: MarkerAnchor? = null,
centerOffset: MarkerPoint? = null,
rotation: Double? = null,
flat: Boolean? = null,
opacity: Double? = null,
+ zIndex: Double? = null,
enteringAnimation: OverlayEnteringAnimationDescriptor? = null,
): MarkerDescriptor {
return MarkerDescriptor(
@@ -18,11 +20,13 @@ internal fun marker(
false,
true,
image,
+ markerColor,
anchor,
centerOffset,
rotation,
flat,
opacity,
+ zIndex,
enteringAnimation,
)
}
diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt
index bbcbff8..ac15b88 100644
--- a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt
+++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt
@@ -15,6 +15,8 @@ class MarkerDisplayedIdentityTest {
),
"rotation" to (marker(rotation = 0.0) to marker(rotation = 45.0)),
"opacity" to (marker(opacity = 1.0) to marker(opacity = 0.4)),
+ "markerColor" to (marker(markerColor = "#FF0000") to marker(markerColor = "#00FF00")),
+ "zIndex" to (marker(zIndex = 1.0) to marker(zIndex = 2.0)),
"anchor" to (
marker(anchor = MarkerAnchor(0.5, 1.0)) to marker(anchor = MarkerAnchor(0.5, 0.5))
),
@@ -48,6 +50,11 @@ class MarkerDisplayedIdentityTest {
arrayOf(marker(opacity = null)).markersFingerprint(),
arrayOf(marker(opacity = 0.0)).markersFingerprint(),
)
+ assertNotEquals(
+ "zIndex",
+ marker(zIndex = null).displayedIdentityVersion(),
+ marker(zIndex = 0.0).displayedIdentityVersion(),
+ )
}
@Test
diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/StringColorIntTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/StringColorIntTest.kt
new file mode 100644
index 0000000..6a9ac60
--- /dev/null
+++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/StringColorIntTest.kt
@@ -0,0 +1,29 @@
+package com.margelo.nitro.nitromaps
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class StringColorIntTest {
+ @Test
+ fun parsesSupportedHexFormatsWithTrailingAlpha() {
+ assertEquals(0xFFFF0000.toInt(), "#F00".toColorInt())
+ assertEquals(0x88FF0000.toInt(), "#F008".toColorInt())
+ assertEquals(0xFFFF0000.toInt(), "#FF0000".toColorInt())
+ assertEquals(0x80FF0000.toInt(), "#FF000080".toColorInt())
+ }
+
+ @Test
+ fun acceptsWhitespaceAndAnOptionalHashPrefix() {
+ assertEquals(0xFF34C759.toInt(), " 34C759 ".toColorInt())
+ }
+
+ @Test
+ fun returnsFallbackForMalformedInput() {
+ val fallback = 0x12345678
+
+ assertEquals(fallback, "".toColorInt(fallback))
+ assertEquals(fallback, "#12".toColorInt(fallback))
+ assertEquals(fallback, "#GG0000".toColorInt(fallback))
+ assertEquals(fallback, "red".toColorInt(fallback))
+ }
+}
diff --git a/package/ios/.gitignore b/package/ios/.gitignore
new file mode 100644
index 0000000..30bcfa4
--- /dev/null
+++ b/package/ios/.gitignore
@@ -0,0 +1 @@
+.build/
diff --git a/package/ios/ColorParser/HexColorComponents.swift b/package/ios/ColorParser/HexColorComponents.swift
new file mode 100644
index 0000000..526dae9
--- /dev/null
+++ b/package/ios/ColorParser/HexColorComponents.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+struct HexColorComponents: Equatable {
+ let red: UInt8
+ let green: UInt8
+ let blue: UInt8
+ let alpha: UInt8
+}
+
+extension String {
+ func toHexColorComponents() -> HexColorComponents? {
+ var hex = trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+
+ if hex.hasPrefix("#") {
+ hex.removeFirst()
+ }
+
+ switch hex.count {
+ case 3, 4:
+ hex = hex.map { "\($0)\($0)" }.joined()
+ case 6, 8:
+ break
+ default:
+ return nil
+ }
+
+ if hex.count == 6 {
+ hex += "FF"
+ }
+
+ guard hex.allSatisfy(\.isHexDigit), let value = UInt32(hex, radix: 16) else {
+ return nil
+ }
+
+ return HexColorComponents(
+ red: UInt8((value & 0xFF00_0000) >> 24),
+ green: UInt8((value & 0x00FF_0000) >> 16),
+ blue: UInt8((value & 0x0000_FF00) >> 8),
+ alpha: UInt8(value & 0x0000_00FF)
+ )
+ }
+}
diff --git a/package/ios/GoogleMapOverlayController.swift b/package/ios/GoogleMapOverlayController.swift
index d033447..6636451 100644
--- a/package/ios/GoogleMapOverlayController.swift
+++ b/package/ios/GoogleMapOverlayController.swift
@@ -10,6 +10,14 @@ final class GoogleMapOverlayController {
// bulk viewport refreshes do not block active Google Maps gestures.
private static let maximumAnimatedMarkersPerDiff = 96
+ private static func nativeZIndex(_ value: Double?) -> Int32 {
+ guard let value, value.isFinite else {
+ return 0
+ }
+ let clamped = min(Double(Int32.max), max(Double(Int32.min), value))
+ return Int32(clamped)
+ }
+
private enum MarkerPayload {
case marker(String)
case cluster(memberIds: [String], region: MKCoordinateRegion)
@@ -284,6 +292,7 @@ final class GoogleMapOverlayController {
marker.title = descriptor.title
marker.snippet = descriptor.subtitle
marker.isDraggable = descriptor.draggable == true
+ marker.zIndex = Self.nativeZIndex(descriptor.zIndex)
marker.userData = MarkerPayload.marker(descriptor.id)
visualApplier.apply(descriptor, to: marker)
case let .cluster(_, coordinate, count, memberIds, region):
@@ -291,6 +300,7 @@ final class GoogleMapOverlayController {
marker.title = nil
marker.snippet = nil
marker.isDraggable = false
+ marker.zIndex = 0
let icon = clusterIcon(count: count)
if marker.icon !== icon {
marker.icon = icon
@@ -387,6 +397,7 @@ final class GoogleMapOverlayController {
polyline.path = descriptor.coordinates.toGMSPath()
polyline.strokeColor = descriptor.strokeColor?.toUIColor(fallback: .systemBlue) ?? .systemBlue
polyline.strokeWidth = CGFloat(descriptor.strokeWidth ?? 4)
+ polyline.zIndex = Self.nativeZIndex(descriptor.zIndex)
polyline.isTappable = descriptor.tappable ?? false
polyline.userData = descriptor.id
}
@@ -399,11 +410,13 @@ final class GoogleMapOverlayController {
private func updatePolygon(_ polygon: GMSPolygon, _ descriptor: PolygonDescriptor) {
polygon.path = descriptor.coordinates.toGMSPath()
+ polygon.holes = descriptor.holes?.map { $0.toGMSPath() }
polygon.strokeColor = descriptor.strokeColor?.toUIColor(fallback: .systemBlue) ?? .systemBlue
polygon.fillColor = descriptor.fillColor?.toUIColor(
fallback: UIColor.systemBlue.withAlphaComponent(0.2)
) ?? UIColor.systemBlue.withAlphaComponent(0.2)
polygon.strokeWidth = CGFloat(descriptor.strokeWidth ?? 2)
+ polygon.zIndex = Self.nativeZIndex(descriptor.zIndex)
polygon.isTappable = descriptor.tappable ?? false
polygon.userData = descriptor.id
}
diff --git a/package/ios/GoogleMarkerVisualApplier.swift b/package/ios/GoogleMarkerVisualApplier.swift
index bfef9ce..e1f4dc2 100644
--- a/package/ios/GoogleMarkerVisualApplier.swift
+++ b/package/ios/GoogleMarkerVisualApplier.swift
@@ -50,9 +50,15 @@ final class GoogleMarkerVisualApplier {
guard let image = descriptor.image else {
cancelPending(state)
- if state.appliedImageToken != Self.defaultIconToken {
- marker.icon = nil
- state.appliedImageToken = Self.defaultIconToken
+ let markerColor = descriptor.markerColor
+ let iconToken = markerColor.map {
+ "\(Self.defaultIconToken):\($0)" as NSString
+ } ?? Self.defaultIconToken
+ if state.appliedImageToken != iconToken {
+ marker.icon = markerColor.map {
+ GMSMarker.markerImage(with: $0.toUIColor(fallback: .systemRed))
+ }
+ state.appliedImageToken = iconToken
}
return
}
diff --git a/package/ios/MapMarkerAnnotation.swift b/package/ios/MapMarkerAnnotation.swift
index 964d50a..97e8d04 100644
--- a/package/ios/MapMarkerAnnotation.swift
+++ b/package/ios/MapMarkerAnnotation.swift
@@ -6,11 +6,13 @@ final class MapMarkerAnnotation: NSObject, MKAnnotation {
var draggable: Bool
var isClusterable: Bool
private(set) var image: MarkerImage?
+ private(set) var markerColor: String?
private(set) var anchor: MarkerAnchor?
private(set) var centerOffset: MarkerPoint?
private(set) var rotation: Double?
private(set) var flat: Bool?
private(set) var opacity: CGFloat
+ private(set) var zIndex: Double?
let enteringAnimation: ResolvedOverlayEnteringAnimation
@objc dynamic var coordinate: CLLocationCoordinate2D
@@ -32,11 +34,13 @@ final class MapMarkerAnnotation: NSObject, MKAnnotation {
draggable = descriptor.draggable ?? false
isClusterable = descriptor.clusterable ?? true
image = descriptor.image
+ markerColor = descriptor.markerColor
anchor = descriptor.anchor
centerOffset = descriptor.centerOffset
rotation = descriptor.rotation
flat = descriptor.flat
opacity = CGFloat(descriptor.opacity ?? 1)
+ zIndex = descriptor.zIndex
}
@discardableResult
@@ -64,30 +68,36 @@ final class MapMarkerAnnotation: NSObject, MKAnnotation {
MarkerImageLoader.cacheKey(for: current) != MarkerImageLoader.cacheKey(for: next)
default: true
}
+ let markerColorChanged = markerColor != descriptor.markerColor
let anchorChanged = anchor?.x != descriptor.anchor?.x || anchor?.y != descriptor.anchor?.y
let centerOffsetChanged = centerOffset?.x != descriptor.centerOffset?.x
|| centerOffset?.y != descriptor.centerOffset?.y
let rotationChanged = rotation != descriptor.rotation
let flatChanged = flat != descriptor.flat
let opacityChanged = opacity != CGFloat(descriptor.opacity ?? 1)
+ let zIndexChanged = zIndex != descriptor.zIndex
image = descriptor.image
+ markerColor = descriptor.markerColor
anchor = descriptor.anchor
centerOffset = descriptor.centerOffset
rotation = descriptor.rotation
flat = descriptor.flat
opacity = CGFloat(descriptor.opacity ?? 1)
+ zIndex = descriptor.zIndex
return titleChanged
|| subtitleChanged
|| draggableChanged
|| clusterableChanged
|| imageChanged
+ || markerColorChanged
|| anchorChanged
|| centerOffsetChanged
|| rotationChanged
|| flatChanged
|| opacityChanged
+ || zIndexChanged
}
func centerOffset(forImageSize imageSize: CGSize) -> CGPoint {
diff --git a/package/ios/MarkerDescriptor+Fingerprint.swift b/package/ios/MarkerDescriptor+Fingerprint.swift
index ea385cb..26ebe04 100644
--- a/package/ios/MarkerDescriptor+Fingerprint.swift
+++ b/package/ios/MarkerDescriptor+Fingerprint.swift
@@ -12,6 +12,7 @@ extension MarkerDescriptor {
hasher.combine(image?.width)
hasher.combine(image?.height)
hasher.combine(image?.scale)
+ hasher.combine(markerColor)
hasher.combine(anchor?.x)
hasher.combine(anchor?.y)
hasher.combine(centerOffset?.x)
@@ -19,6 +20,7 @@ extension MarkerDescriptor {
hasher.combine(rotation)
hasher.combine(flat)
hasher.combine(opacity)
+ hasher.combine(zIndex)
}
func markersDescriptorFingerprint() -> Int {
diff --git a/package/ios/NitroImageAnnotationView.swift b/package/ios/NitroImageAnnotationView.swift
index e514435..4e2aee1 100644
--- a/package/ios/NitroImageAnnotationView.swift
+++ b/package/ios/NitroImageAnnotationView.swift
@@ -24,6 +24,9 @@ final class NitroImageAnnotationView: MKAnnotationView {
isDraggable = marker.draggable
canShowCallout = marker.title != nil || marker.subtitle != nil
alpha = marker.opacity
+ zPriority = marker.zIndex.map {
+ MKAnnotationViewZPriority(rawValue: Float($0))
+ } ?? .defaultUnselected
guard let imageDescriptor = marker.image else {
loadToken = nil
diff --git a/package/ios/NitroPinAnnotationView.swift b/package/ios/NitroPinAnnotationView.swift
index 3a32b1c..9226cab 100644
--- a/package/ios/NitroPinAnnotationView.swift
+++ b/package/ios/NitroPinAnnotationView.swift
@@ -29,6 +29,10 @@ final class NitroPinAnnotationView: MKMarkerAnnotationView {
canShowCallout = marker.title != nil || marker.subtitle != nil
displayPriority = .required
alpha = marker.opacity
+ markerTintColor = marker.markerColor?.toUIColor(fallback: .systemRed)
+ zPriority = marker.zIndex.map {
+ MKAnnotationViewZPriority(rawValue: Float($0))
+ } ?? .defaultUnselected
layoutIfNeeded()
let pinSize = bounds.size == .zero ? Self.defaultPinSize : bounds.size
diff --git a/package/ios/Package.swift b/package/ios/Package.swift
new file mode 100644
index 0000000..bb5275c
--- /dev/null
+++ b/package/ios/Package.swift
@@ -0,0 +1,19 @@
+// swift-tools-version: 6.0
+
+import PackageDescription
+
+let package = Package(
+ name: "NitroMapsColorParser",
+ platforms: [.macOS(.v13)],
+ targets: [
+ .target(
+ name: "NitroMapsColorParser",
+ path: "ColorParser"
+ ),
+ .testTarget(
+ name: "NitroMapsColorParserTests",
+ dependencies: ["NitroMapsColorParser"],
+ path: "Tests"
+ ),
+ ]
+)
diff --git a/package/ios/PolygonDescriptor+MKPolygon.swift b/package/ios/PolygonDescriptor+MKPolygon.swift
index 9bc2fc4..88919cd 100644
--- a/package/ios/PolygonDescriptor+MKPolygon.swift
+++ b/package/ios/PolygonDescriptor+MKPolygon.swift
@@ -3,6 +3,14 @@ import MapKit
extension PolygonDescriptor {
func toMKPolygon() -> MKPolygon {
let coordinates = coordinates.toCLLocationCoordinates()
- return MKPolygon(coordinates: coordinates, count: coordinates.count)
+ let interiorPolygons = holes?.map { hole in
+ let coordinates = hole.toCLLocationCoordinates()
+ return MKPolygon(coordinates: coordinates, count: coordinates.count)
+ }
+ return MKPolygon(
+ coordinates: coordinates,
+ count: coordinates.count,
+ interiorPolygons: interiorPolygons
+ )
}
}
diff --git a/package/ios/String+HexColor.swift b/package/ios/String+HexColor.swift
index 1b27f61..d1780a2 100644
--- a/package/ios/String+HexColor.swift
+++ b/package/ios/String+HexColor.swift
@@ -2,42 +2,17 @@ import UIKit
extension String {
/// Parses a hex color string into a `UIColor`.
- /// Supports `#RGB`, `#RRGGBB`, and `#AARRGGBB`.
+ /// Supports `#RGB`, `#RGBA`, `#RRGGBB`, and `#RRGGBBAA`.
func toUIColor(fallback: UIColor = .black) -> UIColor {
- var hex = trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
-
- if hex.hasPrefix("#") {
- hex.removeFirst()
- }
-
- switch hex.count {
- case 3:
- let red = hex[hex.startIndex]
- let green = hex[hex.index(hex.startIndex, offsetBy: 1)]
- let blue = hex[hex.index(hex.startIndex, offsetBy: 2)]
- hex = "\(red)\(red)\(green)\(green)\(blue)\(blue)FF"
- case 6:
- hex += "FF"
- case 8:
- break
- default:
- return fallback
- }
-
- guard hex.count == 8 else {
+ guard let components = toHexColorComponents() else {
return fallback
}
- var value: UInt64 = 0
- guard Scanner(string: hex).scanHexInt64(&value) else {
- return fallback
- }
-
- let alpha = CGFloat((value & 0xFF00_0000) >> 24) / 255
- let red = CGFloat((value & 0x00FF_0000) >> 16) / 255
- let green = CGFloat((value & 0x0000_FF00) >> 8) / 255
- let blue = CGFloat(value & 0x0000_00FF) / 255
-
- return UIColor(red: red, green: green, blue: blue, alpha: alpha)
+ return UIColor(
+ red: CGFloat(components.red) / 255,
+ green: CGFloat(components.green) / 255,
+ blue: CGFloat(components.blue) / 255,
+ alpha: CGFloat(components.alpha) / 255
+ )
}
}
diff --git a/package/ios/Tests/HexColorComponentsTests.swift b/package/ios/Tests/HexColorComponentsTests.swift
new file mode 100644
index 0000000..7732d35
--- /dev/null
+++ b/package/ios/Tests/HexColorComponentsTests.swift
@@ -0,0 +1,33 @@
+import Testing
+
+@testable import NitroMapsColorParser
+
+@Test
+func parsesSupportedHexFormatsWithTrailingAlpha() {
+ #expect("#F00".toHexColorComponents() == rgba(255, 0, 0, 255))
+ #expect("#F008".toHexColorComponents() == rgba(255, 0, 0, 136))
+ #expect("#FF0000".toHexColorComponents() == rgba(255, 0, 0, 255))
+ #expect("#FF000080".toHexColorComponents() == rgba(255, 0, 0, 128))
+}
+
+@Test
+func acceptsWhitespaceAndAnOptionalHashPrefix() {
+ #expect(" 34C759 ".toHexColorComponents() == rgba(52, 199, 89, 255))
+}
+
+@Test
+func rejectsMalformedInput() {
+ #expect("".toHexColorComponents() == nil)
+ #expect("#12".toHexColorComponents() == nil)
+ #expect("#GG0000".toHexColorComponents() == nil)
+ #expect("red".toHexColorComponents() == nil)
+}
+
+private func rgba(
+ _ red: UInt8,
+ _ green: UInt8,
+ _ blue: UInt8,
+ _ alpha: UInt8
+) -> HexColorComponents {
+ HexColorComponents(red: red, green: green, blue: blue, alpha: alpha)
+}
diff --git a/package/iosTests/GoogleMarkerVisualApplierTests.swift b/package/iosTests/GoogleMarkerVisualApplierTests.swift
index bf55581..801aca7 100644
--- a/package/iosTests/GoogleMarkerVisualApplierTests.swift
+++ b/package/iosTests/GoogleMarkerVisualApplierTests.swift
@@ -81,11 +81,13 @@ private func markerDescriptor(image: MarkerImage, anchor: MarkerAnchor) -> Marke
draggable: nil,
clusterable: nil,
image: image,
+ markerColor: nil,
anchor: anchor,
centerOffset: nil,
rotation: nil,
flat: nil,
opacity: nil,
+ zIndex: nil,
enteringAnimation: nil
)
}
diff --git a/package/package.json b/package/package.json
index 396c31c..b83a0ff 100644
--- a/package/package.json
+++ b/package/package.json
@@ -33,7 +33,9 @@
"!**/__tests__",
"!**/__fixtures__",
"!**/__mocks__",
- "!android/src/test"
+ "!android/src/test",
+ "!ios/Package.swift",
+ "!ios/Tests"
],
"scripts": {
"prebuild": "bun run nitrogen",
@@ -42,8 +44,8 @@
"build:plugin": "bun run clean:plugin && tsc --project tsconfig.plugin.json",
"prepack": "cp ../README.md ./README.md && bun run build",
"postpack": "rm ./README.md",
- "test": "jest",
- "test:ci": "jest --runInBand --ci --watchman=false",
+ "test": "jest && bun test src/geojson src/overlays",
+ "test:ci": "jest --runInBand --ci --watchman=false && bun test src/geojson src/overlays",
"typecheck": "tsc --noEmit && tsc --project tsconfig.plugin.json --noEmit",
"typecheck:provider-types": "tsc --noEmit -p tsconfig.type-tests.json",
"nitrogen": "nitrogen && bun ./scripts/patch-nitrogen-generated.mjs",
diff --git a/package/react-native-better-maps.podspec b/package/react-native-better-maps.podspec
index ecb5fa8..a0dfe50 100644
--- a/package/react-native-better-maps.podspec
+++ b/package/react-native-better-maps.podspec
@@ -39,6 +39,10 @@ Pod::Spec.new do |s|
'ios/**/*.{m,mm}',
'cpp/**/*.{hpp,cpp}',
]
+ s.exclude_files = [
+ 'ios/Package.swift',
+ 'ios/Tests/**/*',
+ ]
s.frameworks = 'MapKit', 'CoreLocation'
diff --git a/package/scripts/patch-nitrogen-generated.mjs b/package/scripts/patch-nitrogen-generated.mjs
index 10ed57a..feaa8e7 100644
--- a/package/scripts/patch-nitrogen-generated.mjs
+++ b/package/scripts/patch-nitrogen-generated.mjs
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const packageDir = join(scriptDir, '..');
+const generatedSwiftDir = join(packageDir, 'nitrogen/generated/ios/swift');
function replaceOnce(filePath, from, to) {
const source = readFileSync(filePath, 'utf8');
@@ -24,7 +25,10 @@ function replaceOnce(filePath, from, to) {
}
replaceOnce(
- join(packageDir, 'nitrogen/generated/shared/c++/views/HybridMapViewComponent.cpp'),
+ join(
+ packageDir,
+ 'nitrogen/generated/shared/c++/views/HybridMapViewComponent.cpp',
+ ),
` const std::shared_ptr& constProps = concreteShadowNode.getConcreteSharedProps();
const std::shared_ptr& props = std::const_pointer_cast(constProps);
`,
@@ -34,10 +38,59 @@ replaceOnce(
);
replaceOnce(
- join(packageDir, 'nitrogen/generated/shared/c++/views/HybridMapViewComponent.hpp'),
+ join(
+ packageDir,
+ 'nitrogen/generated/shared/c++/views/HybridMapViewComponent.hpp',
+ ),
` HybridMapViewState(const HybridMapViewState& /* previousState */, folly::dynamic /* data */) {}
`,
` HybridMapViewState(const HybridMapViewState& previousState, folly::dynamic /* data */):
_props(previousState.getProps()) {}
`,
);
+
+for (const fileName of [
+ 'Func_void_std__vector_std__string__Coordinate.swift',
+ 'HybridMapViewSpec_cxx.swift',
+ 'PolygonDescriptor.swift',
+ 'PolylineDescriptor.swift',
+]) {
+ replaceOnce(
+ join(generatedSwiftDir, fileName),
+ 'import NitroModules\n',
+ 'import CxxStdlib\nimport NitroModules\n',
+ );
+}
+
+for (const fileName of [
+ 'PolygonDescriptor.swift',
+ 'PolylineDescriptor.swift',
+]) {
+ replaceOnce(
+ join(generatedSwiftDir, fileName),
+ ' return self.__coordinates.map({ __item in __item })\n',
+ ` let count = Int(self.__coordinates.size())
+ return (0.. {
+ test('returns the fallback when the property is missing', () => {
+ expect(resolvePaintColor(null, 'fill', '#007AFF')).toBe('#007AFF');
+ expect(resolvePaintColor({ stroke: '#FF3B30' }, 'fill', '#007AFF')).toBe(
+ '#007AFF',
+ );
+ });
+
+ test('returns the color unchanged when opacity is absent', () => {
+ expect(resolvePaintColor({ fill: '#34C75980' }, 'fill', undefined)).toBe(
+ '#34C75980',
+ );
+ });
+
+ test('applies feature opacity to the fallback color', () => {
+ expect(resolvePaintColor({ 'fill-opacity': 0.25 }, 'fill', '#007AFF')).toBe(
+ '#007AFF40',
+ );
+ });
+
+ test('appends alpha to #RGB and #RRGGBB', () => {
+ expect(
+ resolvePaintColor({ fill: '#0F0', 'fill-opacity': 1 }, 'fill', undefined),
+ ).toBe('#00FF00FF');
+ expect(
+ resolvePaintColor(
+ { fill: '#34C759', 'fill-opacity': 0.25 },
+ 'fill',
+ undefined,
+ ),
+ ).toBe('#34C75940');
+ });
+
+ test('replaces alpha on #RGBA and #RRGGBBAA', () => {
+ expect(
+ resolvePaintColor(
+ { fill: '#0F08', 'fill-opacity': 0.25 },
+ 'fill',
+ undefined,
+ ),
+ ).toBe('#00FF0040');
+ expect(
+ resolvePaintColor(
+ { fill: '#34C75980', 'fill-opacity': 0.25 },
+ 'fill',
+ undefined,
+ ),
+ ).toBe('#34C75940');
+ });
+
+ test('leaves non-hex colors unchanged', () => {
+ expect(
+ resolvePaintColor(
+ { fill: 'red', 'fill-opacity': 0.25 },
+ 'fill',
+ undefined,
+ ),
+ ).toBe('red');
+ });
+});
+
+describe('resolveZIndex', () => {
+ test('prefers a finite feature value over the fallback', () => {
+ expect(resolveZIndex({ zIndex: 7 }, 2)).toBe(7);
+ expect(resolveZIndex({ zIndex: '4' }, 2)).toBe(4);
+ });
+
+ test('uses the fallback when the feature value is invalid', () => {
+ expect(resolveZIndex({ zIndex: 'top' }, 2)).toBe(2);
+ expect(resolveZIndex(null, undefined)).toBeUndefined();
+ });
+});
diff --git a/package/src/geojson/__tests__/geojsonToDescriptors.test.ts b/package/src/geojson/__tests__/geojsonToDescriptors.test.ts
new file mode 100644
index 0000000..655cb83
--- /dev/null
+++ b/package/src/geojson/__tests__/geojsonToDescriptors.test.ts
@@ -0,0 +1,344 @@
+import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
+import { geojsonToOverlayDescriptors } from '../geojsonToDescriptors';
+import type {
+ GeojsonFeature,
+ GeojsonFeatureCollection,
+ GeojsonPosition,
+} from '../../types/geojson';
+
+const warnSpy = spyOn(console, 'warn');
+
+beforeEach(() => {
+ (globalThis as { __DEV__?: boolean }).__DEV__ = true;
+});
+
+afterEach(() => {
+ warnSpy.mockClear();
+});
+
+const warsawPoint = [21.0122, 52.2297] as const;
+
+function closedRing(
+ positions: ReadonlyArray,
+): GeojsonPosition[] {
+ const first = positions[0];
+ if (first == null) {
+ return [];
+ }
+
+ return [
+ ...positions.map((position): GeojsonPosition => [
+ position[0],
+ position[1],
+ ...position.slice(2),
+ ]),
+ [first[0], first[1], ...first.slice(2)],
+ ];
+}
+
+describe('geojsonToOverlayDescriptors', () => {
+ test('converts Point, LineString, and Polygon features', () => {
+ const collection: GeojsonFeatureCollection = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'hub',
+ properties: {
+ name: 'Bistro Central',
+ 'marker-color': '#FF9500',
+ zIndex: 9,
+ },
+ geometry: { type: 'Point', coordinates: [...warsawPoint] },
+ },
+ {
+ type: 'Feature',
+ id: 'route',
+ properties: { stroke: '#FF3B30', 'stroke-width': 4, zIndex: 8 },
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [21.005, 52.225],
+ [21.0122, 52.2297],
+ [21.02, 52.235],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ id: 'zone',
+ properties: {
+ fill: '#34C759',
+ 'fill-opacity': 0.25,
+ stroke: '#34C759',
+ zIndex: 7,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ closedRing([
+ [21.0, 52.238],
+ [21.015, 52.242],
+ [21.028, 52.235],
+ [21.008, 52.22],
+ ]),
+ ],
+ },
+ },
+ ],
+ };
+
+ const overlays = geojsonToOverlayDescriptors(collection, {
+ id: 'delivery',
+ markerColor: '#007AFF',
+ zIndex: 1,
+ });
+
+ expect(overlays.markers).toEqual([
+ {
+ id: 'delivery:hub:marker-0',
+ coordinate: { latitude: 52.2297, longitude: 21.0122 },
+ title: 'Bistro Central',
+ markerColor: '#FF9500',
+ zIndex: 9,
+ },
+ ]);
+ expect(overlays.polylines).toHaveLength(1);
+ expect(overlays.polylines[0]?.id).toBe('delivery:route:polyline-0');
+ expect(overlays.polylines[0]?.strokeColor).toBe('#FF3B30');
+ expect(overlays.polylines[0]?.strokeWidth).toBe(4);
+ expect(overlays.polylines[0]?.zIndex).toBe(8);
+ expect(overlays.polygons).toHaveLength(1);
+ expect(overlays.polygons[0]?.id).toBe('delivery:zone:polygon-0');
+ expect(overlays.polygons[0]?.fillColor).toBe('#34C75940');
+ expect(overlays.polygons[0]?.zIndex).toBe(7);
+ expect(overlays.polygons[0]?.coordinates).toHaveLength(4);
+ expect(overlays.featuresByOverlayId['delivery:hub:marker-0']?.id).toBe(
+ 'hub',
+ );
+ });
+
+ test('expands MultiPoint, MultiLineString, and MultiPolygon', () => {
+ const overlays = geojsonToOverlayDescriptors({
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'stops',
+ properties: { title: 'Stops' },
+ geometry: {
+ type: 'MultiPoint',
+ coordinates: [
+ [21.0, 52.22],
+ [21.02, 52.24],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: null,
+ geometry: {
+ type: 'MultiLineString',
+ coordinates: [
+ [
+ [21.0, 52.22],
+ [21.01, 52.23],
+ ],
+ [
+ [21.02, 52.24],
+ [21.03, 52.25],
+ ],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: null,
+ geometry: {
+ type: 'MultiPolygon',
+ coordinates: [
+ [
+ closedRing([
+ [21.0, 52.22],
+ [21.01, 52.22],
+ [21.01, 52.23],
+ ]),
+ ],
+ [
+ closedRing([
+ [21.02, 52.24],
+ [21.03, 52.24],
+ [21.03, 52.25],
+ ]),
+ closedRing([
+ [21.022, 52.242],
+ [21.024, 52.242],
+ [21.024, 52.244],
+ ]),
+ ],
+ ],
+ },
+ },
+ ],
+ });
+
+ expect(overlays.markers).toHaveLength(2);
+ expect(overlays.markers[0]?.id).toBe('geojson:stops:marker-0');
+ expect(overlays.markers[1]?.id).toBe('geojson:stops:marker-1');
+ expect(overlays.polylines).toHaveLength(2);
+ expect(overlays.polygons).toHaveLength(2);
+ expect(overlays.polygons[0]?.holes).toBeUndefined();
+ expect(overlays.polygons[1]?.holes).toEqual([
+ [
+ { latitude: 52.242, longitude: 21.022 },
+ { latitude: 52.242, longitude: 21.024 },
+ { latitude: 52.244, longitude: 21.024 },
+ ],
+ ]);
+ });
+
+ test('flattens GeometryCollection onto the original feature', () => {
+ const overlays = geojsonToOverlayDescriptors({
+ type: 'Feature',
+ id: 'mixed',
+ properties: { name: 'Mixed' },
+ geometry: {
+ type: 'GeometryCollection',
+ geometries: [
+ { type: 'Point', coordinates: [21.0, 52.22] },
+ {
+ type: 'LineString',
+ coordinates: [
+ [21.0, 52.22],
+ [21.01, 52.23],
+ ],
+ },
+ ],
+ },
+ });
+
+ expect(overlays.markers).toHaveLength(1);
+ expect(overlays.polylines).toHaveLength(1);
+ expect(
+ overlays.featuresByOverlayId[overlays.markers[0]?.id ?? '']?.id,
+ ).toBe('mixed');
+ expect(
+ overlays.featuresByOverlayId[overlays.polylines[0]?.id ?? '']?.properties,
+ ).toEqual({ name: 'Mixed' });
+ });
+
+ test('ignores altitude and preserves polygon holes', () => {
+ const source: GeojsonFeature = {
+ type: 'Feature',
+ id: 'zone',
+ properties: null,
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ closedRing([
+ [21.0, 52.22, 10],
+ [21.02, 52.22, 10],
+ [21.02, 52.24, 10],
+ [21.0, 52.24, 10],
+ ]),
+ closedRing([
+ [21.005, 52.225],
+ [21.01, 52.225],
+ [21.01, 52.23],
+ ]),
+ ],
+ },
+ };
+ const overlays = geojsonToOverlayDescriptors(source);
+
+ expect(overlays.polygons).toHaveLength(1);
+ expect(overlays.polygons[0]?.coordinates).toEqual([
+ { latitude: 52.22, longitude: 21.0 },
+ { latitude: 52.22, longitude: 21.02 },
+ { latitude: 52.24, longitude: 21.02 },
+ { latitude: 52.24, longitude: 21.0 },
+ ]);
+ expect(overlays.polygons[0]?.holes).toEqual([
+ [
+ { latitude: 52.225, longitude: 21.005 },
+ { latitude: 52.225, longitude: 21.01 },
+ { latitude: 52.23, longitude: 21.01 },
+ ],
+ ]);
+ expect(overlays.featuresByOverlayId['geojson:zone:polygon-0']).toBe(source);
+ expect(
+ source.geometry?.type === 'Polygon' && source.geometry.coordinates[0],
+ ).toEqual([
+ [21.0, 52.22, 10],
+ [21.02, 52.22, 10],
+ [21.02, 52.24, 10],
+ [21.0, 52.24, 10],
+ [21.0, 52.22, 10],
+ ]);
+ });
+
+ test('applies component style defaults when properties are absent', () => {
+ const overlays = geojsonToOverlayDescriptors(
+ {
+ type: 'Feature',
+ properties: null,
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [21.0, 52.22],
+ [21.01, 52.23],
+ ],
+ },
+ },
+ {
+ strokeColor: '#007AFF',
+ markerColor: '#FF9500',
+ strokeWidth: 3,
+ tappable: true,
+ title: 'Fallback',
+ zIndex: 5,
+ },
+ );
+ const pointOverlays = geojsonToOverlayDescriptors(
+ {
+ type: 'Point',
+ coordinates: [21.0, 52.22],
+ },
+ {
+ markerColor: '#FF9500',
+ title: 'Fallback',
+ zIndex: 5,
+ },
+ );
+
+ expect(overlays.polylines[0]?.strokeColor).toBe('#007AFF');
+ expect(overlays.polylines[0]?.strokeWidth).toBe(3);
+ expect(overlays.polylines[0]?.tappable).toBe(true);
+ expect(overlays.polylines[0]?.zIndex).toBe(5);
+ expect(pointOverlays.markers[0]?.markerColor).toBe('#FF9500');
+ expect(pointOverlays.markers[0]?.title).toBe('Fallback');
+ expect(pointOverlays.markers[0]?.zIndex).toBe(5);
+ });
+
+ test('skips invalid geometry without throwing', () => {
+ const overlays = geojsonToOverlayDescriptors({
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ properties: null,
+ geometry: { type: 'Point', coordinates: [21] },
+ },
+ {
+ type: 'Feature',
+ properties: null,
+ geometry: null,
+ },
+ ],
+ });
+
+ expect(overlays.markers).toEqual([]);
+ expect(overlays.polylines).toEqual([]);
+ expect(overlays.polygons).toEqual([]);
+ });
+});
diff --git a/package/src/geojson/__tests__/parseGeojson.test.ts b/package/src/geojson/__tests__/parseGeojson.test.ts
new file mode 100644
index 0000000..ceb2806
--- /dev/null
+++ b/package/src/geojson/__tests__/parseGeojson.test.ts
@@ -0,0 +1,163 @@
+import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
+import { parseGeojsonFeatures } from '../parseGeojson';
+import type {
+ GeojsonFeatureCollection,
+ GeojsonPolygon,
+} from '../../types/geojson';
+
+const warnSpy = spyOn(console, 'warn');
+
+beforeEach(() => {
+ (globalThis as { __DEV__?: boolean }).__DEV__ = true;
+});
+
+afterEach(() => {
+ warnSpy.mockClear();
+});
+
+describe('parseGeojsonFeatures', () => {
+ test('parses a FeatureCollection', () => {
+ const collection: GeojsonFeatureCollection = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'hub',
+ properties: { name: 'Hub' },
+ geometry: {
+ type: 'Point',
+ coordinates: [21.0122, 52.2297],
+ },
+ },
+ ],
+ };
+
+ const features = parseGeojsonFeatures(collection);
+
+ expect(features).toHaveLength(1);
+ expect(features[0]?.id).toBe('hub');
+ expect(features[0]?.geometry?.type).toBe('Point');
+ });
+
+ test('rejects a FeatureCollection with an invalid bbox', () => {
+ const features = parseGeojsonFeatures({
+ type: 'FeatureCollection',
+ bbox: [],
+ features: [
+ {
+ type: 'Feature',
+ properties: null,
+ geometry: { type: 'Point', coordinates: [21, 52] },
+ },
+ ],
+ });
+
+ expect(features).toEqual([]);
+ expect(warnSpy).toHaveBeenCalled();
+ });
+
+ test('parses a JSON string', () => {
+ const features = parseGeojsonFeatures(
+ JSON.stringify({
+ type: 'Feature',
+ properties: null,
+ geometry: { type: 'Point', coordinates: [21, 52] },
+ }),
+ );
+
+ expect(features).toHaveLength(1);
+ expect(features[0]?.geometry?.type).toBe('Point');
+ });
+
+ test('wraps a bare geometry as a Feature', () => {
+ const polygon: GeojsonPolygon = {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [21, 52],
+ [21.01, 52],
+ [21.01, 52.01],
+ [21, 52.01],
+ [21, 52],
+ ],
+ ],
+ };
+
+ const features = parseGeojsonFeatures(polygon);
+
+ expect(features).toHaveLength(1);
+ expect(features[0]?.properties).toBeNull();
+ expect(features[0]?.geometry?.type).toBe('Polygon');
+ });
+
+ test('returns an empty list for invalid JSON', () => {
+ const features = parseGeojsonFeatures('{not-json');
+
+ expect(features).toEqual([]);
+ expect(warnSpy).toHaveBeenCalled();
+ });
+
+ test('returns an empty list for an unknown type', () => {
+ const features = parseGeojsonFeatures({ type: 'TopoJSON' });
+
+ expect(features).toEqual([]);
+ expect(warnSpy).toHaveBeenCalled();
+ });
+
+ test('skips invalid features in a FeatureCollection', () => {
+ const features = parseGeojsonFeatures({
+ type: 'FeatureCollection',
+ features: [
+ { type: 'Feature', properties: null, geometry: { type: 'Nope' } },
+ {
+ type: 'Feature',
+ properties: { name: 'ok' },
+ geometry: { type: 'Point', coordinates: [21, 52] },
+ },
+ ],
+ });
+
+ expect(features).toHaveLength(1);
+ expect(features[0]?.properties).toEqual({ name: 'ok' });
+ });
+
+ test('rejects invalid coordinates at the parser boundary', () => {
+ const features = parseGeojsonFeatures({
+ type: 'Feature',
+ properties: null,
+ geometry: { type: 'Point', coordinates: [21] },
+ });
+
+ expect(features).toEqual([]);
+ expect(warnSpy).toHaveBeenCalled();
+ });
+
+ test('rejects a MultiGeometry when any member is invalid', () => {
+ const features = parseGeojsonFeatures({
+ type: 'Feature',
+ properties: null,
+ geometry: {
+ type: 'MultiPoint',
+ coordinates: [[21, 52], [21]],
+ },
+ });
+
+ expect(features).toEqual([]);
+ });
+
+ test('preserves a valid source Feature', () => {
+ const source: GeojsonFeatureCollection['features'][number] = {
+ type: 'Feature',
+ id: 'source',
+ properties: { name: 'Source' },
+ geometry: {
+ type: 'Point',
+ coordinates: [21, 52, 10],
+ },
+ };
+
+ const features = parseGeojsonFeatures(source);
+
+ expect(features[0]).toBe(source);
+ });
+});
diff --git a/package/src/geojson/__tests__/warnGeojson.test.ts b/package/src/geojson/__tests__/warnGeojson.test.ts
new file mode 100644
index 0000000..d38f041
--- /dev/null
+++ b/package/src/geojson/__tests__/warnGeojson.test.ts
@@ -0,0 +1,47 @@
+import { afterEach, describe, expect, spyOn, test } from 'bun:test';
+import { warnGeojson } from '../warnGeojson';
+
+const warnSpy = spyOn(console, 'warn');
+const previousDev = (globalThis as { __DEV__?: boolean }).__DEV__;
+
+function restoreDevFlag(): void {
+ const globalDev = globalThis as { __DEV__?: boolean };
+ if (previousDev === undefined) {
+ delete globalDev.__DEV__;
+ return;
+ }
+
+ globalDev.__DEV__ = previousDev;
+}
+
+afterEach(() => {
+ warnSpy.mockClear();
+ restoreDevFlag();
+});
+
+describe('warnGeojson', () => {
+ test('warns when __DEV__ is true', () => {
+ (globalThis as { __DEV__?: boolean }).__DEV__ = true;
+
+ warnGeojson('hello');
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy.mock.calls[0]?.[0]).toContain('hello');
+ });
+
+ test('stays silent when __DEV__ is false', () => {
+ (globalThis as { __DEV__?: boolean }).__DEV__ = false;
+
+ warnGeojson('hello');
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ test('stays silent when __DEV__ is missing', () => {
+ delete (globalThis as { __DEV__?: boolean }).__DEV__;
+
+ warnGeojson('hello');
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/package/src/geojson/geojsonCoordinates.ts b/package/src/geojson/geojsonCoordinates.ts
new file mode 100644
index 0000000..80ef519
--- /dev/null
+++ b/package/src/geojson/geojsonCoordinates.ts
@@ -0,0 +1,17 @@
+import type { Coordinate } from '../types/coordinate';
+import type { GeojsonPosition } from '../types/geojson';
+
+export function positionToCoordinate(position: GeojsonPosition): Coordinate {
+ return {
+ longitude: position[0]!,
+ latitude: position[1]!,
+ };
+}
+
+export function lineToCoordinates(positions: GeojsonPosition[]): Coordinate[] {
+ return positions.map(positionToCoordinate);
+}
+
+export function ringToCoordinates(positions: GeojsonPosition[]): Coordinate[] {
+ return positions.slice(0, -1).map(positionToCoordinate);
+}
diff --git a/package/src/geojson/geojsonStyle.ts b/package/src/geojson/geojsonStyle.ts
new file mode 100644
index 0000000..3aaadc3
--- /dev/null
+++ b/package/src/geojson/geojsonStyle.ts
@@ -0,0 +1,142 @@
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === 'string' && value.length > 0;
+}
+
+function asFiniteNumber(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+
+ if (typeof value === 'string' && value.length > 0) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+
+ return undefined;
+}
+
+function isHexDigit(value: string): boolean {
+ return value.length === 1 && /[0-9A-Fa-f]/.test(value);
+}
+
+function expandShortChannel(channel: string | undefined): string | undefined {
+ if (channel == null || !isHexDigit(channel)) {
+ return undefined;
+ }
+
+ return `${channel}${channel}`.toUpperCase();
+}
+
+function rgbFromHexDigits(digits: string): string | undefined {
+ switch (digits.length) {
+ case 3:
+ case 4: {
+ const red = expandShortChannel(digits[0]);
+ const green = expandShortChannel(digits[1]);
+ const blue = expandShortChannel(digits[2]);
+ if (red == null || green == null || blue == null) {
+ return undefined;
+ }
+
+ return `${red}${green}${blue}`;
+ }
+ case 6:
+ case 8: {
+ const rgb = digits.slice(0, 6).toUpperCase();
+ for (const character of rgb) {
+ if (!isHexDigit(character)) {
+ return undefined;
+ }
+ }
+
+ return rgb;
+ }
+ default:
+ return undefined;
+ }
+}
+
+function applyHexOpacity(color: string, opacity: number): string {
+ const trimmed = color.trim();
+ const digits = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed;
+ const rgb = rgbFromHexDigits(digits);
+ if (rgb == null) {
+ return color;
+ }
+
+ const clamped = Math.min(1, Math.max(0, opacity));
+ const alpha = Math.round(clamped * 255)
+ .toString(16)
+ .padStart(2, '0')
+ .toUpperCase();
+
+ return `#${rgb}${alpha}`;
+}
+
+function readProperty(
+ properties: Record | null,
+ key: string,
+): unknown {
+ if (properties == null) {
+ return undefined;
+ }
+
+ return properties[key];
+}
+
+export function resolvePaintColor(
+ properties: Record | null,
+ propertyName: string,
+ fallback: string | undefined,
+): string | undefined {
+ const color = readProperty(properties, propertyName);
+ const resolvedColor = isNonEmptyString(color) ? color : fallback;
+
+ const opacity = asFiniteNumber(
+ readProperty(properties, `${propertyName}-opacity`),
+ );
+ if (resolvedColor == null || opacity == null) {
+ return resolvedColor;
+ }
+
+ return applyHexOpacity(resolvedColor, opacity);
+}
+
+export function resolveStrokeWidth(
+ properties: Record | null,
+ fallback: number | undefined,
+): number | undefined {
+ const width = asFiniteNumber(readProperty(properties, 'stroke-width'));
+ if (width == null) {
+ return fallback;
+ }
+
+ return width;
+}
+
+export function resolveZIndex(
+ properties: Record | null,
+ fallback: number | undefined,
+): number | undefined {
+ const zIndex = asFiniteNumber(readProperty(properties, 'zIndex'));
+ return zIndex ?? fallback;
+}
+
+export function resolveMarkerTitle(
+ properties: Record | null,
+ fallback: string | undefined,
+): string | undefined {
+ const title = readProperty(properties, 'title');
+ if (isNonEmptyString(title)) {
+ return title;
+ }
+
+ const name = readProperty(properties, 'name');
+ if (isNonEmptyString(name)) {
+ return name;
+ }
+
+ return fallback;
+}
diff --git a/package/src/geojson/geojsonToDescriptors.ts b/package/src/geojson/geojsonToDescriptors.ts
new file mode 100644
index 0000000..d0b2842
--- /dev/null
+++ b/package/src/geojson/geojsonToDescriptors.ts
@@ -0,0 +1,233 @@
+import type {
+ MarkerDescriptor,
+ PolygonDescriptor,
+ PolylineDescriptor,
+} from '../native/specs/overlays';
+import type { Coordinate } from '../types/coordinate';
+import type {
+ GeojsonFeature,
+ GeojsonGeometry,
+ GeojsonInput,
+ GeojsonOverlayDescriptors,
+ GeojsonPosition,
+ GeojsonToOverlayOptions,
+} from '../types/geojson';
+import {
+ lineToCoordinates,
+ positionToCoordinate,
+ ringToCoordinates,
+} from './geojsonCoordinates';
+import {
+ resolveMarkerTitle,
+ resolvePaintColor,
+ resolveStrokeWidth,
+ resolveZIndex,
+} from './geojsonStyle';
+import { parseGeojsonFeatures } from './parseGeojson';
+import { warnGeojson } from './warnGeojson';
+
+const DEFAULT_LAYER_ID = 'geojson';
+const LARGE_OVERLAY_WARN_LIMIT = 1000;
+
+interface ConversionState {
+ layerId: string;
+ options: GeojsonToOverlayOptions;
+ markers: MarkerDescriptor[];
+ polylines: PolylineDescriptor[];
+ polygons: PolygonDescriptor[];
+ featuresByOverlayId: Record;
+ markerIndex: number;
+ polylineIndex: number;
+ polygonIndex: number;
+}
+
+function overlayId(
+ layerId: string,
+ feature: GeojsonFeature,
+ kind: 'marker' | 'polyline' | 'polygon',
+ index: number,
+): string {
+ const featureId = feature.id;
+ if (
+ (typeof featureId === 'string' && featureId.length > 0) ||
+ (typeof featureId === 'number' && Number.isFinite(featureId))
+ ) {
+ return `${layerId}:${featureId}:${kind}-${index}`;
+ }
+
+ return `${layerId}:${kind}-${index}`;
+}
+
+function convertPoint(
+ state: ConversionState,
+ feature: GeojsonFeature,
+ coordinate: Coordinate,
+): void {
+ const id = overlayId(state.layerId, feature, 'marker', state.markerIndex);
+ state.markerIndex += 1;
+ state.markers.push({
+ id,
+ coordinate,
+ title: resolveMarkerTitle(feature.properties, state.options.title),
+ markerColor: resolvePaintColor(
+ feature.properties,
+ 'marker-color',
+ state.options.markerColor,
+ ),
+ zIndex: resolveZIndex(feature.properties, state.options.zIndex),
+ });
+ state.featuresByOverlayId[id] = feature;
+}
+
+function convertLine(
+ state: ConversionState,
+ feature: GeojsonFeature,
+ coordinates: Coordinate[],
+): void {
+ const id = overlayId(state.layerId, feature, 'polyline', state.polylineIndex);
+ state.polylineIndex += 1;
+ state.polylines.push({
+ id,
+ coordinates,
+ strokeColor: resolvePaintColor(
+ feature.properties,
+ 'stroke',
+ state.options.strokeColor,
+ ),
+ strokeWidth: resolveStrokeWidth(
+ feature.properties,
+ state.options.strokeWidth,
+ ),
+ zIndex: resolveZIndex(feature.properties, state.options.zIndex),
+ tappable: state.options.tappable,
+ });
+ state.featuresByOverlayId[id] = feature;
+}
+
+function convertPolygon(
+ state: ConversionState,
+ feature: GeojsonFeature,
+ rings: GeojsonPosition[][],
+): void {
+ const exterior = rings[0];
+ if (exterior == null) {
+ return;
+ }
+
+ const holes = rings.slice(1).map(ringToCoordinates);
+ const id = overlayId(state.layerId, feature, 'polygon', state.polygonIndex);
+ state.polygonIndex += 1;
+ state.polygons.push({
+ id,
+ coordinates: ringToCoordinates(exterior),
+ holes: holes.length > 0 ? holes : undefined,
+ fillColor: resolvePaintColor(
+ feature.properties,
+ 'fill',
+ state.options.fillColor,
+ ),
+ strokeColor: resolvePaintColor(
+ feature.properties,
+ 'stroke',
+ state.options.strokeColor,
+ ),
+ strokeWidth: resolveStrokeWidth(
+ feature.properties,
+ state.options.strokeWidth,
+ ),
+ zIndex: resolveZIndex(feature.properties, state.options.zIndex),
+ tappable: state.options.tappable,
+ });
+ state.featuresByOverlayId[id] = feature;
+}
+
+function convertGeometry(
+ state: ConversionState,
+ feature: GeojsonFeature,
+ geometry: GeojsonGeometry,
+): void {
+ switch (geometry.type) {
+ case 'Point':
+ convertPoint(state, feature, positionToCoordinate(geometry.coordinates));
+ return;
+ case 'MultiPoint':
+ for (const position of geometry.coordinates) {
+ convertPoint(state, feature, positionToCoordinate(position));
+ }
+ return;
+ case 'LineString':
+ convertLine(state, feature, lineToCoordinates(geometry.coordinates));
+ return;
+ case 'MultiLineString':
+ for (const line of geometry.coordinates) {
+ convertLine(state, feature, lineToCoordinates(line));
+ }
+ return;
+ case 'Polygon':
+ convertPolygon(state, feature, geometry.coordinates);
+ return;
+ case 'MultiPolygon':
+ for (const polygon of geometry.coordinates) {
+ convertPolygon(state, feature, polygon);
+ }
+ return;
+ case 'GeometryCollection':
+ for (const child of geometry.geometries) {
+ convertGeometry(state, feature, child);
+ }
+ return;
+ default: {
+ const exhaustive: never = geometry;
+ return exhaustive;
+ }
+ }
+}
+
+/**
+ * Converts GeoJSON into marker, polyline, and polygon descriptors.
+ *
+ * Prefer the GeoJSON overlay child for typical use. Use this helper with bulk
+ * `markers` / `polylines` / `polygons` props for large FeatureCollections.
+ *
+ * Invalid geometry is skipped with a development warning. Altitude (Z) values
+ * are ignored when rendering.
+ */
+export function geojsonToOverlayDescriptors(
+ geojson: GeojsonInput,
+ options: GeojsonToOverlayOptions = {},
+): GeojsonOverlayDescriptors {
+ const layerId =
+ options.id != null && options.id.length > 0 ? options.id : DEFAULT_LAYER_ID;
+ const state: ConversionState = {
+ layerId,
+ options,
+ markers: [],
+ polylines: [],
+ polygons: [],
+ featuresByOverlayId: {},
+ markerIndex: 0,
+ polylineIndex: 0,
+ polygonIndex: 0,
+ };
+
+ for (const feature of parseGeojsonFeatures(geojson)) {
+ if (feature.geometry != null) {
+ convertGeometry(state, feature, feature.geometry);
+ }
+ }
+
+ const overlayCount =
+ state.markers.length + state.polylines.length + state.polygons.length;
+ if (overlayCount > LARGE_OVERLAY_WARN_LIMIT) {
+ warnGeojson(
+ `Converted ${overlayCount} overlays. FeatureCollections above ${LARGE_OVERLAY_WARN_LIMIT} overlays may stutter; prefer bulk MapView overlay props or simplify the data.`,
+ );
+ }
+
+ return {
+ markers: state.markers,
+ polylines: state.polylines,
+ polygons: state.polygons,
+ featuresByOverlayId: state.featuresByOverlayId,
+ };
+}
diff --git a/package/src/geojson/parseGeojson.ts b/package/src/geojson/parseGeojson.ts
new file mode 100644
index 0000000..04d203c
--- /dev/null
+++ b/package/src/geojson/parseGeojson.ts
@@ -0,0 +1,211 @@
+import type {
+ GeojsonFeature,
+ GeojsonGeometry,
+ GeojsonPosition,
+} from '../types/geojson';
+import { warnGeojson } from './warnGeojson';
+
+function isRecord(value: unknown): value is Record {
+ return value != null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function readType(value: Record): string | undefined {
+ return typeof value.type === 'string' ? value.type : undefined;
+}
+
+function isFiniteNumber(value: unknown): value is number {
+ return typeof value === 'number' && Number.isFinite(value);
+}
+
+function isNumberArray(value: unknown): value is number[] {
+ return Array.isArray(value) && value.every(isFiniteNumber);
+}
+
+function isBbox(value: unknown): value is number[] {
+ return isNumberArray(value) && value.length >= 4 && value.length % 2 === 0;
+}
+
+function isPosition(value: unknown): value is GeojsonPosition {
+ return isNumberArray(value) && value.length >= 2;
+}
+
+function isPositionArray(
+ value: unknown,
+ minimumLength: number,
+): value is GeojsonPosition[] {
+ return (
+ Array.isArray(value) &&
+ value.length >= minimumLength &&
+ value.every(isPosition)
+ );
+}
+
+function positionsEqual(
+ left: GeojsonPosition,
+ right: GeojsonPosition,
+): boolean {
+ return (
+ left.length === right.length &&
+ left.every((coordinate, index) => coordinate === right[index])
+ );
+}
+
+function isLinearRing(value: unknown): value is GeojsonPosition[] {
+ if (!isPositionArray(value, 4)) {
+ return false;
+ }
+
+ const first = value[0];
+ const last = value[value.length - 1];
+ return first != null && last != null && positionsEqual(first, last);
+}
+
+function isPolygonCoordinates(value: unknown): value is GeojsonPosition[][] {
+ return Array.isArray(value) && value.length > 0 && value.every(isLinearRing);
+}
+
+function hasValidBbox(value: Record): boolean {
+ return !('bbox' in value) || isBbox(value.bbox);
+}
+
+function isGeojsonGeometry(value: unknown): value is GeojsonGeometry {
+ if (!isRecord(value) || !hasValidBbox(value)) {
+ return false;
+ }
+
+ switch (readType(value)) {
+ case 'Point':
+ return isPosition(value.coordinates);
+ case 'MultiPoint':
+ return isPositionArray(value.coordinates, 1);
+ case 'LineString':
+ return isPositionArray(value.coordinates, 2);
+ case 'MultiLineString':
+ return (
+ Array.isArray(value.coordinates) &&
+ value.coordinates.length > 0 &&
+ value.coordinates.every((line) => isPositionArray(line, 2))
+ );
+ case 'Polygon':
+ return isPolygonCoordinates(value.coordinates);
+ case 'MultiPolygon':
+ return (
+ Array.isArray(value.coordinates) &&
+ value.coordinates.length > 0 &&
+ value.coordinates.every(isPolygonCoordinates)
+ );
+ case 'GeometryCollection':
+ return (
+ Array.isArray(value.geometries) &&
+ value.geometries.every(isGeojsonGeometry)
+ );
+ default:
+ return false;
+ }
+}
+
+function hasValidFeatureId(value: Record): boolean {
+ return (
+ !('id' in value) ||
+ (typeof value.id === 'string' && value.id.length > 0) ||
+ (typeof value.id === 'number' && Number.isFinite(value.id))
+ );
+}
+
+function isGeojsonFeature(value: unknown): value is GeojsonFeature {
+ if (
+ !isRecord(value) ||
+ readType(value) !== 'Feature' ||
+ !hasValidBbox(value) ||
+ !hasValidFeatureId(value)
+ ) {
+ return false;
+ }
+
+ const properties = value.properties;
+ if (properties !== null && !isRecord(properties)) {
+ return false;
+ }
+
+ const geometry = value.geometry;
+ return geometry === null || isGeojsonGeometry(geometry);
+}
+
+function parseFeature(value: unknown): GeojsonFeature | undefined {
+ if (!isGeojsonFeature(value)) {
+ warnGeojson('Skipping invalid Feature.');
+ return undefined;
+ }
+
+ return value;
+}
+
+function wrapGeometry(geometry: GeojsonGeometry): GeojsonFeature {
+ return {
+ type: 'Feature',
+ properties: null,
+ geometry,
+ };
+}
+
+function parseGeojsonObject(value: unknown): GeojsonFeature[] {
+ if (!isRecord(value)) {
+ warnGeojson('GeoJSON input must be an object or JSON string.');
+ return [];
+ }
+
+ switch (readType(value)) {
+ case 'FeatureCollection': {
+ if (!hasValidBbox(value)) {
+ warnGeojson('Skipping invalid FeatureCollection.');
+ return [];
+ }
+
+ if (!Array.isArray(value.features)) {
+ warnGeojson('FeatureCollection.features must be an array.');
+ return [];
+ }
+
+ const features: GeojsonFeature[] = [];
+ for (const item of value.features) {
+ const feature = parseFeature(item);
+ if (feature != null) {
+ features.push(feature);
+ }
+ }
+ return features;
+ }
+ case 'Feature': {
+ const feature = parseFeature(value);
+ return feature == null ? [] : [feature];
+ }
+ case 'Point':
+ case 'MultiPoint':
+ case 'LineString':
+ case 'MultiLineString':
+ case 'Polygon':
+ case 'MultiPolygon':
+ case 'GeometryCollection':
+ if (!isGeojsonGeometry(value)) {
+ warnGeojson('Skipping invalid GeoJSON geometry.');
+ return [];
+ }
+ return [wrapGeometry(value)];
+ default:
+ warnGeojson(`Unsupported GeoJSON type "${readType(value) ?? ''}".`);
+ return [];
+ }
+}
+
+export function parseGeojsonFeatures(input: unknown): GeojsonFeature[] {
+ if (typeof input === 'string') {
+ try {
+ return parseGeojsonObject(JSON.parse(input) as unknown);
+ } catch {
+ warnGeojson('Failed to parse GeoJSON string as JSON.');
+ return [];
+ }
+ }
+
+ return parseGeojsonObject(input);
+}
diff --git a/package/src/geojson/warnGeojson.ts b/package/src/geojson/warnGeojson.ts
new file mode 100644
index 0000000..bfc13a8
--- /dev/null
+++ b/package/src/geojson/warnGeojson.ts
@@ -0,0 +1,7 @@
+export function warnGeojson(message: string): void {
+ if ((globalThis as { __DEV__?: boolean }).__DEV__ !== true) {
+ return;
+ }
+
+ console.warn(`[react-native-better-maps] Geojson: ${message}`);
+}
diff --git a/package/src/hooks/useCollectedOverlays.ts b/package/src/hooks/useCollectedOverlays.ts
index f25a4bf..8c43536 100644
--- a/package/src/hooks/useCollectedOverlays.ts
+++ b/package/src/hooks/useCollectedOverlays.ts
@@ -6,6 +6,7 @@ import type {
PolygonDescriptor,
PolylineDescriptor,
} from '../native/specs/overlays';
+import type { GeojsonProps } from '../types/geojson';
import type {
CircleProps,
MarkerProps,
@@ -13,19 +14,25 @@ import type {
PolylineProps,
} from '../types/overlays';
import { Circle } from '../components/Circle';
+import { Geojson } from '../components/Geojson';
import { Marker } from '../components/Marker';
import { Polygon } from '../components/Polygon';
import { Polyline } from '../components/Polyline';
-import type { OverlayComponentType, OverlayTypeName } from '../overlays/overlayType';
+import { collectGeojsonOverlays } from '../overlays/collectGeojsonOverlays';
+import {
+ resolveOverlayId,
+ tappableFromPress,
+ type OverlayCallbacks,
+ type OverlayCollectorState,
+} from '../overlays/overlayCollect';
+import type {
+ OverlayComponentType,
+ OverlayTypeName,
+} from '../overlays/overlayType';
import { OverlayType, overlayCallbackKey } from '../overlays/overlayType';
import { resolveMarkerImage } from '../overlays/resolveMarkerImage';
import { normalizeEnteringAnimation } from '../utils/enteringAnimation';
-interface OverlayCallbacks {
- onPress?: () => void;
- onDragEnd?: (coordinate: MarkerProps['coordinate']) => void;
-}
-
export interface CollectedOverlays {
markers: MarkerDescriptor[];
polylines: PolylineDescriptor[];
@@ -39,18 +46,6 @@ export interface CollectedOverlays {
hasCirclePress: boolean;
}
-function resolveOverlayId(
- providedId: string | undefined,
- type: string,
- index: number,
-): string {
- if (providedId != null && providedId.length > 0) {
- return providedId;
- }
-
- return `${type}-${index}`;
-}
-
function isOverlayChild(
child: ReactElement,
overlayType: OverlayTypeName,
@@ -66,36 +61,12 @@ function isOverlayChild(
return child.type === component;
}
-interface OverlayCollectorState {
- registry: Map;
- markers: MarkerDescriptor[];
- polylines: PolylineDescriptor[];
- polygons: PolygonDescriptor[];
- circles: CircleDescriptor[];
- markerIndex: number;
- polylineIndex: number;
- polygonIndex: number;
- circleIndex: number;
- hasMarkerPress: boolean;
- hasMarkerDragEnd: boolean;
- hasPolylinePress: boolean;
- hasPolygonPress: boolean;
- hasCirclePress: boolean;
-}
-
interface OverlayCollector {
overlayType: OverlayTypeName;
component: unknown;
collect: (child: ReactElement, state: OverlayCollectorState) => void;
}
-function tappableFromPress(
- onPress: (() => void) | undefined,
- tappable: boolean | undefined,
-): boolean | undefined {
- return onPress != null ? (tappable ?? true) : tappable;
-}
-
const overlayCollectors: OverlayCollector[] = [
{
overlayType: OverlayType.Marker,
@@ -147,7 +118,9 @@ const overlayCollectors: OverlayCollector[] = [
strokeWidth: props.strokeWidth,
tappable: tappableFromPress(props.onPress, props.tappable),
});
- state.registry.set(overlayCallbackKey(OverlayType.Polyline, id), { onPress: props.onPress });
+ state.registry.set(overlayCallbackKey(OverlayType.Polyline, id), {
+ onPress: props.onPress,
+ });
if (props.onPress != null) {
state.hasPolylinePress = true;
}
@@ -169,7 +142,9 @@ const overlayCollectors: OverlayCollector[] = [
strokeWidth: props.strokeWidth,
tappable: tappableFromPress(props.onPress, props.tappable),
});
- state.registry.set(overlayCallbackKey(OverlayType.Polygon, id), { onPress: props.onPress });
+ state.registry.set(overlayCallbackKey(OverlayType.Polygon, id), {
+ onPress: props.onPress,
+ });
if (props.onPress != null) {
state.hasPolygonPress = true;
}
@@ -192,12 +167,21 @@ const overlayCollectors: OverlayCollector[] = [
strokeWidth: props.strokeWidth,
tappable: tappableFromPress(props.onPress, props.tappable),
});
- state.registry.set(overlayCallbackKey(OverlayType.Circle, id), { onPress: props.onPress });
+ state.registry.set(overlayCallbackKey(OverlayType.Circle, id), {
+ onPress: props.onPress,
+ });
if (props.onPress != null) {
state.hasCirclePress = true;
}
},
},
+ {
+ overlayType: OverlayType.Geojson,
+ component: Geojson,
+ collect: (child, state) => {
+ collectGeojsonOverlays(child.props as GeojsonProps, state);
+ },
+ },
];
export function useCollectedOverlays(children: ReactNode): CollectedOverlays {
@@ -214,6 +198,7 @@ export function useCollectedOverlays(children: ReactNode): CollectedOverlays {
polylineIndex: 0,
polygonIndex: 0,
circleIndex: 0,
+ geojsonIndex: 0,
hasMarkerPress: false,
hasMarkerDragEnd: false,
hasPolylinePress: false,
@@ -227,7 +212,9 @@ export function useCollectedOverlays(children: ReactNode): CollectedOverlays {
}
for (const collector of overlayCollectors) {
- if (!isOverlayChild(child, collector.overlayType, collector.component)) {
+ if (
+ !isOverlayChild(child, collector.overlayType, collector.component)
+ ) {
continue;
}
diff --git a/package/src/index.ts b/package/src/index.ts
index b421da2..be56c29 100644
--- a/package/src/index.ts
+++ b/package/src/index.ts
@@ -1,4 +1,12 @@
-export { MapView, Marker, Polyline, Polygon, Circle } from './components';
+export {
+ MapView,
+ Marker,
+ Polyline,
+ Polygon,
+ Circle,
+ Geojson,
+} from './components';
+export { geojsonToOverlayDescriptors } from './geojson/geojsonToDescriptors';
export type {
Coordinate,
@@ -27,6 +35,13 @@ export type {
PolylineProps,
PolygonProps,
CircleProps,
+ GeojsonFeature,
+ GeojsonFeatureCollection,
+ GeojsonGeometry,
+ GeojsonInput,
+ GeojsonOverlayDescriptors,
+ GeojsonProps,
+ GeojsonToOverlayOptions,
MapViewRef,
} from './types';
diff --git a/package/src/native/specs/overlays.ts b/package/src/native/specs/overlays.ts
index a83dae7..fe516c1 100644
--- a/package/src/native/specs/overlays.ts
+++ b/package/src/native/specs/overlays.ts
@@ -71,6 +71,9 @@ export interface MarkerDescriptor {
/** Custom marker image. */
image?: MarkerImage;
+ /** Color applied to the default marker when no custom image is set. */
+ markerColor?: string;
+
/** Anchor point on the image relative to the coordinate (default bottom-center). */
anchor?: MarkerAnchor;
@@ -86,6 +89,9 @@ export interface MarkerDescriptor {
/** Opacity from 0 to 1. */
opacity?: number;
+ /** Drawing order relative to other map overlays. */
+ zIndex?: number;
+
/** Entering animation override for this marker. */
enteringAnimation?: OverlayEnteringAnimationDescriptor;
}
@@ -106,6 +112,9 @@ export interface PolylineDescriptor {
/** Stroke width in density-independent pixels. */
strokeWidth?: number;
+ /** Drawing order relative to other map overlays. */
+ zIndex?: number;
+
/** Whether the polyline is tappable. */
tappable?: boolean;
}
@@ -117,9 +126,12 @@ export interface PolygonDescriptor {
/** Unique identifier for the polygon. */
id: string;
- /** Ordered list of coordinates forming the polygon boundary. */
+ /** Ordered list of coordinates forming the polygon exterior boundary. */
coordinates: Coordinate[];
+ /** Interior polygon boundaries that remain unfilled. */
+ holes?: Coordinate[][];
+
/** Fill color in hex format (e.g. '#FF000080'). */
fillColor?: string;
@@ -129,6 +141,9 @@ export interface PolygonDescriptor {
/** Stroke width in density-independent pixels. */
strokeWidth?: number;
+ /** Drawing order relative to other map overlays. */
+ zIndex?: number;
+
/** Whether the polygon is tappable. */
tappable?: boolean;
}
diff --git a/package/src/overlays/__tests__/collectGeojsonOverlays.test.ts b/package/src/overlays/__tests__/collectGeojsonOverlays.test.ts
new file mode 100644
index 0000000..d149176
--- /dev/null
+++ b/package/src/overlays/__tests__/collectGeojsonOverlays.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, mock, test } from 'bun:test';
+import type {
+ GeojsonFeatureCollection,
+ GeojsonProps,
+} from '../../types/geojson';
+import { collectGeojsonOverlays } from '../collectGeojsonOverlays';
+import type { OverlayCollectorState } from '../overlayCollect';
+import { OverlayType, overlayCallbackKey } from '../overlayType';
+
+function createState(): OverlayCollectorState {
+ return {
+ registry: new Map(),
+ markers: [],
+ polylines: [],
+ polygons: [],
+ circles: [],
+ markerIndex: 0,
+ polylineIndex: 0,
+ polygonIndex: 0,
+ circleIndex: 0,
+ geojsonIndex: 0,
+ hasMarkerPress: false,
+ hasMarkerDragEnd: false,
+ hasPolylinePress: false,
+ hasPolygonPress: false,
+ hasCirclePress: false,
+ };
+}
+
+const collection: GeojsonFeatureCollection = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'point',
+ properties: { name: 'Point' },
+ geometry: { type: 'Point', coordinates: [21, 52] },
+ },
+ {
+ type: 'Feature',
+ id: 'line',
+ properties: null,
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [21, 52],
+ [21.1, 52.1],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ id: 'polygon',
+ properties: null,
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [21, 52],
+ [21.1, 52],
+ [21.1, 52.1],
+ [21, 52],
+ ],
+ ],
+ },
+ },
+ ],
+};
+
+describe('collectGeojsonOverlays', () => {
+ test('routes generated overlay presses to their source features', () => {
+ const state = createState();
+ const onPress = mock>(() => {});
+
+ collectGeojsonOverlays(
+ {
+ id: 'layer',
+ geojson: collection,
+ markerColor: '#FF9500',
+ onPress,
+ zIndex: 4,
+ },
+ state,
+ );
+
+ expect(state.markers).toHaveLength(1);
+ expect(state.polylines).toHaveLength(1);
+ expect(state.polygons).toHaveLength(1);
+ expect(state.polylines[0]?.tappable).toBe(true);
+ expect(state.polygons[0]?.tappable).toBe(true);
+ expect(state.markers[0]?.markerColor).toBe('#FF9500');
+ expect(state.markers[0]?.zIndex).toBe(4);
+ expect(state.polylines[0]?.zIndex).toBe(4);
+ expect(state.polygons[0]?.zIndex).toBe(4);
+ expect(state.hasMarkerPress).toBe(true);
+ expect(state.hasPolylinePress).toBe(true);
+ expect(state.hasPolygonPress).toBe(true);
+
+ const cases = [
+ [OverlayType.Marker, 'layer:point:marker-0', collection.features[0]],
+ [OverlayType.Polyline, 'layer:line:polyline-0', collection.features[1]],
+ [OverlayType.Polygon, 'layer:polygon:polygon-0', collection.features[2]],
+ ] as const;
+
+ for (const [type, id, feature] of cases) {
+ state.registry.get(overlayCallbackKey(type, id))?.onPress?.();
+ expect(onPress.mock.calls.at(-1)?.[0]).toBe(feature);
+ }
+ });
+
+ test('respects explicit tappable without installing press handlers', () => {
+ const state = createState();
+
+ collectGeojsonOverlays(
+ {
+ geojson: collection,
+ tappable: true,
+ },
+ state,
+ );
+
+ expect(state.polylines[0]?.tappable).toBe(true);
+ expect(state.polygons[0]?.tappable).toBe(true);
+ expect(state.registry.size).toBe(0);
+ expect(state.hasMarkerPress).toBe(false);
+ expect(state.hasPolylinePress).toBe(false);
+ expect(state.hasPolygonPress).toBe(false);
+ });
+
+ test('keeps explicit tappable false when onPress is present', () => {
+ const state = createState();
+
+ collectGeojsonOverlays(
+ {
+ geojson: collection,
+ tappable: false,
+ onPress: () => {},
+ },
+ state,
+ );
+
+ expect(state.polylines[0]?.tappable).toBe(false);
+ expect(state.polygons[0]?.tappable).toBe(false);
+ expect(state.registry.size).toBe(3);
+ });
+});
diff --git a/package/src/overlays/__tests__/normalizeMarkerDescriptors.test.ts b/package/src/overlays/__tests__/normalizeMarkerDescriptors.test.ts
index 22ae880..921fc4d 100644
--- a/package/src/overlays/__tests__/normalizeMarkerDescriptors.test.ts
+++ b/package/src/overlays/__tests__/normalizeMarkerDescriptors.test.ts
@@ -27,6 +27,8 @@ const baseDescriptor: MarkerDescriptor = {
id: 'marker-1',
coordinate: { latitude: 37.7749, longitude: -122.4194 },
title: 'Test',
+ markerColor: '#FF9500',
+ zIndex: 3,
};
describe('normalizeMarkerDescriptors', () => {
diff --git a/package/src/overlays/collectGeojsonOverlays.ts b/package/src/overlays/collectGeojsonOverlays.ts
new file mode 100644
index 0000000..640cc9f
--- /dev/null
+++ b/package/src/overlays/collectGeojsonOverlays.ts
@@ -0,0 +1,96 @@
+import { geojsonToOverlayDescriptors } from '../geojson/geojsonToDescriptors';
+import type {
+ GeojsonFeature,
+ GeojsonOverlayDescriptors,
+ GeojsonProps,
+} from '../types/geojson';
+import {
+ tappableFromPress,
+ resolveOverlayId,
+ type OverlayCollectorState,
+} from './overlayCollect';
+import { OverlayType, overlayCallbackKey } from './overlayType';
+import type { OverlayTypeName } from './overlayType';
+
+function bindFeaturePress(
+ state: OverlayCollectorState,
+ overlayType: OverlayTypeName,
+ overlayId: string,
+ feature: GeojsonFeature | undefined,
+ onFeaturePress: GeojsonProps['onPress'],
+): void {
+ if (onFeaturePress == null || feature == null) {
+ return;
+ }
+
+ state.registry.set(overlayCallbackKey(overlayType, overlayId), {
+ onPress: () => {
+ onFeaturePress(feature);
+ },
+ });
+}
+
+function mergeConvertedOverlays(
+ state: OverlayCollectorState,
+ converted: GeojsonOverlayDescriptors,
+ onFeaturePress: GeojsonProps['onPress'],
+): void {
+ state.markers.push(...converted.markers);
+ state.polylines.push(...converted.polylines);
+ state.polygons.push(...converted.polygons);
+
+ for (const marker of converted.markers) {
+ bindFeaturePress(
+ state,
+ OverlayType.Marker,
+ marker.id,
+ converted.featuresByOverlayId[marker.id],
+ onFeaturePress,
+ );
+ }
+ for (const polyline of converted.polylines) {
+ bindFeaturePress(
+ state,
+ OverlayType.Polyline,
+ polyline.id,
+ converted.featuresByOverlayId[polyline.id],
+ onFeaturePress,
+ );
+ }
+ for (const polygon of converted.polygons) {
+ bindFeaturePress(
+ state,
+ OverlayType.Polygon,
+ polygon.id,
+ converted.featuresByOverlayId[polygon.id],
+ onFeaturePress,
+ );
+ }
+
+ if (onFeaturePress != null) {
+ state.hasMarkerPress ||= converted.markers.length > 0;
+ state.hasPolylinePress ||= converted.polylines.length > 0;
+ state.hasPolygonPress ||= converted.polygons.length > 0;
+ }
+}
+
+export function collectGeojsonOverlays(
+ props: GeojsonProps,
+ state: OverlayCollectorState,
+): void {
+ const layerId = resolveOverlayId(props.id, 'geojson', state.geojsonIndex);
+ state.geojsonIndex += 1;
+
+ const converted = geojsonToOverlayDescriptors(props.geojson, {
+ id: layerId,
+ strokeColor: props.strokeColor,
+ fillColor: props.fillColor,
+ markerColor: props.markerColor,
+ strokeWidth: props.strokeWidth,
+ tappable: tappableFromPress(props.onPress, props.tappable),
+ title: props.title,
+ zIndex: props.zIndex,
+ });
+
+ mergeConvertedOverlays(state, converted, props.onPress);
+}
diff --git a/package/src/overlays/normalizeMarkerDescriptors.ts b/package/src/overlays/normalizeMarkerDescriptors.ts
index d45783f..ad78867 100644
--- a/package/src/overlays/normalizeMarkerDescriptors.ts
+++ b/package/src/overlays/normalizeMarkerDescriptors.ts
@@ -13,11 +13,13 @@ function normalizeDescriptor(descriptor: PublicMarkerDescriptor): MarkerDescript
clusterable: descriptor.clusterable,
image:
descriptor.image != null ? resolveMarkerImage(descriptor.image) : undefined,
+ markerColor: descriptor.markerColor,
anchor: descriptor.anchor,
centerOffset: descriptor.centerOffset,
rotation: descriptor.rotation,
flat: descriptor.flat,
opacity: descriptor.opacity,
+ zIndex: descriptor.zIndex,
enteringAnimation: normalizeEnteringAnimation(descriptor.enteringAnimation),
};
}
@@ -35,11 +37,13 @@ function descriptorsEqual(
left.draggable === right.draggable &&
left.clusterable === right.clusterable &&
left.image === right.image &&
+ left.markerColor === right.markerColor &&
left.anchor === right.anchor &&
left.centerOffset === right.centerOffset &&
left.rotation === right.rotation &&
left.flat === right.flat &&
left.opacity === right.opacity &&
+ left.zIndex === right.zIndex &&
left.enteringAnimation === right.enteringAnimation
);
}
diff --git a/package/src/overlays/overlayCollect.ts b/package/src/overlays/overlayCollect.ts
new file mode 100644
index 0000000..aab67a3
--- /dev/null
+++ b/package/src/overlays/overlayCollect.ts
@@ -0,0 +1,49 @@
+import type {
+ CircleDescriptor,
+ MarkerDescriptor,
+ PolygonDescriptor,
+ PolylineDescriptor,
+} from '../native/specs/overlays';
+import type { MarkerProps } from '../types/overlays';
+
+export interface OverlayCallbacks {
+ onPress?: () => void;
+ onDragEnd?: (coordinate: MarkerProps['coordinate']) => void;
+}
+
+export interface OverlayCollectorState {
+ registry: Map;
+ markers: MarkerDescriptor[];
+ polylines: PolylineDescriptor[];
+ polygons: PolygonDescriptor[];
+ circles: CircleDescriptor[];
+ markerIndex: number;
+ polylineIndex: number;
+ polygonIndex: number;
+ circleIndex: number;
+ geojsonIndex: number;
+ hasMarkerPress: boolean;
+ hasMarkerDragEnd: boolean;
+ hasPolylinePress: boolean;
+ hasPolygonPress: boolean;
+ hasCirclePress: boolean;
+}
+
+export function resolveOverlayId(
+ providedId: string | undefined,
+ type: string,
+ index: number,
+): string {
+ if (providedId != null && providedId.length > 0) {
+ return providedId;
+ }
+
+ return `${type}-${index}`;
+}
+
+export function tappableFromPress(
+ onPress: unknown,
+ tappable: boolean | undefined,
+): boolean | undefined {
+ return onPress != null ? (tappable ?? true) : tappable;
+}
diff --git a/package/src/overlays/overlayType.ts b/package/src/overlays/overlayType.ts
index 8909f77..62260e9 100644
--- a/package/src/overlays/overlayType.ts
+++ b/package/src/overlays/overlayType.ts
@@ -9,6 +9,7 @@ export const OverlayType = {
Polyline: 'NitroMaps.Polyline',
Polygon: 'NitroMaps.Polygon',
Circle: 'NitroMaps.Circle',
+ Geojson: 'NitroMaps.Geojson',
} as const;
export type OverlayTypeName = (typeof OverlayType)[keyof typeof OverlayType];
@@ -17,9 +18,6 @@ export interface OverlayComponentType {
overlayType?: OverlayTypeName;
}
-export function overlayCallbackKey(
- type: OverlayTypeName,
- id: string,
-): string {
+export function overlayCallbackKey(type: OverlayTypeName, id: string): string {
return `${type}:${id}`;
}
diff --git a/package/src/types/geojson.ts b/package/src/types/geojson.ts
new file mode 100644
index 0000000..5306da9
--- /dev/null
+++ b/package/src/types/geojson.ts
@@ -0,0 +1,160 @@
+import type {
+ MarkerDescriptor,
+ PolygonDescriptor,
+ PolylineDescriptor,
+} from '../native/specs/overlays';
+
+/** GeoJSON position as `[longitude, latitude]`, optionally with ignored altitude. */
+export type GeojsonPosition = number[];
+
+export interface GeojsonPoint {
+ type: 'Point';
+ coordinates: GeojsonPosition;
+ bbox?: number[];
+}
+
+export interface GeojsonMultiPoint {
+ type: 'MultiPoint';
+ coordinates: GeojsonPosition[];
+ bbox?: number[];
+}
+
+export interface GeojsonLineString {
+ type: 'LineString';
+ coordinates: GeojsonPosition[];
+ bbox?: number[];
+}
+
+export interface GeojsonMultiLineString {
+ type: 'MultiLineString';
+ coordinates: GeojsonPosition[][];
+ bbox?: number[];
+}
+
+/** First ring is the exterior; additional rings are rendered as holes. */
+export interface GeojsonPolygon {
+ type: 'Polygon';
+ coordinates: GeojsonPosition[][];
+ bbox?: number[];
+}
+
+export interface GeojsonMultiPolygon {
+ type: 'MultiPolygon';
+ coordinates: GeojsonPosition[][][];
+ bbox?: number[];
+}
+
+export interface GeojsonGeometryCollection {
+ type: 'GeometryCollection';
+ geometries: GeojsonGeometry[];
+ bbox?: number[];
+}
+
+export type GeojsonGeometry =
+ | GeojsonPoint
+ | GeojsonMultiPoint
+ | GeojsonLineString
+ | GeojsonMultiLineString
+ | GeojsonPolygon
+ | GeojsonMultiPolygon
+ | GeojsonGeometryCollection;
+
+/** Feature received by {@linkcode GeojsonProps.onPress}. */
+export interface GeojsonFeature {
+ type: 'Feature';
+ id?: string | number;
+ properties: Record | null;
+ geometry: GeojsonGeometry | null;
+ bbox?: number[];
+}
+
+export interface GeojsonFeatureCollection {
+ type: 'FeatureCollection';
+ features: GeojsonFeature[];
+ bbox?: number[];
+}
+
+export type GeojsonObject =
+ GeojsonFeature | GeojsonFeatureCollection | GeojsonGeometry;
+
+/** GeoJSON object or JSON string. Invalid input is skipped with a development warning. */
+export type GeojsonInput = GeojsonObject | string;
+
+export interface GeojsonToOverlayOptions {
+ /**
+ * Overlay id prefix used for generated markers, polylines, and polygons.
+ *
+ * @default 'geojson'
+ */
+ id?: string;
+
+ /**
+ * Default stroke color for lines and polygons when a feature does not set
+ * `properties.stroke`.
+ */
+ strokeColor?: string;
+
+ /**
+ * Default fill color for polygons when a feature does not set
+ * `properties.fill`.
+ */
+ fillColor?: string;
+
+ /**
+ * Default marker color for points when a feature does not set
+ * `properties['marker-color']`.
+ */
+ markerColor?: string;
+
+ /**
+ * Default stroke width in density-independent pixels when a feature does not
+ * set `properties['stroke-width']`.
+ */
+ strokeWidth?: number;
+
+ /**
+ * Whether generated polylines and polygons are tappable.
+ * Set to `true` when {@linkcode GeojsonProps.onPress} is provided.
+ */
+ tappable?: boolean;
+
+ /**
+ * Default marker title when a Point feature does not set `properties.title`
+ * or `properties.name`.
+ */
+ title?: string;
+
+ /**
+ * Default drawing order when a feature does not set `properties.zIndex`.
+ */
+ zIndex?: number;
+}
+
+export interface GeojsonOverlayDescriptors {
+ /** Point and MultiPoint features, one marker per position. */
+ markers: MarkerDescriptor[];
+
+ /** LineString and MultiLineString features, one polyline per line. */
+ polylines: PolylineDescriptor[];
+
+ /** Polygon and MultiPolygon features, one descriptor per polygon. */
+ polygons: PolygonDescriptor[];
+
+ /**
+ * Source {@linkcode GeojsonFeature} for each generated overlay id. Use this
+ * to wire bulk overlay press callbacks back to feature properties.
+ */
+ featuresByOverlayId: Record;
+}
+
+export interface GeojsonProps extends GeojsonToOverlayOptions {
+ /**
+ * GeoJSON object or JSON string. FeatureCollections, Features, geometry
+ * objects, and GeometryCollections are flattened into markers, polylines,
+ * and polygons.
+ */
+ geojson: GeojsonInput;
+
+ /** Called when a generated overlay is pressed. */
+ onPress?: (feature: GeojsonFeature) => void;
+}
diff --git a/package/src/types/index.ts b/package/src/types/index.ts
index c03dbb6..b6b344d 100644
--- a/package/src/types/index.ts
+++ b/package/src/types/index.ts
@@ -26,4 +26,13 @@ export type {
PolygonProps,
CircleProps,
} from './overlays';
+export type {
+ GeojsonFeature,
+ GeojsonFeatureCollection,
+ GeojsonGeometry,
+ GeojsonInput,
+ GeojsonOverlayDescriptors,
+ GeojsonProps,
+ GeojsonToOverlayOptions,
+} from './geojson';
export type { MapViewRef } from './ref';
diff --git a/package/src/types/overlays.ts b/package/src/types/overlays.ts
index a791ba6..b64333f 100644
--- a/package/src/types/overlays.ts
+++ b/package/src/types/overlays.ts
@@ -56,6 +56,9 @@ export interface MarkerDescriptor {
/** Custom marker image. */
image?: MarkerImageSource;
+ /** Color applied to the default marker when no custom image is set. */
+ markerColor?: string;
+
/** Anchor point on the image relative to the coordinate (default bottom-center). */
anchor?: MarkerAnchor;
@@ -71,6 +74,9 @@ export interface MarkerDescriptor {
/** Opacity from 0 to 1. */
opacity?: number;
+ /** Drawing order relative to other map overlays. */
+ zIndex?: number;
+
/** Entering animation override for this marker. */
enteringAnimation?: OverlayEnteringAnimation;
}