> For the complete documentation index, see [llms.txt](https://docs.mapbox.com/ios/ja/maps/llms.txt)

# SwiftUI - Animated 3D model along a route

![Animate a 3D airplane model along a flight path using ModelSource.](https://docs.mapbox.com/ios/ja/assets/ideal-img/maps-example-animated-3d-model.9884334.480.png)

This example demonstrates how to animate a 3D airplane model along a predefined flight path using [`ModelSource`](https://docs.mapbox.com/ios/maps/api/latest/documentation/mapboxmaps/modelsource/) in a SwiftUI environment.

A GeoJSON flight path is loaded and rendered as a [`LineLayer`](https://docs.mapbox.com/ios/maps/api/latest/documentation/mapboxmaps/linelayer/), while a 3D airplane model is placed in a `ModelSource` and displayed via a [`ModelLayer`](https://docs.mapbox.com/ios/maps/api/latest/documentation/mapboxmaps/modellayer/). On each display frame, the airplane's position and bearing are interpolated along the route, and the model source is updated to move the airplane smoothly.

The camera follows the airplane using a viewport that tracks the model's current position, creating a flyover effect along the route.

> **Note: iOS Examples App Available**
> 
> This example code is part of the **Maps SDK for iOS Examples App**, a working iOS project [available on Github](https://github.com/mapbox/mapbox-maps-ios/tree/11.33.0/Sources/Examples). iOS developers are encouraged to run the examples app locally to interact with this example in an emulator and explore other features of the Maps SDK.
> 
> See our [Run the Maps SDK for iOS Examples App](https://docs.mapbox.com/help/tutorials/maps-sdk-ios-examples-app/) tutorial for step-by-step instructions.

**Swift**

Title: `Animated3DModelSourceExample.swift`

[View on GitHub](https://github.com/mapbox/mapbox-maps-ios/blob/main/Sources/Examples/SwiftUI%20Examples/Animated3DModelSourceExample.swift)

```swift
import MapboxMaps
import SwiftUI

struct Animated3DModelSourceExample: View {
    @State private var airplane = Airplane()
    @State private var flightRoute: FlightRoute?
    @State private var animationPhase: Double = 0
    @State private var displayLink: DisplayLink?
    @State private var viewport: Viewport = .camera(
        center: CLLocationCoordinate2D(latitude: 37.6199, longitude: -122.3721),
        zoom: 17.457,
        bearing: 141.62,
        pitch: 70.58
    )

    private let sourceId = "3d-model-source"
    private let planeModelKey = "plane"
    private let flightPathSourceId = "flightpath"

    var body: some View {
        MapReader { mapProxy in
            Map(viewport: $viewport) {
                // Flight path line
                GeoJSONSource(id: flightPathSourceId)
                    .data(.url(URL(string: Constants.flightPathJsonUri)!))

                LineLayer(id: "flight-path-line", source: flightPathSourceId)
                    .lineColor(.init(UIColor(red: 0, green: 124/255, blue: 191/255, alpha: 1)))
                    .lineWidth(8.0)
                    .lineEmissiveStrength(1.0)
                    .lineCap(.round)
                    .lineJoin(.round)

                ModelSource(id: sourceId)
                    .models([createAirplaneModel()])

                ModelLayer(id: "3d-model-layer", source: sourceId)
                    .modelType(.locationIndicator)
                    .modelTranslation(
                        Exp(arguments:
                                [.number(0), .number(0), .stringArray(["feature-state", "z-elevation"])]
                           )
                    )
                    .modelScale(
                        Exp(.interpolate) {
                            Exp(.exponential) { 0.5 }
                            Exp(.zoom)
                            2.0
                            [40000.0, 40000.0, 40000.0]
                            16.0
                            [1.0, 1.0, 1.0]
                        }
                    )
                    .modelRotation(
                        Exp(.match) {
                            Exp(.get) { "part" }
                            // Gears
                            "front_gear"
                            Exp(.featureState) { "front-gear-rotation" }
                            "rear_gears"
                            Exp(.featureState) { "rear-gear-rotation" }
                            // Propellers
                            "propeller_left_outer"
                            Exp(.featureState) { "propeller-rotation" }
                            "propeller_left_inner"
                            Exp(.featureState) { "propeller-rotation" }
                            "propeller_right_outer"
                            Exp(.featureState) { "propeller-rotation" }
                            "propeller_right_inner"
                            Exp(.featureState) { "propeller-rotation" }
                            // Blurred propellers
                            "propeller_left_outer_blur"
                            Exp(.featureState) { "propeller-rotation-blur" }
                            "propeller_left_inner_blur"
                            Exp(.featureState) { "propeller-rotation-blur" }
                            "propeller_right_outer_blur"
                            Exp(.featureState) { "propeller-rotation-blur" }
                            "propeller_right_inner_blur"
                            Exp(.featureState) { "propeller-rotation-blur" }
                            [0.0, 0.0, 0.0]
                        }
                    )
                    .modelEmissiveStrength(
                        Exp(.match) {
                            Exp(.get) { "part" }
                            "lights_position_white"
                            Exp(.featureState) { "light-emission-strobe" }
                            "lights_position_white_volume"
                            Exp(.featureState) { "light-emission-strobe" }
                            "lights_anti_collision_red"
                            Exp(.featureState) { "light-emission-strobe" }
                            "lights_anti_collision_red_volume"
                            Exp(.featureState) { "light-emission-strobe" }
                            "lights_position_red"
                            Exp(.featureState) { "light-emission" }
                            "lights_position_red_volume"
                            Exp(.featureState) { "light-emission" }
                            "lights_position_green"
                            Exp(.featureState) { "light-emission" }
                            "lights_position_green_volume"
                            Exp(.featureState) { "light-emission" }
                            "lights_taxi_white"
                            Exp(.featureState) { "light-emission-taxi" }
                            "lights_taxi_white_volume"
                            Exp(.featureState) { "light-emission-taxi" }
                            0.0
                        }
                    )
                    .modelOpacity(
                        Exp(.match) {
                            Exp(.get) { "part" }
                            "lights_position_white_volume"
                            Exp(.product) {
                                Exp(.featureState) { "light-emission-strobe" }
                                0.25
                            }
                            "lights_anti_collision_red_volume"
                            Exp(.product) {
                                Exp(.featureState) { "light-emission-strobe" }
                                0.45
                            }
                            "lights_position_green_volume"
                            Exp(.product) {
                                Exp(.featureState) { "light-emission" }
                                0.25
                            }
                            "lights_position_red_volume"
                            Exp(.product) {
                                Exp(.featureState) { "light-emission" }
                                0.25
                            }
                            "lights_taxi_white"
                            Exp(.product) {
                                Exp(.featureState) { "light-emission-taxi" }
                                0.25
                            }
                            "lights_taxi_white_volume"
                            Exp(.product) {
                                Exp(.featureState) { "light-emission-taxi" }
                                0.25
                            }
                            "propeller_blur"
                            0.2
                            1.0
                        }
                    )
            }
            .debugOptions(.camera)
            .mapStyle(.standard(lightPreset: .dusk, showPointOfInterestLabels: false, showRoadLabels: false))
            .onStyleLoaded { _ in
                startAnimation(mapProxy: mapProxy)
            }
            .task {
                loadFlightRoute { route in
                    flightRoute = route
                }
            }
            .onDisappear {
                displayLink = nil
            }
            .ignoresSafeArea()
        }
    }

    private func createAirplaneModel() -> Model {
        Model(
            id: planeModelKey,
            uri: URL(string: Constants.airplaneModelUri)!,
            position: airplane.position,
            orientation: [airplane.roll, airplane.pitch, airplane.bearing + 90.0]
        )
        .materialOverrideNames(Constants.materialOverrideNames)
        .nodeOverrideNames(Constants.nodeOverrideNames)
    }

    private func startAnimation(mapProxy: MapProxy) {
        guard let map = mapProxy.map else { return }

        displayLink = DisplayLink { deltaTime in
            guard let route = flightRoute else { return }

            let routeElevation = airplane.altitude
            let animFade = Self.clamp((routeElevation - Constants.flightTravelAltitudeMin) /
                                      (Constants.flightTravelAltitudeMax - Constants.flightTravelAltitudeMin))

            let timelapseFactor = Self.mix(0.001, 10.0, animFade * animFade)
            animationPhase += (deltaTime * timelapseFactor) / Constants.animationDuration

            if animationPhase > 1.0 {
                animationPhase = 0
            }

            if let target = route.sample(distance: route.totalLength * animationPhase) {
                airplane = airplane.update(target: target, dtimeMs: deltaTime * 1000.0)

                updateCamera(map: map)
                updateFeatureState(map: map)
            }
        }
    }

    private func updateCamera(map: MapboxMap) {
        let animFade = Self.clamp((airplane.altitude - Constants.flightTravelAltitudeMin) /
                                  (Constants.flightTravelAltitudeMax - Constants.flightTravelAltitudeMin))

        let cameraOffsetLng = Self.mix(-0.003, 0.0, airplane.altitude / 200.0)
        let cameraOffsetLat = Self.mix(0.003, 0.0, airplane.altitude / 200.0)
        let cameraAltitude = airplane.altitude + 150.0 + Self.mix(0.0, 10000000.0, animFade)

        let cameraLocation = CLLocationCoordinate2D(
            latitude: airplane.position[1] + cameraOffsetLat,
            longitude: airplane.position[0] + cameraOffsetLng
        )
        let targetLocation = CLLocationCoordinate2D(
            latitude: airplane.position[1],
            longitude: airplane.position[0]
        )

        let freeCameraOptions = map.freeCameraOptions
        freeCameraOptions.location = cameraLocation
        freeCameraOptions.altitude = cameraAltitude
        freeCameraOptions.lookAtPoint(forLocation: targetLocation, altitude: airplane.altitude)

        map.freeCameraOptions = freeCameraOptions

        print("Camera: location=(\(cameraLocation.latitude), \(cameraLocation.longitude)), altitude=\(cameraAltitude), bearing=\(map.cameraState.bearing), pitch=\(map.cameraState.pitch), zoom=\(map.cameraState.zoom)")
    }

    private func updateFeatureState(map: MapboxMap) {
        let featureState: [String: Any] = [
            "z-elevation": airplane.altitude,
            "front-gear-rotation": [0.0, 0.0, airplane.frontGearRotation],
            "rear-gear-rotation": [0.0, 0.0, airplane.rearGearRotation],
            "propeller-rotation": [
                0.0,
                0.0,
                -(airplane.animTimeS.truncatingRemainder(dividingBy: 0.5)) * 2.0 * 360.0
            ],
            "propeller-rotation-blur": [
                0.0,
                0.0,
                (airplane.animTimeS.truncatingRemainder(dividingBy: 0.1)) * 10.0 * 360.0
            ],
            "light-emission": airplane.lightPhase,
            "light-emission-strobe": airplane.lightPhaseStrobe,
            "light-emission-taxi": airplane.lightTaxiPhase
        ]

        map.setFeatureState(
            sourceId: sourceId,
            sourceLayerId: nil,
            featureId: planeModelKey,
            state: featureState, callback: { _ in }
        )
    }

    private func loadFlightRoute(completion: @escaping (FlightRoute) -> Void) {
        URLSession.shared.dataTask(with: URL(string: Constants.flightPathJsonUri)!) { data, _, error in
            guard let data = data, error == nil else { return }

            do {
                let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
                guard let features = json?["features"] as? [[String: Any]],
                      let firstFeature = features.first,
                      let geometry = firstFeature["geometry"] as? [String: Any],
                      let coordinates = geometry["coordinates"] as? [[Double]],
                      let properties = firstFeature["properties"] as? [String: Any],
                      let elevation = properties["elevation"] as? [Double] else { return }

                let route = FlightRoute(coordinates: coordinates, elevation: elevation)
                DispatchQueue.main.async {
                    completion(route)
                }
            } catch {
                print("Error parsing flight route: \(error)")
            }
        }.resume()
    }

    static func clamp(_ value: Double) -> Double {
        max(0.0, min(value, 1.0))
    }

    static func mix(_ a: Double, _ b: Double, _ t: Double) -> Double {
        let f = clamp(t)
        return a * (1 - f) + b * f
    }
}

// MARK: - Data Models

private struct Airplane {
    var position: [Double] = [-122.37204647633236, 37.619836883832306]
    var altitude: Double = 0.0
    var bearing: Double = -60.0
    var pitch: Double = 0.0
    var roll: Double = 0.0
    var rearGearRotation: Double = 0.0
    var frontGearRotation: Double = 0.0
    var lightPhase: Double = 0.0
    var lightPhaseStrobe: Double = 0.0
    var lightTaxiPhase: Double = 0.0
    var animTimeS: Double = 0.0

    func update(target: RoutePoint, dtimeMs: Double) -> Airplane {
        let newAnimTimeS = animTimeS + dtimeMs / 1000.0
        return Airplane(
            position: [
                Animated3DModelSourceExample.mix(position[0], target.position[0], dtimeMs * 0.05),
                Animated3DModelSourceExample.mix(position[1], target.position[1], dtimeMs * 0.05)
            ],
            altitude: Animated3DModelSourceExample.mix(altitude, target.altitude, dtimeMs * 0.05),
            bearing: Animated3DModelSourceExample.mix(bearing, target.bearing, dtimeMs * 0.01),
            pitch: Animated3DModelSourceExample.mix(pitch, target.pitch, dtimeMs * 0.01),
            roll: (Animated3DModelSourceExample.mix(0.0, sin(newAnimTimeS * .pi * 0.2) * 0.1, (altitude - 50.0) / 100.0)) * 180.0 / .pi, rearGearRotation: Animated3DModelSourceExample.mix(0.0, -90.0, altitude / 50.0), frontGearRotation: Animated3DModelSourceExample.mix(0.0, 90.0, altitude / 50.0),
            lightPhase: animSinPhaseFromTime(newAnimTimeS, 2.0) * 0.25 + 0.75,
            lightPhaseStrobe: animSinPhaseFromTime(newAnimTimeS, 1.0),
            lightTaxiPhase: Animated3DModelSourceExample.mix(1.0, 0.0, altitude / 100.0),
            animTimeS: newAnimTimeS
        )
    }

    private func animSinPhaseFromTime(_ animTimeS: Double, _ phaseLen: Double) -> Double {
        return sin(((animTimeS.truncatingRemainder(dividingBy: phaseLen)) / phaseLen) * .pi * 2.0) * 0.5 + 0.5
    }
}

private struct RoutePoint {
    let position: [Double]
    let altitude: Double
    let bearing: Double
    let pitch: Double
}

private struct FlightRoute {
    let coordinates: [[Double]]
    let elevation: [Double]
    let distances: [Double]
    let maxElevation: Double

    var totalLength: Double {
        distances.last ?? 0.0
    }

    init(coordinates: [[Double]], elevation: [Double]) {
        self.coordinates = coordinates
        self.elevation = elevation

        var distances: [Double] = [0.0]
        var maxElevation = elevation[0]

        for i in 1..<coordinates.count {
            let p1 = coordinates[i - 1]
            let p2 = coordinates[i]

            // Simple distance calculation (not accurate for long distances but sufficient for this demo)
            let dlat = p2[1] - p1[1]
            let dlng = p2[0] - p1[0]
            let segmentDistance = sqrt(dlat * dlat + dlng * dlng) * 111000.0 // Rough conversion to meters

            distances.append(distances[i - 1] + segmentDistance)
            maxElevation = max(maxElevation, elevation[i])
        }

        self.distances = distances
        self.maxElevation = maxElevation
    }

    func sample(distance: Double) -> RoutePoint? {
        guard !distances.isEmpty else { return nil }

        var segmentIndex = distances.firstIndex { $0 >= distance } ?? 0
        segmentIndex = max(0, segmentIndex - 1)
        segmentIndex = min(coordinates.count - 2, segmentIndex)

        let p1 = coordinates[segmentIndex]
        let p2 = coordinates[segmentIndex + 1]
        let segmentLength = distances[segmentIndex + 1] - distances[segmentIndex]
        let segmentRatio = (distance - distances[segmentIndex]) / segmentLength

        let e1 = elevation[segmentIndex]
        let e2 = elevation[segmentIndex + 1]
        let altitude = e1 + (e2 - e1) * segmentRatio

        let bearing = atan2(p2[0] - p1[0], p2[1] - p1[1]) * 180.0 / .pi
        let pitch = atan2(e2 - e1, segmentLength) * 180.0 / .pi

        return RoutePoint(
            position: [
                p1[0] + (p2[0] - p1[0]) * segmentRatio,
                p1[1] + (p2[1] - p1[1]) * segmentRatio
            ],
            altitude: altitude,
            bearing: bearing,
            pitch: pitch
        )
    }
}

// MARK: - DisplayLink Helper

private class DisplayLink {
    private var displayLink: CADisplayLink?
    private var callback: (Double) -> Void
    private var lastTimestamp: CFTimeInterval = 0

    private class WeakProxy {
        weak var target: DisplayLink?

        init(target: DisplayLink) {
            self.target = target
        }

        @objc func frame(displayLink: CADisplayLink) {
            target?.frame(displayLink: displayLink)
        }
    }

    init(callback: @escaping (Double) -> Void) {
        self.callback = callback
        let proxy = WeakProxy(target: self)
        self.displayLink = CADisplayLink(target: proxy, selector: #selector(WeakProxy.frame(displayLink:)))
        self.displayLink?.add(to: .main, forMode: .common)
    }

    @objc private func frame(displayLink: CADisplayLink) {
        if lastTimestamp == 0 {
            lastTimestamp = displayLink.timestamp
        }

        let deltaTime = displayLink.timestamp - lastTimestamp
        lastTimestamp = displayLink.timestamp

        callback(deltaTime)
    }

    deinit {
        displayLink?.invalidate()
    }
}

// MARK: - Constants

private enum Constants {
    static let flightPathJsonUri = "https://docs.mapbox.com/mapbox-gl-js/assets/flightpath.json"
    static let airplaneModelUri = "https://docs.mapbox.com/mapbox-gl-js/assets/airplane.glb"
    static let animationDuration: TimeInterval = 50.0
    static let flightTravelAltitudeMin: Double = 200.0
    static let flightTravelAltitudeMax: Double = 3000.0

    static let materialOverrideNames = [
        "propeller_blur",
        "lights_position_white",
        "lights_position_white_volume",
        "lights_position_red",
        "lights_position_red_volume",
        "lights_position_green",
        "lights_position_green_volume",
        "lights_anti_collision_red",
        "lights_anti_collision_red_volume",
        "lights_taxi_white",
        "lights_taxi_white_volume"
    ]

    static let nodeOverrideNames = [
        "front_gear",
        "rear_gears",
        "propeller_left_inner",
        "propeller_left_outer",
        "propeller_right_inner",
        "propeller_right_outer",
        "propeller_left_inner_blur",
        "propeller_left_outer_blur",
        "propeller_right_inner_blur",
        "propeller_right_outer_blur"
    ]
}
```