# Navvo Maps SDK for iOS (`NavvoMaps`)

Native Swift SDK for the [Navvo](https://navvo.io) maps platform: a
Navvo-branded basemap view (UIKit + SwiftUI) and a full `async/await` client
for Navvo's REST services — search, geocoding, places, routing, traffic,
weather, EV and transit.

- **Platform:** iOS 13+ (iPhone **and iPad**) · **CarPlay** (built in, entitlement-gated) · macOS via *Designed for iPad* / Mac Catalyst — see [iPad, macOS & large screens](#ipad-macos--large-screens)
- **Toolchain:** Swift 5.9 · Swift Package Manager
- **Version:** 0.2.0
- **Docs:** [docs.navvo.io](https://docs.navvo.io)
- **Attribution:** maps display "© Navvo" — the attribution control is on by
  default and must remain visible.

> The map view renders via an embedded open-source rendering engine compiled
> into your app; every URL it loads (styles, tiles, sprites, glyphs) and every
> service call is Navvo's (`https://navvo.io/api/v1`). The public API contains
> only Navvo types.

---

## Install (Swift Package Manager)

In Xcode: **File → Add Package Dependencies…** and add the NavvoMaps package,
or in `Package.swift`:

```swift
dependencies: [
    .package(url: "https://github.com/navvo-io/navvo-maps-ios.git", from: "0.2.0")
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [.product(name: "NavvoMaps", package: "navvo-maps-ios")]
    )
]
```

You can also download the source archive from
[docs.navvo.io/sdks](https://docs.navvo.io/sdks) and add the folder as a local
package.

## Configure

Get an API key (`nvvo_…`) at [navvo.io/developer](https://navvo.io/developer),
then configure once at app start:

```swift
import NavvoMaps

NavvoMaps.configure(apiKey: "nvvo_YOUR_KEY")
```

Service calls send the key as the `x-api-key` header. Map style/tile URLs
carry it as `?api_key=` (the renderer fetches those and cannot set headers).
For production, prefer a dedicated minimal-scope key for apps and keep
full-scope keys on your backend.

## Quickstart — UIKit

```swift
import UIKit
import NavvoMaps

final class MapViewController: UIViewController, NavvoMapViewDelegate {
    private var mapView: NavvoMapView!

    override func viewDidLoad() {
        super.viewDidLoad()

        var options = NavvoMapView.Options()
        options.style = .standard
        options.camera = CameraPosition(target: LatLng(31.95, 35.91), zoom: 12) // Amman
        mapView = NavvoMapView(frame: view.bounds, options: options)
        mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        mapView.delegate = self
        view.addSubview(mapView)

        let marker = NavvoMarker(position: LatLng(31.9539, 35.9106), title: "Downtown Amman")
        mapView.addMarker(marker)
    }

    func mapView(_ mapView: NavvoMapView, didTapAt coordinate: LatLng) {
        print("tapped \(coordinate)")
    }
}
```

## Quickstart — SwiftUI

```swift
import SwiftUI
import NavvoMaps

struct ContentView: View {
    @State private var camera = CameraPosition(target: LatLng(31.95, 35.91), zoom: 12)

    var body: some View {
        NavvoMap(style: .standard, camera: $camera, markers: [
            NavvoMarker(position: LatLng(31.9539, 35.9106), title: "Downtown Amman"),
        ])
        .onNavvoTap { print("tapped \($0)") }
        .onNavvoMarkerTap { print("marker \($0.title ?? "")") }
        .ignoresSafeArea()
    }
}
```

## Quickstart — services

`NavvoServices` is an actor, fully `async/await`, and independent of the map:

```swift
let services = NavvoServices.shared

// Search
let result = try await services.search("coffee in Abdoun", limit: 10)
for place in result.places {
    print(place.name ?? "?", place.location ?? "")
}

// Geocode / reverse geocode
let hits = try await services.geocode("Rainbow Street, Amman")
let nearest = try await services.reverseGeocode(LatLng(31.95, 35.91))

// Route (corridor-aware planner; auto-avoids closures/incidents/no-entry signs)
let plan = try await services.plan(RoutePlanRequest(
    locations: [PlanStop(LatLng(31.95, 35.91)), PlanStop(LatLng(31.99, 35.86))],
    costing: .auto,
    preference: .fastest
))
print(plan.distanceKm ?? 0, "km,", plan.durationMinutes ?? 0, "min")
mapView.addPolyline(id: "route", path: plan.geometry)

// Traffic-aware ETA (needs routing + traffic scopes)
let live = try await services.trafficRoute(RouteRequest(
    locations: [LatLng(31.95, 35.91), LatLng(31.99, 35.86)]
))

// Matrix, isochrone, map-match, optimize
let matrix = try await services.matrix(MatrixRequest(
    sources: [LatLng(31.95, 35.91)],
    targets: [LatLng(31.99, 35.86), LatLng(31.90, 35.95)]
))
let iso = try await services.isochrone(.times(LatLng(31.95, 35.91), minutes: [5, 10, 15]))
mapView.addGeoJSON(id: "iso", json: iso.geoJSON,
                   style: .fill(color: .systemBlue, opacity: 0.2, strokeColor: nil))

// Places
let page = try await services.places(query: "pharmacy", near: LatLng(31.95, 35.91))
let detail = try await services.place("jordan-beans-cafe")   // nil when not found
let door = NavvoServices.routeTarget(for: detail)            // entrance, not the pin

// Weather / air quality / EV / transit
let weather = try await services.weatherPoint(at: LatLng(31.95, 35.91))
let stations = try await services.evStations(bbox: mapView.visibleBounds)
let stops = try await services.transitStops(near: LatLng(31.95, 35.91), radius: 800)
```

## CarPlay

CarPlay support is **built into the package** (`NavvoCarPlayController` +
`NavvoCarPlaySceneDelegate`), compile-guarded with
`#if canImport(CarPlay)` / `@available(iOS 13.0, *)` so the package builds
unchanged for targets without CarPlay. The car screen shows a real
`NavvoMapView` — every camera/marker/overlay API works there — under a
`CPMapTemplate` with zoom/pan buttons and a turn-by-turn skeleton (trip
preview → navigation session → maneuver + ETA updates).

**1. Entitlement.** Request the CarPlay navigation entitlement from Apple
(this is an Apple review process), then add it to your `.entitlements` file:

```xml
<key>com.apple.developer.carplay-maps</key>
<true/>
```

**2. Scene manifest.** Declare the CarPlay scene in `Info.plist` — point it
at `NavvoMaps.NavvoCarPlaySceneDelegate` directly, or at your own subclass
(e.g. `$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate`):

```xml
<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <true/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>CPTemplateApplicationSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneConfigurationName</key>
                <string>NavvoCarPlay</string>
                <key>UISceneDelegateClassName</key>
                <string>NavvoMaps.NavvoCarPlaySceneDelegate</string>
            </dict>
        </array>
    </dict>
</dict>
```

**3. Drive it.** Subclass the scene delegate for custom behaviour; the
controller hands you the ordinary map view plus the navigation chrome:

```swift
import NavvoMaps

final class CarPlaySceneDelegate: NavvoCarPlaySceneDelegate, NavvoCarPlayControllerDelegate {
    override func carPlayMapOptions() -> NavvoMapView.Options {
        var options = NavvoMapView.Options()
        options.style = .dark
        return options
    }

    override func carPlayDidConnect(_ controller: NavvoCarPlayController) {
        controller.delegate = self
        controller.mapView?.setCamera(
            CameraPosition(target: LatLng(31.95, 35.91), zoom: 13), animated: false
        )

        // Navigation skeleton:
        let trip = controller.makeTrip(
            from: LatLng(31.95, 35.91), originName: "Start",
            to: LatLng(31.99, 35.86), destinationName: "Sweifieh"
        )
        controller.previewTrips([trip])
    }

    func carPlayController(
        _ controller: NavvoCarPlayController, didSelect trip: CPTrip, routeChoice: CPRouteChoice
    ) {
        controller.startNavigation(for: trip)
        // From your guidance loop (e.g. positions matched against services.plan):
        controller.updateManeuver(instruction: "Turn right onto Rainbow Street",
                                  distanceRemainingMeters: 320, timeRemainingSeconds: 45)
        controller.updateTripEstimates(for: trip,
                                       distanceRemainingMeters: 5_400,
                                       timeRemainingSeconds: 11 * 60)
    }
}
```

`NavvoMaps.configure(apiKey:)` from app launch covers the CarPlay scene too
(same process). Test in the Xcode CarPlay simulator (**I/O → External
Displays → CarPlay**) — the simulator needs no entitlement.

## iPad, macOS & large screens

- **iPad / tablets** — fully supported out of the box: `NavvoMapView` and
  `NavvoMap` are size-class-agnostic and resize live with Split View, Slide
  Over, Stage Manager and rotation (the quickstarts above already use
  autoresizing/SwiftUI layout). No iPad-specific configuration exists.
- **macOS (Designed for iPad)** — the supported desktop path: run the iOS
  app unmodified on Apple-silicon Macs. Map gestures map to
  trackpad/scroll-wheel automatically; nothing to change in code.
- **Mac Catalyst** — the services half (`NavvoServices`, pure Foundation)
  compiles for Catalyst as-is; the map view compiles wherever the embedded
  rendering engine provides a Catalyst slice — otherwise keep map code
  behind your Catalyst checks and use *Designed for iPad*. For a native
  Windows/macOS/Linux desktop app, see the **Electron/Tauri desktop sample**
  in the SDK downloads (`navvo-desktop-sample`), which uses the Navvo Web
  SDK.

## API overview

| Area | Calls |
| --- | --- |
| Config | `NavvoMaps.configure(apiKey:)`, `NavvoMaps.styleURL(for:terrain:)` |
| CarPlay | `NavvoCarPlayController` (`connect`, `makeTrip`, `previewTrips`, `startNavigation`, `updateManeuver`, `updateTripEstimates`, `endNavigation`), `NavvoCarPlaySceneDelegate`, `NavvoCarPlayControllerDelegate` |
| Map view | `NavvoMapView` (UIKit), `NavvoMap` (SwiftUI), `NavvoMapView.Options` |
| Camera | `camera`, `setCamera(_:animated:)`, `animate(to:duration:)`, `fit(bounds:padding:)`, `zoomIn()/zoomOut()` |
| Markers | `addMarker`, `addMarkers`, `removeMarker`, `clearMarkers`, tap callbacks |
| Overlays | `addGeoJSON(id:json:style:)`, `addPolyline`, `addPolygon`, `removeGeoJSON`, auto re-applied on style switches |
| Styles | `NavvoMapStyle`: `.standard`, `.light`, `.dark`, `.satellite`, `.terrain`, `.threeD` (+ `terrain: true`); catalog via `services.styles()` |
| Search & geocode | `search`, `geocode`, `reverseGeocode`, `batchGeocode` |
| Places | `places`, `place(slug)` (soft-nil), `placeThumb`, `placeCategories`, `reachable` |
| Routing | `plan` (corridor-aware), `route` (fast, no avoidance), `trafficRoute`, `matrix`, `isochrone`, `mapMatch`, `optimize` |
| Traffic & road data | `trafficSummary`, `incidents`, `incidentTypes`, `roadEdits`, `routeRisk` |
| Weather & air | `weatherPoint`, `weatherForecast`, `weatherCities`, `airQualityCities` |
| Mobility | `evStations`, `transitStops`, `gbfs` |
| Helpers | `LatLng`, `LatLngBounds`, `CameraPosition`, `NavvoPolylineCodec` (polyline6), `LatLng(wkt:)` |

### Coordinates

The public API uses one type everywhere: `LatLng(lat, lng)` — latitude first.
The SDK marshals internally to each endpoint's wire convention (GeoJSON
`[lng, lat]`, WKT `POINT(lng lat)`, `bbox=west,south,east,north`, `{lon,lat}`
vs `{lat,lon}` bodies), so app code can never get the axis order wrong.
`CLLocationCoordinate2D` bridging is built in (`LatLng(coordinate)`,
`point.clCoordinate`).

## Error model

Every failure is a typed `NavvoError`:

| Case | Meaning | Retry? |
| --- | --- | --- |
| `.config(String)` | Client-side misconfiguration (missing key, bad argument) | no |
| `.auth(ctx)` | 401 — missing/unknown API key | no |
| `.scope(missingScope:ctx)` | 403 — key lacks a scope (name parsed when available) | no |
| `.notFound(ctx)` | 404 (single-resource lookups return `nil` instead) | no |
| `.validation(ctx)` | 400 / 422 — invalid request | no |
| `.rateLimit(retryAfter:ctx)` | 429 or 403 quota — honours `Retry-After` when sent | gently |
| `.server(ctx)` | 5xx | yes (backoff) |
| `.engineUnavailable(ctx)` | HTTP 200/201 with `available: false` — engine down | yes (backoff) |
| `.network(String)` | DNS / connection / TLS failure | yes |
| `.timeout(seconds:attempts:)` | Per-attempt timeout exceeded | yes |

The SDK already retries idempotent GETs (backoff + full jitter, `Retry-After`
aware). POSTs (routing, reports) are never auto-retried. Check
`error.isRetryable` for a quick decision, and `error.context` for the status
code, path and raw body.

```swift
do {
    let plan = try await services.plan(request)
} catch let error as NavvoError {
    switch error {
    case .scope(let missing, _):        showBanner("Key needs scope: \(missing ?? "?")")
    case .rateLimit(let retryAfter, _): scheduleRetry(after: retryAfter ?? 2)
    case .engineUnavailable:            showRoutingDownBanner()
    default:                            present(error)
    }
}
```

## Rate limits

Default per-key quotas: 180 requests/minute, 2,500/day (plan-dependent).
Debounce autocomplete (250–300 ms), prefer `matrix` over N route calls, and
pass `bbox` to bounded endpoints. `NavvoError.rateLimit` carries `retryAfter`
when the server provides it.

## Privacy

With default settings the SDK collects nothing and sends no telemetry or
analytics; a `PrivacyInfo.xcprivacy` manifest ships in the package. Location
is only used when the host app enables the user-location puck, and the host
app owns the permission prompt (`NSLocationWhenInUseUsageDescription`).

## Example

See [`Example/`](Example/) for a runnable UIKit view controller and a SwiftUI
screen.

## License

MIT © 2026 Navvo — see [LICENSE](LICENSE).
