From 5abfc128532089af85656e5a7aa34171a30f2f0b Mon Sep 17 00:00:00 2001 From: Ashik Abbas Date: Thu, 5 Nov 2026 21:51:20 +0530 Subject: [PATCH] fix(HillyRoadAlert): add Hilly & Undulating Road Early Alerts sample across Kotlin, Java, and Compose --- .../ApiDemos/common/build.gradle.kts | 1 + .../maps3d/common/ElevationSparklineView.kt | 256 ++++ .../maps3d/common/HillyRoadAlertViewModel.kt | 470 +++++++ .../example/maps3d/common/HillyRouteData.kt | 138 ++ .../maps3d/common/MaxHeightScrollView.kt | 10 + .../maps3d/common/RoadTerrainAnalyzer.kt | 1104 +++++++++++++++ .../res/layout/activity_hilly_road_alerts.xml | 488 +++++++ .../common/src/main/res/values/strings.xml | 32 + .../src/main/resources/hilly_routes.json | 1188 +++++++++++++++++ .../common/HillyRoadAlertViewModelTest.kt | 200 +++ .../maps3d/common/RoadTerrainAnalyzerTest.kt | 234 ++++ .../java-app/src/main/AndroidManifest.xml | 9 + .../HillyRoadAlertsActivity.java | 721 ++++++++++ .../maps3djava/mainactivity/MainActivity.java | 2 + .../kotlin-app/src/main/AndroidManifest.xml | 9 + .../HillyRoadAlertsActivity.kt | 668 +++++++++ .../maps3dkotlin/mainactivity/MainActivity.kt | 2 + .../app/src/main/AndroidManifest.xml | 3 + .../com/example/composedemos/MainActivity.kt | 6 + .../HillyRoadAlertsActivity.kt | 1026 ++++++++++++++ 20 files changed, 6567 insertions(+) create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/ElevationSparklineView.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HillyRoadAlertViewModel.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HillyRouteData.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/RoadTerrainAnalyzer.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_hilly_road_alerts.xml create mode 100644 Maps3DSamples/ApiDemos/common/src/main/resources/hilly_routes.json create mode 100644 Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/HillyRoadAlertViewModelTest.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/RoadTerrainAnalyzerTest.kt create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/hillyroadalerts/HillyRoadAlertsActivity.java create mode 100644 Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/hillyroadalerts/HillyRoadAlertsActivity.kt create mode 100644 Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hillyroadalerts/HillyRoadAlertsActivity.kt diff --git a/Maps3DSamples/ApiDemos/common/build.gradle.kts b/Maps3DSamples/ApiDemos/common/build.gradle.kts index 09a7643c..5186d36b 100644 --- a/Maps3DSamples/ApiDemos/common/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/common/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { implementation(libs.material) api(libs.androidx.lifecycle.viewmodel.ktx) testImplementation(libs.junit) + testImplementation(libs.json) testImplementation(libs.google.truth) testImplementation(libs.androidx.arch.core.testing) testImplementation(libs.kotlinx.coroutines.test) diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/ElevationSparklineView.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/ElevationSparklineView.kt new file mode 100644 index 00000000..2622fd75 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/ElevationSparklineView.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.util.AttributeSet +import android.view.View +import kotlin.math.max + +/** + * Custom 2D sparkline [View] shared by Kotlin Views and Java Views to plot the upcoming + * 3D terrain elevation profile (`250m–750m` ahead), shade active hazard zones in Amber/Red, + * mark upcoming [ExtremaType.CREST] and [ExtremaType.DIP] inflection points, and pin the + * vehicle indicator at `x = 0`. + */ +class ElevationSparklineView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private var profilePoints: List = emptyList() + private var extremaPoints: List = emptyList() + private var alertSeverity: AlertSeverity = AlertSeverity.NONE + private var lookaheadHorizonMeters: Double = 500.0 + + private val density = resources.displayMetrics.density + + private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 1f * density + color = Color.argb(45, 255, 255, 255) + } + + private val baseFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.argb(45, 56, 189, 248) // Translucent sky cyan (#38BDF8) + } + + private val hazardFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + + private val baseCurvePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2.2f * density + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + color = Color.rgb(56, 189, 248) // Sky cyan (#38BDF8) + } + + private val hazardCurvePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 3.0f * density + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + } + + private val dotFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + + private val dotStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 1.5f * density + color = Color.WHITE + } + + private val curvePath = Path() + private val fillPath = Path() + private val hazardCurvePath = Path() + private val hazardFillPath = Path() + + /** + * Updates the sparkline dataset and triggers a redraw. + */ + fun updateData( + profile: List, + extrema: List, + severity: AlertSeverity, + horizonMeters: Double + ) { + profilePoints = profile + extremaPoints = extrema + alertSeverity = severity + lookaheadHorizonMeters = max(10.0, horizonMeters) + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val w = width.toFloat() + val h = height.toFloat() + if (w <= 0f || h <= 0f || profilePoints.size < 2) return + + val padH = 10f * density + val padV = 8f * density + val plotLeft = padH + val plotRight = (w - padH).coerceAtLeast(plotLeft + 1f) + val plotTop = padV + val plotBottom = (h - padV).coerceAtLeast(plotTop + 1f) + val plotWidth = plotRight - plotLeft + val plotHeight = plotBottom - plotTop + + // Baseline grid lines + val midY = (plotTop + plotBottom) * 0.5f + canvas.drawLine(plotLeft, midY, plotRight, midY, gridPaint) + canvas.drawLine(plotLeft, plotBottom, plotRight, plotBottom, gridPaint) + + var minAlt = profilePoints.first().altitudeMeters + var maxAlt = minAlt + for (pt in profilePoints) { + if (pt.altitudeMeters < minAlt) minAlt = pt.altitudeMeters + if (pt.altitudeMeters > maxAlt) maxAlt = pt.altitudeMeters + } + // Enforce at least a 12m vertical span so flat roads look authentically flat + val rawSpan = maxAlt - minAlt + val minVisualSpan = 12.0 + val effectiveSpan = max(rawSpan, minVisualSpan) + val altPadding = (effectiveSpan - rawSpan) * 0.5 + val lowAlt = minAlt - altPadding + + val maxDist = max( + lookaheadHorizonMeters, + profilePoints.last().distanceOffsetMeters.coerceAtLeast(1.0) + ) + + fun mapX(distOffset: Double): Float { + val ratio = (distOffset / maxDist).coerceIn(0.0, 1.0).toFloat() + return plotLeft + ratio * plotWidth + } + + fun mapY(altitude: Double): Float { + val ratio = ((altitude - lowAlt) / effectiveSpan).coerceIn(0.0, 1.0).toFloat() + return plotBottom - ratio * plotHeight + } + + // 1. Base area fill and curve path + curvePath.reset() + fillPath.reset() + hazardCurvePath.reset() + hazardFillPath.reset() + + var inHazardRun = false + var hazardRunStartX = plotLeft + + for (i in profilePoints.indices) { + val pt = profilePoints[i] + val x = mapX(pt.distanceOffsetMeters) + val y = mapY(pt.altitudeMeters) + + if (i == 0) { + curvePath.moveTo(x, y) + fillPath.moveTo(x, plotBottom) + fillPath.lineTo(x, y) + } else { + curvePath.lineTo(x, y) + fillPath.lineTo(x, y) + } + + if (pt.isHazardZone) { + if (!inHazardRun) { + inHazardRun = true + hazardRunStartX = x + hazardCurvePath.moveTo(x, y) + hazardFillPath.moveTo(x, plotBottom) + hazardFillPath.lineTo(x, y) + } else { + hazardCurvePath.lineTo(x, y) + hazardFillPath.lineTo(x, y) + } + } else if (inHazardRun) { + val prevX = mapX(profilePoints[i - 1].distanceOffsetMeters) + hazardFillPath.lineTo(prevX, plotBottom) + hazardFillPath.lineTo(hazardRunStartX, plotBottom) + hazardFillPath.close() + inHazardRun = false + } + } + + val lastX = mapX(profilePoints.last().distanceOffsetMeters) + fillPath.lineTo(lastX, plotBottom) + fillPath.lineTo(plotLeft, plotBottom) + fillPath.close() + + if (inHazardRun) { + hazardFillPath.lineTo(lastX, plotBottom) + hazardFillPath.lineTo(hazardRunStartX, plotBottom) + hazardFillPath.close() + } + + canvas.drawPath(fillPath, baseFillPaint) + + // 2. Hazard shading & highlight curve (Amber #FBBC04 for CAUTION, Red #EA4335 for WARNING) + if (alertSeverity != AlertSeverity.NONE) { + val isWarning = alertSeverity == AlertSeverity.WARNING + hazardFillPaint.color = if (isWarning) { + Color.argb(85, 234, 67, 53) // Canonical Google Red (#EA4335) + } else { + Color.argb(85, 251, 188, 4) // Canonical Google Amber (#FBBC04) + } + hazardCurvePaint.color = if (isWarning) { + Color.rgb(234, 67, 53) + } else { + Color.rgb(251, 188, 4) + } + canvas.drawPath(hazardFillPath, hazardFillPaint) + } + + canvas.drawPath(curvePath, baseCurvePaint) + if (alertSeverity != AlertSeverity.NONE) { + canvas.drawPath(hazardCurvePath, hazardCurvePaint) + } + + // 3. Draw Crest (peak) and Dip (trough) dots + val dotRadius = 3.8f * density + for (ext in extremaPoints) { + val x = mapX(ext.distanceAheadMeters) + val y = mapY(ext.position.altitude) + dotFillPaint.color = if (ext.type == ExtremaType.CREST) { + Color.rgb(234, 67, 53) // Crest: Red-Orange (#EA4335) + } else { + Color.rgb(251, 188, 4) // Dip: Amber (#FBBC04) + } + canvas.drawCircle(x, y, dotRadius, dotFillPaint) + canvas.drawCircle(x, y, dotRadius, dotStrokePaint) + } + + // 4. Pin vehicle indicator at x = 0 (plotLeft) + val carPoint = profilePoints.first() + val carX = mapX(carPoint.distanceOffsetMeters) + val carY = mapY(carPoint.altitudeMeters) + val carRadius = 4.5f * density + dotFillPaint.color = Color.rgb(52, 211, 153) // Emerald green (#34D399) vehicle dot + canvas.drawCircle(carX, carY, carRadius, dotFillPaint) + canvas.drawCircle(carX, carY, carRadius, dotStrokePaint) + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HillyRoadAlertViewModel.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HillyRoadAlertViewModel.kt new file mode 100644 index 00000000..e90cc406 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HillyRoadAlertViewModel.kt @@ -0,0 +1,470 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude +import kotlin.math.abs +import kotlin.math.max +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Immutable UI state for the Hilly & Undulating (Roller-Coaster) Road Early Alerts sample. + * + * Shared across Kotlin Views, Java Views, and Jetpack Compose. + * + * @property selectedPreset Currently selected 3D route preset. + * @property routePoints Raw 3D route waypoints. + * @property staticRouteVertices Base route polyline vertices with +0.4m elevation bias. + * @property traveledRouteVertices Driven route polyline vertices behind the vehicle. + * @property isMapReady Whether the 3D map has finished initializing and is ready to start the route. + * @property isPlaying Whether the vehicle simulation is actively advancing. + * @property isUserScrubbing Whether the user is dragging the progress slider. + * @property progressRatio Normalized route progress in `[0.0f, 1.0f]`. + * @property currentDistanceMeters Traveled distance along the route in meters. + * @property totalDistanceMeters Total horizontal length of the active route in meters. + * @property speedMultiplier Playback speed multiplier (`0.5`, `1.0`, `2.0`, `3.0`, `5.0`). + * @property lookaheadHorizonMeters Forward terrain scan window in meters (`250.0`, `500.0`, `750.0`). + * @property autoCameraPitchPreview Whether the chase camera automatically widens range, lowers tilt, + * and adjusts camera height when approaching sharp elevation changes or blind crests. + * @property vehiclePosition Current 3D coordinate of the simulated vehicle. + * @property currentAltitudeMeters Current road elevation in meters at the vehicle position. + * @property currentHeadingDegrees Smoothed forward heading of the vehicle in degrees `[0, 360)`. + * @property cameraCenter Target 3D center point for the chase camera. + * @property cameraHeading Chase camera heading in degrees. + * @property cameraTilt Chase camera tilt angle in degrees (`74.0°` normal, `66.0°` crest preview). + * @property cameraRange Chase camera range in meters (`160.0m` normal, `230.0m` crest preview). + * @property terrainAlert Forward terrain analysis and early hazard alert state. + */ +data class HillyRoadUiState( + @JvmField val selectedPreset: HillyRoutePreset, + @JvmField val routePoints: List, + @JvmField val staticRouteVertices: List, + @JvmField val traveledRouteVertices: List, + @JvmField val isMapReady: Boolean, + @JvmField val isPlaying: Boolean, + @JvmField val isUserScrubbing: Boolean, + @JvmField val progressRatio: Float, + @JvmField val currentDistanceMeters: Double, + @JvmField val totalDistanceMeters: Double, + @JvmField val speedMultiplier: Double, + @JvmField val lookaheadHorizonMeters: Double, + @JvmField val autoCameraPitchPreview: Boolean, + @JvmField val vehiclePosition: LatLngAltitude, + @JvmField val currentAltitudeMeters: Double, + @JvmField val currentHeadingDegrees: Double, + @JvmField val cameraCenter: LatLngAltitude, + @JvmField val cameraHeading: Double, + @JvmField val cameraTilt: Double, + @JvmField val cameraRange: Double, + @JvmField val terrainAlert: TerrainAlertState +) + +/** + * Shared MVVM ViewModel driving the Hilly & Undulating Road Early Alerts sample across + * Kotlin Views, Java Views, and Jetpack Compose. + * + * Reuses [PathEngine] for spherical distance accumulation, 3D interpolation, and EMA heading + * smoothing, and delegates forward terrain hazard detection to [RoadTerrainAnalyzer]. + */ +class HillyRoadAlertViewModel : ViewModel() { + + companion object { + const val BASE_ROUTE_POLYLINE_ID = "hilly_road_base_polyline" + const val TRAVELED_ROUTE_POLYLINE_ID = "hilly_road_traveled_polyline" + const val HAZARD_ROUTE_POLYLINE_ID = "hilly_road_hazard_polyline" + const val VEHICLE_MARKER_ID = "hilly_road_vehicle_marker" + const val VEHICLE_MODEL_ID = "hilly_road_vehicle_car" + const val VEHICLE_MODEL_URL = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/red_car.glb" + const val VEHICLE_MODEL_SCALE = 5.0 + const val VEHICLE_MODEL_TILT = -90.0 + + const val DEFAULT_LOOKAHEAD_METERS = 500.0 + const val NORMAL_CAMERA_TILT = 74.0 + const val PREVIEW_CAMERA_TILT = 66.0 + const val NORMAL_CAMERA_RANGE = 160.0 + const val PREVIEW_CAMERA_RANGE = 230.0 + private const val ROUTE_ALTITUDE_OFFSET_METERS = 0.4 + private const val VEHICLE_ALTITUDE_OFFSET_METERS = 0.4 + private const val CAMERA_ALTITUDE_LOOKAHEAD_METERS = 35.0 + private const val CAMERA_LOOKAHEAD_ALTITUDE_WEIGHT = 0.30 + private const val CAMERA_ALTITUDE_EMA_ALPHA = 0.18 + private const val MAX_SHARP_ELEVATION_HEIGHT_BOOST_METERS = 10.0 + + @JvmField + val LOOKAHEAD_PRESETS_METERS: List = listOf(250.0, 500.0, 750.0) + + @JvmField + val SPEED_MULTIPLIERS: List = listOf(0.5, 1.0, 2.0, 3.0, 5.0) + + /** + * Safely checks whether the device currently has an active internet-capable network. + * When offline, the Maps 3D SDK displays its built-in "Check your internet connection" + * dialog and the route simulation waits until the map finishes loading. + */ + @JvmStatic + fun isInternetAvailable(context: Context): Boolean { + return try { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return true + val activeNetwork = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(activeNetwork) ?: return false + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } catch (_: Exception) { + true + } + } + } + + private var activePreset: HillyRoutePreset = HillyRoutePreset.ROLLER_COASTER + private var activeRoute: List = HillyRouteData.getRoute(activePreset) + private var cumulativeDistances: DoubleArray = PathEngine.calculateCumulativeDistances(activeRoute) + private var routeProfile: RouteProfile = PathEngine.profileRoute(activeRoute) + private var staticVertices: List = PathEngine.buildStaticVertices( + path = activeRoute, + altitudeMode = AltitudeMode.ABSOLUTE, + pathAltitudeOffset = ROUTE_ALTITUDE_OFFSET_METERS + ) + + private var currentDistanceMeters: Double = 0.0 + private var isMapReady: Boolean = false + private var isPlaying: Boolean = false + private var isUserScrubbing: Boolean = false + private var speedMultiplier: Double = 1.0 + private var lookaheadHorizonMeters: Double = DEFAULT_LOOKAHEAD_METERS + private var autoCameraPitchPreview: Boolean = true + + private var smoothedHeadingDegrees: Double? = null + private var smoothedCameraAltitude: Double? = null + private var currentCameraTilt: Double = NORMAL_CAMERA_TILT + private var currentCameraRange: Double = NORMAL_CAMERA_RANGE + + private val _uiState = MutableStateFlow(buildUiState(snapCamera = true)) + + /** Reactive [StateFlow] for Kotlin Views and Jetpack Compose. */ + val uiState: StateFlow = _uiState.asStateFlow() + + /** Lifecycle-aware [LiveData] bridged from [uiState] for Java Views. */ + val uiStateLiveData: LiveData = _uiState.asLiveData() + + /** Synchronous snapshot of the current [HillyRoadUiState]. */ + val currentState: HillyRoadUiState + get() = _uiState.value + + /** + * Notifies the ViewModel that the 3D map has finished initializing and is ready. + * Starts route elevation analysis and simulation playback from the start of the route (`0.0m`). + */ + fun onMapReady() { + if (isMapReady) return + isMapReady = true + currentDistanceMeters = 0.0 + smoothedHeadingDegrees = null + smoothedCameraAltitude = null + isPlaying = true + emitState(snapCamera = true) + } + + /** Toggles vehicle playback between playing and paused. */ + fun togglePlayPause() { + setPlaying(!isPlaying) + } + + /** Explicitly sets the playback running state. */ + fun setPlaying(playing: Boolean) { + if (!isMapReady && playing) { + return + } + if (playing && currentDistanceMeters >= totalDistance() - 0.5) { + currentDistanceMeters = 0.0 + smoothedHeadingDegrees = null + smoothedCameraAltitude = null + } + isPlaying = playing + emitState(snapCamera = false) + } + + /** Updates whether the user is actively scrubbing the progress slider. */ + fun setScrubbing(scrubbing: Boolean) { + isUserScrubbing = scrubbing + emitState(snapCamera = scrubbing) + } + + /** Seeks vehicle position to a normalized progress ratio in `[0.0f, 1.0f]`. */ + fun seekToProgress(ratio: Float) { + val clampedRatio = ratio.coerceIn(0.0f, 1.0f) + currentDistanceMeters = totalDistance() * clampedRatio.toDouble() + smoothedHeadingDegrees = null + smoothedCameraAltitude = null + emitState(snapCamera = true) + } + + /** Seeks vehicle position to an explicit distance in meters along the active route. */ + fun seekToDistance(distanceMeters: Double) { + currentDistanceMeters = distanceMeters.coerceIn(0.0, totalDistance()) + smoothedHeadingDegrees = null + smoothedCameraAltitude = null + emitState(snapCamera = true) + } + + /** + * Seeks ~240m before the first undulating crest so the user immediately observes + * the early warning alert trigger before the vehicle enters the hilly section. + */ + fun jumpToHillyZone() { + val targetDist = RoadTerrainAnalyzer.findFirstHillyAlertTriggerDistance( + path = activeRoute, + preCrestOffsetMeters = 240.0 + ) + currentDistanceMeters = targetDist.coerceIn(0.0, totalDistance()) + smoothedHeadingDegrees = null + smoothedCameraAltitude = null + emitState(snapCamera = true) + } + + /** Sets the playback speed multiplier (`0.5x`, `1x`, `2x`, `3x`, `5x`). */ + fun setSpeedMultiplier(multiplier: Double) { + speedMultiplier = multiplier.coerceIn(0.25, 10.0) + emitState(snapCamera = false) + } + + /** Switches the active 3D route preset and resets vehicle position to the start of the route. */ + fun selectRoute(preset: HillyRoutePreset) { + activePreset = preset + activeRoute = HillyRouteData.getRoute(preset) + cumulativeDistances = PathEngine.calculateCumulativeDistances(activeRoute) + routeProfile = PathEngine.profileRoute(activeRoute) + staticVertices = PathEngine.buildStaticVertices( + path = activeRoute, + altitudeMode = AltitudeMode.ABSOLUTE, + pathAltitudeOffset = ROUTE_ALTITUDE_OFFSET_METERS + ) + currentDistanceMeters = 0.0 + smoothedHeadingDegrees = null + smoothedCameraAltitude = null + emitState(snapCamera = true) + } + + /** Updates the forward terrain lookahead horizon (`250.0`, `500.0`, `750.0` meters). */ + fun setLookaheadHorizon(meters: Double) { + lookaheadHorizonMeters = meters.coerceIn(100.0, 1500.0) + emitState(snapCamera = true) + } + + /** Toggles automatic camera pitch/range adjustment when approaching blind crests or undulations. */ + fun toggleAutoCameraPitchPreview() { + setAutoCameraPitchPreview(!autoCameraPitchPreview) + } + + /** Explicitly enables or disables automatic crest camera pitch preview. */ + fun setAutoCameraPitchPreview(enabled: Boolean) { + autoCameraPitchPreview = enabled + emitState(snapCamera = true) + } + + /** Resets vehicle position to the beginning of the current route. */ + fun reset() { + currentDistanceMeters = 0.0 + smoothedHeadingDegrees = null + smoothedCameraAltitude = null + emitState(snapCamera = true) + } + + /** + * Advances simulation playback by [deltaTimeSeconds] (invoked from Choreographer / Compose frame loop). + * Does not advance until [onMapReady] has been called. + */ + fun advance(deltaTimeSeconds: Double) { + if (!isMapReady || !isPlaying || isUserScrubbing || deltaTimeSeconds <= 0.0) return + + val clampedDt = deltaTimeSeconds.coerceAtMost(0.1) + val totalDist = totalDistance() + if (totalDist <= 0.0) return + + val stepMeters = activePreset.defaultBaseSpeedMps * speedMultiplier * clampedDt + val nextDist = currentDistanceMeters + stepMeters + + if (nextDist >= totalDist) { + currentDistanceMeters = 0.0 + smoothedHeadingDegrees = null + smoothedCameraAltitude = null + emitState(snapCamera = true) + } else { + currentDistanceMeters = nextDist + emitState(snapCamera = false) + } + } + + private fun totalDistance(): Double = cumulativeDistances.lastOrNull() ?: 0.0 + + private fun emitState(snapCamera: Boolean) { + _uiState.value = buildUiState(snapCamera = snapCamera) + } + + private fun buildUiState(snapCamera: Boolean): HillyRoadUiState { + val totalDist = totalDistance() + val clampedDist = currentDistanceMeters.coerceIn(0.0, totalDist) + val progressRatio = if (totalDist > 0.0) { + (clampedDist / totalDist).toFloat().coerceIn(0.0f, 1.0f) + } else { + 0.0f + } + + val interp = PathEngine.interpolatePoint( + path = activeRoute, + cumulativeDistances = cumulativeDistances, + distance = clampedDist, + lookaheadDistance = 28.0 + ) + + val smoothedHeading = PathEngine.smoothHeading( + targetHeading = interp.bearing, + currentHeading = smoothedHeadingDegrees, + isUserScrubbing = isUserScrubbing || snapCamera, + isPlaying = isPlaying + ) + smoothedHeadingDegrees = smoothedHeading + + // Only start forward elevation & terrain analysis once the 3D map is ready + val terrainAlert = if (isMapReady) { + RoadTerrainAnalyzer.analyze( + path = activeRoute, + currentDistanceMeters = clampedDist, + lookaheadHorizonMeters = lookaheadHorizonMeters + ) + } else { + RoadTerrainAnalyzer.emptyAlertState() + } + + val shouldPreviewCrest = autoCameraPitchPreview && ( + terrainAlert.alertType == TerrainAlertType.UNDULATING_ROLLER_COASTER || + terrainAlert.alertType == TerrainAlertType.BLIND_CREST + ) + val targetTilt = if (shouldPreviewCrest) PREVIEW_CAMERA_TILT else NORMAL_CAMERA_TILT + val targetRange = if (shouldPreviewCrest) PREVIEW_CAMERA_RANGE else NORMAL_CAMERA_RANGE + + if (snapCamera || isUserScrubbing) { + currentCameraTilt = targetTilt + currentCameraRange = targetRange + } else { + val blend = 0.12 + currentCameraTilt += (targetTilt - currentCameraTilt) * blend + currentCameraRange += (targetRange - currentCameraRange) * blend + } + + // Smooth camera height adjustment for sharp elevation changes: + // 1. Blend vehicle altitude (70%) with lookahead road altitude (30% at +35m) to round off crests/dips + val lookaheadInterp = PathEngine.interpolatePoint( + path = activeRoute, + cumulativeDistances = cumulativeDistances, + distance = (clampedDist + CAMERA_ALTITUDE_LOOKAHEAD_METERS).coerceAtMost(totalDist), + lookaheadDistance = 15.0 + ) + val blendedSurfaceAltitude = + interp.altitude * (1.0 - CAMERA_LOOKAHEAD_ALTITUDE_WEIGHT) + + lookaheadInterp.altitude * CAMERA_LOOKAHEAD_ALTITUDE_WEIGHT + + // 2. Grade-proportional vertical clearance boost when traversing or approaching sharp slopes + val activeSlopeMagnitude = max( + abs(terrainAlert.currentGradePercent), + if (terrainAlert.distanceToHazardMeters <= 120.0) { + max(terrainAlert.maxPositiveGradePercent, abs(terrainAlert.maxNegativeGradePercent)) * 0.6 + } else { + 0.0 + } + ) + val sharpElevationHeightBoost = if (autoCameraPitchPreview && isMapReady) { + ((activeSlopeMagnitude - 4.0).coerceAtLeast(0.0) * 0.65) + .coerceIn(0.0, MAX_SHARP_ELEVATION_HEIGHT_BOOST_METERS) + } else { + 0.0 + } + + val targetCameraAltitude = PathEngine.calculateCameraAltitude( + altitudeMode = AltitudeMode.ABSOLUTE, + baseAltitude = routeProfile.baseAltitude, + interpolatedAltitude = blendedSurfaceAltitude + sharpElevationHeightBoost, + groundAltitude = routeProfile.baseAltitude, + cameraRange = currentCameraRange, + pathAltitudeOffset = ROUTE_ALTITUDE_OFFSET_METERS + ) + + // 3. EMA vertical damping during playback to eliminate harsh C0 vertex kinks + val previousCameraAlt = smoothedCameraAltitude + val cameraAltitude = if (previousCameraAlt == null || snapCamera || isUserScrubbing || !isPlaying) { + targetCameraAltitude + } else { + previousCameraAlt + (targetCameraAltitude - previousCameraAlt) * CAMERA_ALTITUDE_EMA_ALPHA + } + smoothedCameraAltitude = cameraAltitude + + val vehiclePos = LatLngAltitude( + interp.latLng.latitude, + interp.latLng.longitude, + interp.altitude + VEHICLE_ALTITUDE_OFFSET_METERS + ) + + val cameraCenter = LatLngAltitude( + interp.latLng.latitude, + interp.latLng.longitude, + cameraAltitude + ) + + val traveledVertices = PathEngine.buildProgressVertices( + path = activeRoute, + cumulativeDistances = cumulativeDistances, + elapsedDistance = clampedDist, + currentLatLng = interp.latLng, + waypointIndex = interp.waypointIndex, + altitudeMode = AltitudeMode.ABSOLUTE, + pathAltitudeOffset = ROUTE_ALTITUDE_OFFSET_METERS + ) + + return HillyRoadUiState( + selectedPreset = activePreset, + routePoints = activeRoute, + staticRouteVertices = staticVertices, + traveledRouteVertices = traveledVertices, + isMapReady = isMapReady, + isPlaying = isPlaying, + isUserScrubbing = isUserScrubbing, + progressRatio = progressRatio, + currentDistanceMeters = clampedDist, + totalDistanceMeters = totalDist, + speedMultiplier = speedMultiplier, + lookaheadHorizonMeters = lookaheadHorizonMeters, + autoCameraPitchPreview = autoCameraPitchPreview, + vehiclePosition = vehiclePos, + currentAltitudeMeters = interp.altitude, + currentHeadingDegrees = smoothedHeading, + cameraCenter = cameraCenter, + cameraHeading = smoothedHeading, + cameraTilt = currentCameraTilt, + cameraRange = currentCameraRange, + terrainAlert = terrainAlert + ) + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HillyRouteData.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HillyRouteData.kt new file mode 100644 index 00000000..74f80e32 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HillyRouteData.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps3d.model.LatLngAltitude +import org.json.JSONArray +import org.json.JSONObject + +/** + * Selectable 3D route presets for the Hilly & Undulating (Roller-Coaster) Road Early Alerts sample. + * + * @property displayName Short human-readable label for UI chips. + * @property subtitle Short description of the route terrain character. + */ +enum class HillyRoutePreset( + val displayName: String, + val subtitle: String, + val defaultBaseSpeedMps: Double = 24.0 +) { + /** + * Famous undulating roller-coaster road with rapid crests and dips. + */ + ROLLER_COASTER( + displayName = "Roller-Coaster Rd", + subtitle = "Rapid undulating crests & dips" + ), + + /** + * Sustained steep climb to a sharp summit crest followed by a steep descent. + */ + MOUNTAIN_PASS( + displayName = "Mountain Pass", + subtitle = "Sustained steep climb & blind summit" + ), + + /** + * Gentle coastal route preset. + */ + COASTAL_DRIVE( + displayName = "Coastal Drive (Flat)", + subtitle = "Gentle < 2% grade control route" + ) +} + +/** + * Lazily loads curated 3D route coordinates (`LatLngAltitude` with explicit `ABSOLUTE` elevations + * in meters) from `hilly_routes.json` on the classpath for the Hilly & Undulating Road Alerts sample. + */ +object HillyRouteData { + + private const val ROUTES_RESOURCE_NAME = "hilly_routes.json" + private const val KEY_ROLLER_COASTER = "roller_coaster" + private const val KEY_MOUNTAIN_PASS = "mountain_pass" + private const val KEY_COASTAL_DRIVE = "coastal_drive" + + private val routesByKey: Map> by lazy { + loadAllRoutes() + } + + /** + * Real-world GPS/DEM route trace for the undulating Roller-Coaster road preset. + */ + val SAN_FRANCISCO_HYDE_ST_ROLLER_COASTER: List + get() = routesByKey[KEY_ROLLER_COASTER].orEmpty() + + /** + * Alias for the default Roller-Coaster route ([SAN_FRANCISCO_HYDE_ST_ROLLER_COASTER]). + */ + val ROLLER_COASTER_PATH: List + get() = SAN_FRANCISCO_HYDE_ST_ROLLER_COASTER + + /** + * Real-world GPS/DEM route trace for the Mountain Pass preset. + */ + val MOUNTAIN_PASS_PATH: List + get() = routesByKey[KEY_MOUNTAIN_PASS].orEmpty() + + /** + * Real-world GPS/DEM route trace for the Coastal Drive preset. + */ + val COASTAL_DRIVE_PATH: List + get() = routesByKey[KEY_COASTAL_DRIVE].orEmpty() + + /** + * Returns the 3D coordinate list for the given [preset]. + */ + @JvmStatic + fun getRoute(preset: HillyRoutePreset): List = when (preset) { + HillyRoutePreset.ROLLER_COASTER -> ROLLER_COASTER_PATH + HillyRoutePreset.MOUNTAIN_PASS -> MOUNTAIN_PASS_PATH + HillyRoutePreset.COASTAL_DRIVE -> COASTAL_DRIVE_PATH + } + + private fun loadAllRoutes(): Map> { + val stream = HillyRouteData::class.java.classLoader + ?.getResourceAsStream(ROUTES_RESOURCE_NAME) + ?: HillyRouteData::class.java.getResourceAsStream("/$ROUTES_RESOURCE_NAME") + ?: error("Missing classpath resource: $ROUTES_RESOURCE_NAME") + + val jsonText = stream.bufferedReader(Charsets.UTF_8).use { it.readText() } + val root = JSONObject(jsonText) + return mapOf( + KEY_ROLLER_COASTER to parseCoordinateArray(root.getJSONArray(KEY_ROLLER_COASTER)), + KEY_MOUNTAIN_PASS to parseCoordinateArray(root.getJSONArray(KEY_MOUNTAIN_PASS)), + KEY_COASTAL_DRIVE to parseCoordinateArray(root.getJSONArray(KEY_COASTAL_DRIVE)) + ) + } + + private fun parseCoordinateArray(array: JSONArray): List { + val count = array.length() + val points = ArrayList(count) + for (i in 0 until count) { + val tuple = array.getJSONArray(i) + points.add( + LatLngAltitude( + tuple.getDouble(0), + tuple.getDouble(1), + tuple.getDouble(2) + ) + ) + } + return points + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt index bf97e748..a6c73051 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt @@ -66,6 +66,16 @@ class MaxHeightScrollView @JvmOverloads constructor( super.onMeasure(widthMeasureSpec, constrainedHeightSpec) } + /** + * Dynamically sets the maximum allowed height in pixels and requests a layout pass. + */ + fun setMaxHeightPx(px: Int) { + if (maxHeightPx != px) { + maxHeightPx = px + requestLayout() + } + } + companion object { private const val DEFAULT_MAX_HEIGHT_DP = 480 } diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/RoadTerrainAnalyzer.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/RoadTerrainAnalyzer.kt new file mode 100644 index 00000000..68fa9391 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/RoadTerrainAnalyzer.kt @@ -0,0 +1,1104 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.maps.android.SphericalUtil +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt + +/** + * Type of vertical road inflection point. + */ +enum class ExtremaType { + /** Local elevation peak where road transitions from uphill to downhill. */ + CREST, + + /** Local elevation trough where road transitions from downhill to uphill. */ + DIP +} + +/** + * Classification of upcoming terrain hazards along a 3D route. + */ +enum class TerrainAlertType { + /** Rapid consecutive climbs and descents (≥ 2 crest/dip reversals with |grade| ≥ 5%). */ + UNDULATING_ROLLER_COASTER, + + /** Single sharp summit transition from uphill (≥ +5%) to downhill (≤ -5%) ahead. */ + BLIND_CREST, + + /** Sustained steep uphill grade (≥ +8%) without rapid oscillation. */ + STEEP_CLIMB, + + /** Sustained steep downhill grade (≤ -8%) without rapid oscillation. */ + STEEP_DESCENT, + + /** Normal flat or gently graded road (|grade| < 5% and < 2 reversals). */ + NONE +} + +/** + * Early warning urgency level based on distance to the detected terrain hazard. + */ +enum class AlertSeverity { + /** No terrain hazard within the active lookahead window. */ + NONE, + + /** Early warning (Amber): hazard detected ≥ 150m ahead within the lookahead horizon. */ + CAUTION, + + /** Imminent or active alert (Red): hazard < 150m ahead or vehicle is inside the hazard zone. */ + WARNING +} + +/** + * Represents a detected crest (local peak) or dip (local trough) ahead of the vehicle. + * + * @property id Stable identifier anchored to the route vertex index (e.g., `"crest_5"`, `"dip_6"`). + * @property position 3D geographic coordinates and elevation of the inflection point. + * @property type Whether this point is a [ExtremaType.CREST] or [ExtremaType.DIP]. + * @property distanceAheadMeters Horizontal distance ahead of the vehicle in meters. + * @property gradeDeltaPercent Signed grade change across the inflection point (`gIn - gOut`), + * positive for crests and negative for dips. + * @property approachGradePercent Road grade (%) leading into the inflection point. + */ +data class TerrainExtremaPoint( + @JvmField val id: String, + @JvmField val position: LatLngAltitude, + @JvmField val type: ExtremaType, + @JvmField val distanceAheadMeters: Double, + @JvmField val gradeDeltaPercent: Double, + @JvmField val approachGradePercent: Double = gradeDeltaPercent +) + +/** + * Sampled elevation point within the forward lookahead horizon for HUD sparkline rendering. + * + * @property distanceOffsetMeters Distance ahead of the vehicle (`0.0` to `lookaheadHorizonMeters`). + * @property altitudeMeters Interpolated terrain altitude in meters. + * @property isHazardZone True if this sample lies inside a steep or undulating hazard segment. + */ +data class ElevationProfilePoint( + @JvmField val distanceOffsetMeters: Double, + @JvmField val altitudeMeters: Double, + @JvmField val isHazardZone: Boolean +) + +/** + * Complete snapshot of the forward terrain analysis and early alert state. + * + * @property alertType Classified terrain hazard type in the lookahead window. + * @property severity Alert urgency ([AlertSeverity.NONE], [AlertSeverity.CAUTION], [AlertSeverity.WARNING]). + * @property distanceToHazardMeters Distance from the car to the start of the hazard zone (`0.0` when inside). + * @property remainingHazardDistanceMeters Remaining length of the active or upcoming hazard zone in meters. + * @property currentGradePercent Instantaneous road grade (%) at the vehicle's current position. + * @property maxPositiveGradePercent Maximum uphill grade (%) within the lookahead window. + * @property maxNegativeGradePercent Maximum downhill grade (%) within the lookahead window (≤ 0.0). + * @property cumulativeVerticalChangeMeters Total vertical oscillation (∑ |Δh|) in the lookahead window. + * @property reversalCount Number of crest/dip reversals detected in the lookahead window. + * @property crestCount Number of [ExtremaType.CREST] points in [upcomingExtrema]. + * @property dipCount Number of [ExtremaType.DIP] points in [upcomingExtrema]. + * @property oscillationScore Composite roller-coaster intensity score. + * @property recommendedSpeedKmh Illustrative reference advisory speed in km/h (inspired by AASHTO + * vertical-curve sight distance / comfort equations and MUTCD 5 km/h rounding). **Note:** This + * value is for sample demonstration and reference only, not a real-world regulatory or + * safety-certified speed limit; users must implement their own calculations according to their + * specific vehicle dynamics and application requirements. + * @property upcomingExtrema Ordered list of crests and dips within the lookahead window. + * @property lookaheadProfile Sampled elevation curve for the HUD sparkline. + * @property hazardPolylineCoordinates 3D polyline coordinates highlighting the active hazard zone. + */ +data class TerrainAlertState( + @JvmField val alertType: TerrainAlertType, + @JvmField val severity: AlertSeverity, + @JvmField val distanceToHazardMeters: Double, + @JvmField val remainingHazardDistanceMeters: Double, + @JvmField val currentGradePercent: Double, + @JvmField val maxPositiveGradePercent: Double, + @JvmField val maxNegativeGradePercent: Double, + @JvmField val cumulativeVerticalChangeMeters: Double, + @JvmField val reversalCount: Int, + @JvmField val crestCount: Int, + @JvmField val dipCount: Int, + @JvmField val oscillationScore: Double, + @JvmField val recommendedSpeedKmh: Int, + @JvmField val upcomingExtrema: List, + @JvmField val lookaheadProfile: List, + @JvmField val hazardPolylineCoordinates: List +) + +/** + * Pure Kotlin terrain lookahead and undulation analyzer. + * + * Scans the upcoming `[currentDistanceMeters, currentDistanceMeters + lookaheadHorizonMeters]` + * window along a 3D route to detect crests, dips, steep grades, and undulating ("roller-coaster") + * road zones before the vehicle enters them. + */ +object RoadTerrainAnalyzer { + + /** Minimum horizontal distance in meters to guard against division by zero. */ + const val MIN_HORIZONTAL_DELTA_METERS = 1e-4 + + /** Minimum grade reversal magnitude (|Δgrade| ≥ 4%) required to register a crest or dip. */ + const val MIN_GRADE_DELTA_PERCENT = 4.0 + + /** Minimum vertical prominence (≥ 1.5m) required to register a crest or dip. */ + const val MIN_PROMINENCE_METERS = 1.5 + + /** Grade threshold (|grade| ≥ 5%) for undulating and blind-crest hazard zones. */ + const val HAZARD_GRADE_THRESHOLD_PERCENT = 5.0 + + /** Grade threshold (|grade| ≥ 8%) for sustained steep climb or descent alerts. */ + const val STEEP_GRADE_THRESHOLD_PERCENT = 8.0 + + /** Distance threshold (< 150m) at which CAUTION escalates to WARNING. */ + const val WARNING_PROXIMITY_METERS = 150.0 + + /** Horizontal baseline window (60m) used to smooth micro-segment GPS noise when segments are < 60m. */ + private const val WINDOWED_GRADE_BASELINE_METERS = 60.0 + + /** Minimum horizontal span (15m) required when clamping a slope window to the active lookahead bounds. */ + private const val MIN_WINDOW_SLOPE_SPAN_METERS = 15.0 + + /** Maximum spacing between adjacent extrema hill runs to be grouped into a single undulating cluster. */ + private const val CLUSTER_MAX_SPACING_METERS = 250.0 + + /** Altitude bias (+0.65m) applied to the hazard highlight polyline so it renders above the base route. */ + private const val HAZARD_POLYLINE_ALTITUDE_BIAS_METERS = 0.65 + + /** Number of sample intervals used to build the HUD elevation sparkline profile. */ + private const val SPARKLINE_SAMPLE_STEPS = 32 + + private data class CandidateExtremum( + val type: ExtremaType, + val vertexIndex: Int + ) + + private data class RouteSegment( + val index: Int, + val startDist: Double, + val endDist: Double, + val startPoint: LatLngAltitude, + val endPoint: LatLngAltitude, + val gradePercent: Double, + val monotonicStartIdx: Int, + val monotonicEndIdx: Int + ) + + private data class RouteExtremum( + val id: String, + val vertexIndex: Int, + val routeDistanceMeters: Double, + val position: LatLngAltitude, + val type: ExtremaType, + val gradeDeltaPercent: Double, + val approachGradePercent: Double, + val hazardRunStartDist: Double, + val hazardRunEndDist: Double, + val clusterId: Int, + val clusterSize: Int + ) + + /** + * Computes road grade percentage `(Δaltitude / ΔhorizontalDistance) × 100`, guarding against + * `Δd ≤ 1e-4m` so `NaN` or `Infinity` can never occur. + */ + @JvmStatic + fun computeGradePercent( + deltaAltitudeMeters: Double, + horizontalDistanceMeters: Double + ): Double { + if (horizontalDistanceMeters <= MIN_HORIZONTAL_DELTA_METERS || + deltaAltitudeMeters.isNaN() || + horizontalDistanceMeters.isNaN() + ) { + return 0.0 + } + val grade = (deltaAltitudeMeters / horizontalDistanceMeters) * 100.0 + return if (grade.isFinite()) grade else 0.0 + } + + /** + * Analyzes the upcoming road window `[currentDistanceMeters, currentDistanceMeters + lookaheadHorizonMeters]` + * and returns the complete [TerrainAlertState]. + */ + @JvmStatic + @JvmOverloads + fun analyze( + path: List, + currentDistanceMeters: Double, + lookaheadHorizonMeters: Double = 500.0 + ): TerrainAlertState { + val cleanedPath = removeConsecutiveDuplicates(path) + if (cleanedPath.size < 2) { + return emptyAlertState() + } + + val cumulativeDistances = PathEngine.calculateCumulativeDistances(cleanedPath) + val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 + if (totalDistance <= MIN_HORIZONTAL_DELTA_METERS) { + return emptyAlertState() + } + + val safeHorizon = lookaheadHorizonMeters.coerceAtLeast(10.0) + val clampedCurrentDist = currentDistanceMeters.coerceIn(0.0, totalDistance) + val windowEndDist = (clampedCurrentDist + safeHorizon).coerceAtMost(totalDistance) + + val segments = buildSegments(cleanedPath, cumulativeDistances) + if (segments.isEmpty()) { + return emptyAlertState() + } + + val allExtrema = detectRouteExtrema(cleanedPath, cumulativeDistances, segments) + + // Instantaneous grade at current vehicle position + val currentSeg = segments.firstOrNull { + clampedCurrentDist >= it.startDist && clampedCurrentDist <= it.endDist + } ?: segments.last() + val currentGradePercent = currentSeg.gradePercent + + // If at the very end of the route, return clear state + if (windowEndDist - clampedCurrentDist <= MIN_HORIZONTAL_DELTA_METERS) { + val endPt = cleanedPath.last() + return emptyAlertState( + currentGradePercent = currentGradePercent, + lookaheadProfile = listOf( + ElevationProfilePoint(0.0, endPt.altitude, false) + ) + ) + } + + // Extrema directly inside the lookahead window [clampedCurrentDist, windowEndDist] + val directWindowExtrema = allExtrema.filter { + it.routeDistanceMeters >= clampedCurrentDist - MIN_HORIZONTAL_DELTA_METERS && + it.routeDistanceMeters <= windowEndDist + MIN_HORIZONTAL_DELTA_METERS + } + + // Also check if the lookahead window has entered the climb/descent run of a multi-undulation cluster + val multiUndulationClusters = allExtrema + .filter { it.clusterSize >= 2 && abs(it.approachGradePercent) >= HAZARD_GRADE_THRESHOLD_PERCENT } + .groupBy { it.clusterId } + + val enteredClusterExtrema = mutableListOf() + for ((_, clusterList) in multiUndulationClusters) { + val clusterStart = clusterList.first().hazardRunStartDist + val clusterEnd = clusterList.last().hazardRunEndDist + val overlapsWindow = windowEndDist > clusterStart + MIN_HORIZONTAL_DELTA_METERS && + clampedCurrentDist < clusterEnd - MIN_HORIZONTAL_DELTA_METERS + if (overlapsWindow) { + val upcomingInCluster = clusterList.filter { + it.routeDistanceMeters >= clampedCurrentDist - MIN_HORIZONTAL_DELTA_METERS + } + val previewCount = if (safeHorizon >= 700.0) 2 else 1 + enteredClusterExtrema.addAll(upcomingInCluster.take(previewCount)) + } + } + + val windowExtremaInternal = (directWindowExtrema + enteredClusterExtrema) + .distinctBy { it.id } + .sortedBy { it.routeDistanceMeters } + + val upcomingExtrema = windowExtremaInternal.map { ext -> + TerrainExtremaPoint( + id = ext.id, + position = ext.position, + type = ext.type, + distanceAheadMeters = (ext.routeDistanceMeters - clampedCurrentDist).coerceAtLeast(0.0), + gradeDeltaPercent = ext.gradeDeltaPercent, + approachGradePercent = ext.approachGradePercent + ) + } + + // Segments overlapping [clampedCurrentDist, windowEndDist] + val windowSegments = segments.filter { seg -> + seg.endDist > clampedCurrentDist + MIN_HORIZONTAL_DELTA_METERS && + seg.startDist < windowEndDist - MIN_HORIZONTAL_DELTA_METERS + } + + val windowStartVertexIdx = windowSegments.firstOrNull()?.index ?: 0 + val windowEndVertexIdx = (windowSegments.lastOrNull()?.index ?: 0) + 1 + + var maxPosGrade = 0.0 + var maxNegGrade = 0.0 + var cumulativeVerticalChange = 0.0 + + for (seg in windowSegments) { + val overlapStart = max(seg.startDist, clampedCurrentDist) + val overlapEnd = min(seg.endDist, windowEndDist) + val overlapLen = overlapEnd - overlapStart + if (overlapLen > MIN_HORIZONTAL_DELTA_METERS) { + val lookaheadGrade = computeLookaheadSegmentGrade( + seg = seg, + cleanedPath = cleanedPath, + cumulativeDistances = cumulativeDistances, + windowStartVertexIdx = windowStartVertexIdx, + windowEndVertexIdx = windowEndVertexIdx + ) + if (lookaheadGrade > maxPosGrade) maxPosGrade = lookaheadGrade + if (lookaheadGrade < maxNegGrade) maxNegGrade = lookaheadGrade + cumulativeVerticalChange += abs(seg.gradePercent / 100.0) * overlapLen + } + } + + val reversalCount = upcomingExtrema.size + val crestCount = upcomingExtrema.count { it.type == ExtremaType.CREST } + val dipCount = upcomingExtrema.count { it.type == ExtremaType.DIP } + + // Check whether any extremum in the window belongs to a multi-undulation roller-coaster cluster + val hasMultiUndulationClusterInWindow = windowExtremaInternal.any { ext -> + ext.clusterSize >= 2 && abs(ext.approachGradePercent) >= HAZARD_GRADE_THRESHOLD_PERCENT + } + + val maxAbsGradeInWindow = max(maxPosGrade, abs(maxNegGrade)) + val oscillationScore = cumulativeVerticalChange * (1.0 + reversalCount * 0.6) + + maxAbsGradeInWindow * 0.8 + + // Classify TerrainAlertType + val alertType = classifyAlertType( + windowExtrema = windowExtremaInternal, + hasMultiUndulationClusterInWindow = hasMultiUndulationClusterInWindow, + maxPosGrade = maxPosGrade, + maxNegGrade = maxNegGrade + ) + + // Identify hazard distance bounds & coordinates + val hazardBounds = findHazardBounds( + alertType = alertType, + segments = segments, + allExtrema = allExtrema, + windowExtrema = windowExtremaInternal, + clampedCurrentDist = clampedCurrentDist, + windowEndDist = windowEndDist + ) + + val distanceToHazardMeters: Double + val remainingHazardDistanceMeters: Double + val severity: AlertSeverity + val hazardPolylineCoordinates: List + + if (alertType == TerrainAlertType.NONE || hazardBounds == null) { + distanceToHazardMeters = 0.0 + remainingHazardDistanceMeters = 0.0 + severity = AlertSeverity.NONE + hazardPolylineCoordinates = emptyList() + } else { + val (hazardStartDist, hazardEndDist) = hazardBounds + distanceToHazardMeters = if (hazardStartDist <= clampedCurrentDist + 0.5) { + 0.0 + } else { + hazardStartDist - clampedCurrentDist + } + val activeStart = max(clampedCurrentDist, hazardStartDist) + remainingHazardDistanceMeters = (hazardEndDist - activeStart).coerceAtLeast(0.0) + severity = if (distanceToHazardMeters < WARNING_PROXIMITY_METERS) { + AlertSeverity.WARNING + } else { + AlertSeverity.CAUTION + } + hazardPolylineCoordinates = sliceRouteSegment( + path = cleanedPath, + cumulativeDistances = cumulativeDistances, + startDist = activeStart, + endDist = max(activeStart + 1.0, min(hazardEndDist, windowEndDist)), + altitudeBias = HAZARD_POLYLINE_ALTITUDE_BIAS_METERS + ) + } + + val recommendedSpeedKmh = computeRecommendedSpeedKmh( + alertType = alertType, + severity = severity, + maxPosGrade = maxPosGrade, + maxNegGrade = maxNegGrade, + upcomingExtrema = upcomingExtrema, + reversalCount = reversalCount + ) + + val lookaheadProfile = sampleLookaheadProfile( + path = cleanedPath, + cumulativeDistances = cumulativeDistances, + segments = segments, + clampedCurrentDist = clampedCurrentDist, + windowEndDist = windowEndDist, + hazardBounds = hazardBounds + ) + + return TerrainAlertState( + alertType = alertType, + severity = severity, + distanceToHazardMeters = distanceToHazardMeters, + remainingHazardDistanceMeters = remainingHazardDistanceMeters, + currentGradePercent = currentGradePercent, + maxPositiveGradePercent = maxPosGrade, + maxNegativeGradePercent = maxNegGrade, + cumulativeVerticalChangeMeters = cumulativeVerticalChange, + reversalCount = reversalCount, + crestCount = crestCount, + dipCount = dipCount, + oscillationScore = oscillationScore, + recommendedSpeedKmh = recommendedSpeedKmh, + upcomingExtrema = upcomingExtrema, + lookaheadProfile = lookaheadProfile, + hazardPolylineCoordinates = hazardPolylineCoordinates + ) + } + + /** + * Finds the optimal route distance (~240m before the first hazard climb/crest) for the + * "Jump to Hills" quick action so the user immediately sees the early warning trigger. + */ + @JvmStatic + @JvmOverloads + fun findFirstHillyAlertTriggerDistance( + path: List, + preCrestOffsetMeters: Double = 240.0 + ): Double { + val cleanedPath = removeConsecutiveDuplicates(path) + if (cleanedPath.size < 2) return 0.0 + val cumulativeDistances = PathEngine.calculateCumulativeDistances(cleanedPath) + val segments = buildSegments(cleanedPath, cumulativeDistances) + val extrema = detectRouteExtrema(cleanedPath, cumulativeDistances, segments) + + val firstExt = extrema.firstOrNull() + if (firstExt != null) { + return (firstExt.hazardRunStartDist - preCrestOffsetMeters).coerceAtLeast(0.0) + } + + val firstSteepSegDist = segments.firstOrNull { + abs(it.gradePercent) >= HAZARD_GRADE_THRESHOLD_PERCENT + }?.startDist ?: 0.0 + return (firstSteepSegDist - 180.0).coerceAtLeast(0.0) + } + + private fun removeConsecutiveDuplicates(path: List): List { + if (path.size <= 1) return path + val result = ArrayList(path.size) + result.add(path.first()) + for (i in 1 until path.size) { + val prev = result.last() + val curr = path[i] + val dist = SphericalUtil.computeDistanceBetween( + LatLng(prev.latitude, prev.longitude), + LatLng(curr.latitude, curr.longitude) + ) + if (dist > MIN_HORIZONTAL_DELTA_METERS) { + result.add(curr) + } + } + return result + } + + /** + * Identifies local elevation peaks ([ExtremaType.CREST]) and troughs ([ExtremaType.DIP]) + * using vertical prominence hysteresis ([MIN_PROMINENCE_METERS]). + */ + private fun findCandidateExtrema(cleanedPath: List): List { + val candidates = mutableListOf() + var state = 0 // 0 = initial, 1 = climbing, -1 = descending + var maxIdx = 0 + var minIdx = 0 + + for (i in 1 until cleanedPath.size) { + val alt = cleanedPath[i].altitude + when (state) { + 1 -> { + if (alt >= cleanedPath[maxIdx].altitude) { + maxIdx = i + } else if (cleanedPath[maxIdx].altitude - alt >= MIN_PROMINENCE_METERS) { + candidates.add(CandidateExtremum(ExtremaType.CREST, maxIdx)) + state = -1 + minIdx = i + } + } + -1 -> { + if (alt <= cleanedPath[minIdx].altitude) { + minIdx = i + } else if (alt - cleanedPath[minIdx].altitude >= MIN_PROMINENCE_METERS) { + candidates.add(CandidateExtremum(ExtremaType.DIP, minIdx)) + state = 1 + maxIdx = i + } + } + else -> { + // state == 0 (initial undetermined direction): track both bounds until prominence locks direction + if (alt >= cleanedPath[maxIdx].altitude) { + maxIdx = i + } + if (alt <= cleanedPath[minIdx].altitude) { + minIdx = i + } + if (cleanedPath[maxIdx].altitude - alt >= MIN_PROMINENCE_METERS) { + if (cleanedPath[maxIdx].altitude - cleanedPath[0].altitude >= MIN_PROMINENCE_METERS) { + candidates.add(CandidateExtremum(ExtremaType.CREST, maxIdx)) + } + state = -1 + minIdx = i + } else if (alt - cleanedPath[minIdx].altitude >= MIN_PROMINENCE_METERS) { + if (cleanedPath[0].altitude - cleanedPath[minIdx].altitude >= MIN_PROMINENCE_METERS) { + candidates.add(CandidateExtremum(ExtremaType.DIP, minIdx)) + } + state = 1 + maxIdx = i + } + } + } + } + return candidates + } + + private fun buildSegments( + cleanedPath: List, + cumulativeDistances: DoubleArray + ): List { + val n = cleanedPath.size + val candidates = findCandidateExtrema(cleanedPath) + val turningPoints = IntArray(candidates.size + 2) + turningPoints[0] = 0 + for (k in candidates.indices) { + turningPoints[k + 1] = candidates[k].vertexIndex + } + turningPoints[turningPoints.lastIndex] = n - 1 + + var turnCursor = 0 + val segments = ArrayList(n - 1) + for (i in 0 until n - 1) { + while (turnCursor + 1 < turningPoints.lastIndex && turningPoints[turnCursor + 1] <= i) { + turnCursor++ + } + val monotonicStartIdx = turningPoints[turnCursor] + val monotonicEndIdx = turningPoints[turnCursor + 1].coerceAtLeast(i + 1) + + val d1 = cumulativeDistances[i] + val d2 = cumulativeDistances[i + 1] + val deltaDist = d2 - d1 + val p1 = cleanedPath[i] + val p2 = cleanedPath[i + 1] + + // Use direct segment grade for coarse routes (>= 60m) and a 60m windowed baseline + // clamped to monotonic turning points [monotonicStartIdx, monotonicEndIdx] for dense + // GPS/DEM traces (~1-15m spacing) so the smoothing window never straddles a crest or dip. + val grade = if (deltaDist >= WINDOWED_GRADE_BASELINE_METERS) { + computeGradePercent(p2.altitude - p1.altitude, deltaDist) + } else { + var s = i + var e = i + 1 + while ((s > monotonicStartIdx || e < monotonicEndIdx) && + (cumulativeDistances[e] - cumulativeDistances[s]) < WINDOWED_GRADE_BASELINE_METERS + ) { + if (s > monotonicStartIdx) s-- + if ((cumulativeDistances[e] - cumulativeDistances[s]) < WINDOWED_GRADE_BASELINE_METERS && + e < monotonicEndIdx + ) { + e++ + } + } + val winDist = cumulativeDistances[e] - cumulativeDistances[s] + val winAlt = cleanedPath[e].altitude - cleanedPath[s].altitude + computeGradePercent(winAlt, winDist) + } + + segments.add( + RouteSegment( + index = i, + startDist = d1, + endDist = d2, + startPoint = p1, + endPoint = p2, + gradePercent = grade, + monotonicStartIdx = monotonicStartIdx, + monotonicEndIdx = monotonicEndIdx + ) + ) + } + return segments + } + + /** + * Computes a segment's grade strictly within the intersection of its monotonic slope interval + * `[seg.monotonicStartIdx, seg.monotonicEndIdx]` and the active lookahead window + * `[windowStartVertexIdx, windowEndVertexIdx]`, preventing grades behind the vehicle from + * bleeding into upcoming `Max Grade` telemetry. + */ + private fun computeLookaheadSegmentGrade( + seg: RouteSegment, + cleanedPath: List, + cumulativeDistances: DoubleArray, + windowStartVertexIdx: Int, + windowEndVertexIdx: Int + ): Double { + val segLen = seg.endDist - seg.startDist + if (segLen >= WINDOWED_GRADE_BASELINE_METERS) { + return seg.gradePercent + } + val leftBound = max(seg.monotonicStartIdx, windowStartVertexIdx).coerceAtMost(seg.index) + val rightBound = min(seg.monotonicEndIdx, windowEndVertexIdx).coerceAtLeast(seg.index + 1) + var s = seg.index + var e = seg.index + 1 + while ((s > leftBound || e < rightBound) && + (cumulativeDistances[e] - cumulativeDistances[s]) < WINDOWED_GRADE_BASELINE_METERS + ) { + if (s > leftBound) s-- + if ((cumulativeDistances[e] - cumulativeDistances[s]) < WINDOWED_GRADE_BASELINE_METERS && + e < rightBound + ) { + e++ + } + } + val winDist = cumulativeDistances[e] - cumulativeDistances[s] + val fullMonotonicSpan = + cumulativeDistances[seg.monotonicEndIdx] - cumulativeDistances[seg.monotonicStartIdx] + val minRequiredSpan = min(MIN_WINDOW_SLOPE_SPAN_METERS, fullMonotonicSpan) + if (winDist < minRequiredSpan) { + return 0.0 + } + val winAlt = cleanedPath[e].altitude - cleanedPath[s].altitude + return computeGradePercent(winAlt, winDist) + } + + private fun detectRouteExtrema( + cleanedPath: List, + cumulativeDistances: DoubleArray, + segments: List + ): List { + if (segments.size < 2) return emptyList() + + data class RawExtremum( + val id: String, + val vertexIndex: Int, + val routeDistanceMeters: Double, + val position: LatLngAltitude, + val type: ExtremaType, + val gradeDeltaPercent: Double, + val approachGradePercent: Double, + val hazardRunStartDist: Double, + val hazardRunEndDist: Double + ) + + // 1. Identify local elevation peaks (CREST) and troughs (DIP) using vertical prominence hysteresis + val candidates = findCandidateExtrema(cleanedPath) + + val rawExtrema = mutableListOf() + + // 2. Evaluate incoming/outgoing grades and hazard run bounds for each candidate peak/trough + for ((idx, candidate) in candidates.withIndex()) { + val vIdx = candidate.vertexIndex + if (vIdx <= 0 || vIdx >= cleanedPath.size - 1) continue + + val rStart = if (idx > 0) candidates[idx - 1].vertexIndex else 0 + val rEnd = if (idx + 1 < candidates.size) { + candidates[idx + 1].vertexIndex + } else { + cleanedPath.size - 1 + } + + var effectiveGIn = segments[vIdx - 1].gradePercent + for (segIdx in rStart until vIdx) { + val segGrade = segments[segIdx].gradePercent + if (candidate.type == ExtremaType.CREST && segGrade > effectiveGIn) { + effectiveGIn = segGrade + } else if (candidate.type == ExtremaType.DIP && segGrade < effectiveGIn) { + effectiveGIn = segGrade + } + } + + var effectiveGOut = segments[vIdx].gradePercent + for (segIdx in vIdx until rEnd) { + val segGrade = segments[segIdx].gradePercent + if (candidate.type == ExtremaType.CREST && segGrade < effectiveGOut) { + effectiveGOut = segGrade + } else if (candidate.type == ExtremaType.DIP && segGrade > effectiveGOut) { + effectiveGOut = segGrade + } + } + + val gradeDelta = effectiveGIn - effectiveGOut // Positive at CREST, negative at DIP + val hasSteepLeg = abs(effectiveGIn) >= HAZARD_GRADE_THRESHOLD_PERCENT || + abs(effectiveGOut) >= HAZARD_GRADE_THRESHOLD_PERCENT + + val isValid = if (candidate.type == ExtremaType.CREST) { + effectiveGIn > 0.5 && + effectiveGOut < -0.5 && + gradeDelta >= MIN_GRADE_DELTA_PERCENT && + hasSteepLeg + } else { + effectiveGIn < -0.5 && + effectiveGOut > 0.5 && + abs(gradeDelta) >= MIN_GRADE_DELTA_PERCENT && + hasSteepLeg && + !(idx == 0 && abs(effectiveGIn) < HAZARD_GRADE_THRESHOLD_PERCENT) + } + + if (isValid) { + val inStart = if (idx > 0) (rStart + vIdx) / 2 else 0 + val outEnd = if (idx + 1 < candidates.size) (vIdx + rEnd + 1) / 2 else rEnd + + val firstHazardInSegIdx = (inStart until vIdx).firstOrNull { + abs(segments[it].gradePercent) >= HAZARD_GRADE_THRESHOLD_PERCENT + } ?: (vIdx - 1).coerceAtLeast(0) + val lastHazardOutSegIdx = (vIdx until outEnd).lastOrNull { + abs(segments[it].gradePercent) >= HAZARD_GRADE_THRESHOLD_PERCENT + } ?: vIdx.coerceAtMost(segments.size - 1) + + val hazardRunStartDist = segments[firstHazardInSegIdx].startDist + val hazardRunEndDist = segments[lastHazardOutSegIdx].endDist + val prefix = if (candidate.type == ExtremaType.CREST) "crest" else "dip" + + rawExtrema.add( + RawExtremum( + id = "${prefix}_$vIdx", + vertexIndex = vIdx, + routeDistanceMeters = cumulativeDistances[vIdx], + position = cleanedPath[vIdx], + type = candidate.type, + gradeDeltaPercent = gradeDelta, + approachGradePercent = effectiveGIn, + hazardRunStartDist = hazardRunStartDist, + hazardRunEndDist = hazardRunEndDist + ) + ) + } + } + + if (rawExtrema.isEmpty()) return emptyList() + + // Group adjacent extrema into clusters when their hill runs connect or are spaced <= CLUSTER_MAX_SPACING_METERS + val clusterIds = IntArray(rawExtrema.size) + var currentClusterId = 0 + clusterIds[0] = currentClusterId + for (idx in 1 until rawExtrema.size) { + val prev = rawExtrema[idx - 1] + val curr = rawExtrema[idx] + val runGap = curr.hazardRunStartDist - prev.hazardRunEndDist + val vertexSpacing = curr.routeDistanceMeters - prev.routeDistanceMeters + if (runGap <= CLUSTER_MAX_SPACING_METERS || vertexSpacing <= CLUSTER_MAX_SPACING_METERS) { + clusterIds[idx] = currentClusterId + } else { + currentClusterId++ + clusterIds[idx] = currentClusterId + } + } + + val clusterSizes = clusterIds.toList().groupingBy { it }.eachCount() + + return rawExtrema.mapIndexed { idx, raw -> + val cid = clusterIds[idx] + RouteExtremum( + id = raw.id, + vertexIndex = raw.vertexIndex, + routeDistanceMeters = raw.routeDistanceMeters, + position = raw.position, + type = raw.type, + gradeDeltaPercent = raw.gradeDeltaPercent, + approachGradePercent = raw.approachGradePercent, + hazardRunStartDist = raw.hazardRunStartDist, + hazardRunEndDist = raw.hazardRunEndDist, + clusterId = cid, + clusterSize = clusterSizes[cid] ?: 1 + ) + } + } + + private fun classifyAlertType( + windowExtrema: List, + hasMultiUndulationClusterInWindow: Boolean, + maxPosGrade: Double, + maxNegGrade: Double + ): TerrainAlertType { + val significantExtremaCount = windowExtrema.count { + abs(it.approachGradePercent) >= HAZARD_GRADE_THRESHOLD_PERCENT || + abs(it.gradeDeltaPercent) >= HAZARD_GRADE_THRESHOLD_PERCENT * 2.0 + } + + // 1. Undulating roller-coaster road: >= 2 significant reversals in window, + // or leading/trailing extremum of a multi-undulation cluster inside the window. + if (significantExtremaCount >= 2 || hasMultiUndulationClusterInWindow) { + return TerrainAlertType.UNDULATING_ROLLER_COASTER + } + + // 2. Single blind crest: sharp uphill (>= +5%) to downhill (<= -5%) transition + val hasBlindCrest = windowExtrema.any { ext -> + ext.type == ExtremaType.CREST && + ext.approachGradePercent >= HAZARD_GRADE_THRESHOLD_PERCENT && + (ext.gradeDeltaPercent - ext.approachGradePercent) >= HAZARD_GRADE_THRESHOLD_PERCENT + } + if (hasBlindCrest) { + return TerrainAlertType.BLIND_CREST + } + + // 3. Sustained steep climb or descent (>= 8% magnitude without rapid oscillation) + if (maxPosGrade >= STEEP_GRADE_THRESHOLD_PERCENT && maxPosGrade >= abs(maxNegGrade)) { + return TerrainAlertType.STEEP_CLIMB + } + if (maxNegGrade <= -STEEP_GRADE_THRESHOLD_PERCENT) { + return TerrainAlertType.STEEP_DESCENT + } + + return TerrainAlertType.NONE + } + + private fun findHazardBounds( + alertType: TerrainAlertType, + segments: List, + allExtrema: List, + windowExtrema: List, + clampedCurrentDist: Double, + windowEndDist: Double + ): Pair? { + if (alertType == TerrainAlertType.NONE) return null + + if (alertType == TerrainAlertType.UNDULATING_ROLLER_COASTER && windowExtrema.isNotEmpty()) { + val activeClusterIds = windowExtrema.map { it.clusterId }.toSet() + val clusterExtrema = allExtrema.filter { it.clusterId in activeClusterIds } + val firstExt = clusterExtrema.firstOrNull() ?: windowExtrema.first() + val lastExt = clusterExtrema.lastOrNull() ?: windowExtrema.last() + + return Pair(firstExt.hazardRunStartDist, lastExt.hazardRunEndDist) + } + + if (alertType == TerrainAlertType.BLIND_CREST && windowExtrema.isNotEmpty()) { + val crest = windowExtrema.firstOrNull { it.type == ExtremaType.CREST } ?: windowExtrema.first() + val startSegIdx = (crest.vertexIndex - 1).coerceAtLeast(0) + val endSegIdx = crest.vertexIndex.coerceAtMost(segments.size - 1) + return Pair(segments[startSegIdx].startDist, segments[endSegIdx].endDist) + } + + val threshold = if (alertType == TerrainAlertType.STEEP_CLIMB || + alertType == TerrainAlertType.STEEP_DESCENT + ) { + STEEP_GRADE_THRESHOLD_PERCENT + } else { + HAZARD_GRADE_THRESHOLD_PERCENT + } + + val firstHazardIdx = segments.indexOfFirst { seg -> + seg.endDist > clampedCurrentDist + MIN_HORIZONTAL_DELTA_METERS && + seg.startDist < windowEndDist - MIN_HORIZONTAL_DELTA_METERS && + abs(seg.gradePercent) >= threshold + } + if (firstHazardIdx < 0) return null + + var lastHazardIdx = firstHazardIdx + while (lastHazardIdx + 1 < segments.size && + abs(segments[lastHazardIdx + 1].gradePercent) >= threshold + ) { + lastHazardIdx++ + } + + return Pair(segments[firstHazardIdx].startDist, segments[lastHazardIdx].endDist) + } + + /** + * Computes an illustrative advisory speed (km/h) inspired by **AASHTO "A Policy on Geometric + * Design of Highways and Streets" (Green Book)** vertical-curve and grade-braking equations, + * rounded to 5 km/h increments per **MUTCD W13-1P** convention: + * + * 1. **Crest Stopping Sight Distance (SSD)**: + * `SSD = sqrt(658 * L / A)` (driver eye height `1.08m`, object height `0.60m`, vertical + * curve length `L = 60m`, algebraic grade change `A = |gIn - gOut|`), solved for speed `V` + * from `SSD = 0.278 * V * t_r + V^2 / (254 * (a / 9.81 - G_down))` with `t_r = 2.5s` and + * `a = 3.4 m/s^2`. + * 2. **Sag / Undulating Vertical Comfort Acceleration (`a_v <= 0.3 m/s^2`)**: + * `V_comfort = sqrt(395 * L / A)` with multi-reversal damping for consecutive undulations. + * 3. **Sustained Grade Braking / Climb Adjustment**: + * Adjusts baseline speed (`80 km/h`) for downhill braking reduction `(a / 9.81 - |G|)` or + * sustained uphill grade resistance. + * + * **DISCLAIMER:** This calculation is provided **for reference and sample demonstration only** + * and does **not** produce real-world regulatory or safety-certified values. Developers and + * users must implement and validate their own speed calculations according to their specific + * vehicle dynamics, road geometry data, and application requirements. + */ + private fun computeRecommendedSpeedKmh( + alertType: TerrainAlertType, + severity: AlertSeverity, + maxPosGrade: Double, + maxNegGrade: Double, + upcomingExtrema: List, + reversalCount: Int + ): Int { + val baselineSpeedKmh = 80 + if (alertType == TerrainAlertType.NONE || severity == AlertSeverity.NONE) { + return baselineSpeedKmh + } + + // Reference constants (for demonstration only; not exact or certified values) + val curveLengthMeters = WINDOWED_GRADE_BASELINE_METERS // L = 60.0m effective vertical curve length + val aashtoFriction = 3.4 / 9.81 // a = 3.4 m/s^2 comfortable deceleration (~0.3466g) + val reactionTimeSec = 2.5 + val maxAbsGrade = max(maxPosGrade, abs(maxNegGrade)) + + val maxExtremaDelta = upcomingExtrema.maxOfOrNull { abs(it.gradeDeltaPercent) } + ?: (maxAbsGrade * 2.0) + val algebraicGradeDiff = max(maxExtremaDelta, HAZARD_GRADE_THRESHOLD_PERCENT * 2.0) + val downhillSlopeRatio = (abs(maxNegGrade) / 100.0).coerceIn(0.0, 0.20) + val uphillSlopeRatio = (maxPosGrade / 100.0).coerceIn(0.0, 0.20) + + // Helper: Solve AASHTO SSD quadratic for maximum speed V (km/h) given available sight distance + fun speedFromStoppingSightDistance(ssdMeters: Double, downhillGradeRatio: Double): Double { + val effectiveFriction = (aashtoFriction - downhillGradeRatio).coerceAtLeast(0.15) + val k1 = 0.278 * reactionTimeSec + val k2 = 1.0 / (254.0 * effectiveFriction) + val discriminant = (k1 * k1) + (4.0 * k2 * ssdMeters) + return (-k1 + sqrt(discriminant)) / (2.0 * k2) + } + + val rawAdvisoryKmh: Double = when (alertType) { + TerrainAlertType.UNDULATING_ROLLER_COASTER -> { + // 1. AASHTO sag/undulating comfort limit (a_v <= 0.3 m/s^2): V = sqrt(395 * L / A) + val comfortSpeedKmh = sqrt((395.0 * curveLengthMeters) / algebraicGradeDiff) + // 2. AASHTO crest sight-distance limit across undulating peaks + val crestSsdMeters = sqrt((658.0 * curveLengthMeters) / algebraicGradeDiff) + val ssdSpeedKmh = speedFromStoppingSightDistance(crestSsdMeters, downhillSlopeRatio) + // Multi-reversal damping when consecutive crests/dips compound vertical oscillation + val extraReversals = (reversalCount - 1).coerceAtLeast(0) + val undulationDamping = 1.0 / (1.0 + 0.05 * extraReversals) + (min(comfortSpeedKmh, ssdSpeedKmh * 1.15) * undulationDamping).coerceIn(30.0, 60.0) + } + + TerrainAlertType.BLIND_CREST -> { + // AASHTO crest vertical curve Stopping Sight Distance: SSD = sqrt(658 * L / A) + val crestDelta = upcomingExtrema + .filter { it.type == ExtremaType.CREST } + .maxOfOrNull { abs(it.gradeDeltaPercent) } + ?: algebraicGradeDiff + val crestSsdMeters = sqrt((658.0 * curveLengthMeters) / max(crestDelta, 10.0)) + speedFromStoppingSightDistance(crestSsdMeters, downhillSlopeRatio).coerceIn(35.0, 65.0) + } + + TerrainAlertType.STEEP_DESCENT -> { + // Grade-adjusted braking speed maintaining equivalent level-road stopping distance + val effectiveDecelRatio = (aashtoFriction - downhillSlopeRatio).coerceAtLeast(0.15) + (baselineSpeedKmh * sqrt(effectiveDecelRatio / (aashtoFriction + 0.08))).coerceIn(40.0, 65.0) + } + + TerrainAlertType.STEEP_CLIMB -> { + // Sustained uphill grade reduction from baseline speed + (baselineSpeedKmh * sqrt(aashtoFriction / (aashtoFriction + uphillSlopeRatio + 0.08))) + .coerceIn(45.0, 70.0) + } + + TerrainAlertType.NONE -> baselineSpeedKmh.toDouble() + } + + // Round down to nearest 5 km/h increment per MUTCD W13-1P advisory speed plaque convention + return (rawAdvisoryKmh.roundToInt() / 5) * 5 + } + + private fun sampleLookaheadProfile( + path: List, + cumulativeDistances: DoubleArray, + segments: List, + clampedCurrentDist: Double, + windowEndDist: Double, + hazardBounds: Pair? + ): List { + val span = (windowEndDist - clampedCurrentDist).coerceAtLeast(1.0) + val samples = ArrayList(SPARKLINE_SAMPLE_STEPS + 1) + + for (step in 0..SPARKLINE_SAMPLE_STEPS) { + val fraction = step.toDouble() / SPARKLINE_SAMPLE_STEPS.toDouble() + val offsetMeters = span * fraction + val sampleDist = (clampedCurrentDist + offsetMeters).coerceAtMost(windowEndDist) + val interp = PathEngine.interpolatePoint(path, cumulativeDistances, sampleDist) + + val segAtSample = segments.firstOrNull { + sampleDist >= it.startDist && sampleDist <= it.endDist + } + val inHazardBounds = hazardBounds != null && + sampleDist >= hazardBounds.first - 0.5 && + sampleDist <= hazardBounds.second + 0.5 + val isSteepAtSample = segAtSample != null && + abs(segAtSample.gradePercent) >= HAZARD_GRADE_THRESHOLD_PERCENT + + samples.add( + ElevationProfilePoint( + distanceOffsetMeters = offsetMeters, + altitudeMeters = interp.altitude, + isHazardZone = inHazardBounds || isSteepAtSample + ) + ) + } + return samples + } + + private fun sliceRouteSegment( + path: List, + cumulativeDistances: DoubleArray, + startDist: Double, + endDist: Double, + altitudeBias: Double + ): List { + if (endDist - startDist <= MIN_HORIZONTAL_DELTA_METERS || path.size < 2) { + return emptyList() + } + val coords = mutableListOf() + val startInterp = PathEngine.interpolatePoint(path, cumulativeDistances, startDist) + coords.add( + LatLngAltitude( + startInterp.latLng.latitude, + startInterp.latLng.longitude, + startInterp.altitude + altitudeBias + ) + ) + + for (i in path.indices) { + val d = cumulativeDistances[i] + if (d > startDist + 0.2 && d < endDist - 0.2) { + val pt = path[i] + coords.add(LatLngAltitude(pt.latitude, pt.longitude, pt.altitude + altitudeBias)) + } + } + + val endInterp = PathEngine.interpolatePoint(path, cumulativeDistances, endDist) + coords.add( + LatLngAltitude( + endInterp.latLng.latitude, + endInterp.latLng.longitude, + endInterp.altitude + altitudeBias + ) + ) + return coords + } + + @JvmStatic + @JvmOverloads + fun emptyAlertState( + currentGradePercent: Double = 0.0, + lookaheadProfile: List = emptyList() + ): TerrainAlertState = TerrainAlertState( + alertType = TerrainAlertType.NONE, + severity = AlertSeverity.NONE, + distanceToHazardMeters = 0.0, + remainingHazardDistanceMeters = 0.0, + currentGradePercent = currentGradePercent, + maxPositiveGradePercent = 0.0, + maxNegativeGradePercent = 0.0, + cumulativeVerticalChangeMeters = 0.0, + reversalCount = 0, + crestCount = 0, + dipCount = 0, + oscillationScore = 0.0, + recommendedSpeedKmh = 80, + upcomingExtrema = emptyList(), + lookaheadProfile = lookaheadProfile, + hazardPolylineCoordinates = emptyList() + ) +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_hilly_road_alerts.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_hilly_road_alerts.xml new file mode 100644 index 00000000..318c3d4b --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_hilly_road_alerts.xml @@ -0,0 +1,488 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml index 4e9783f3..7d09b031 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml @@ -350,4 +350,36 @@ Clear Filters 3D View 2D View + + + Hilly & Undulating Road Alerts + Terrain Lookahead Controls + 🎢 Roller-Coaster Terrain + ⚠️ Blind Crest Ahead + ⛰️ Steep Climb Ahead + ⛰️ Steep Descent Ahead + ✅ Clear Road Profile + In %1$d m + ACTIVE — %1$d m left + Normal Grade + %1$d Crests / %2$d Dips + Max Grade: +%1$.1f%% / %2$.1f%% + Advisory: %1$d km/h + Lookahead Elevation (Next %1$d m) + Grade: %1$+.1f%% • %2$d / %3$d m + Jump to Hills + Route Preset + Roller-Coaster + Mountain Pass + Coastal + Lookahead Horizon + 250m + 500m + 750m + Playback Speed + Crest Camera Preview + Crest (%1$+.0f%%) + Dip (%1$+.0f%%) + Vehicle + %1$s • %2$d m ahead (%3$d m elev) diff --git a/Maps3DSamples/ApiDemos/common/src/main/resources/hilly_routes.json b/Maps3DSamples/ApiDemos/common/src/main/resources/hilly_routes.json new file mode 100644 index 00000000..f551a108 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/resources/hilly_routes.json @@ -0,0 +1,1188 @@ +{ + "roller_coaster": [ + [36.4859382, -114.5328932, 611.4035034179688], + [36.4859602, -114.5328593, 611.0433349609375], + [36.4859882, -114.53282970000001, 610.7452392578125], + [36.4860067, -114.5328175, 610.6300048828125], + [36.486034, -114.5328071, 610.59814453125], + [36.486065499999995, -114.53279889999999, 610.6231689453125], + [36.4860919, -114.53280079999999, 610.6760864257812], + [36.486134266666745, -114.5328039333299, 610.7490234375], + [36.486176633333415, -114.53280706666324, 610.8128662109375], + [36.486219, -114.5328102, 610.85595703125], + [36.486257066668315, -114.53281906664489, 610.9247436523438], + [36.48629513333595, -114.53282793329848, 610.9803466796875], + [36.48633320000295, -114.53283679996078, 610.994873046875], + [36.48637126666929, -114.5328456666318, 610.9976196289062], + [36.48640933333497, -114.53285453331155, 610.947509765625], + [36.486447399999996, -114.5328634, 610.8934936523438], + [36.4864656, -114.53286759999999, 610.8876342773438], + [36.48650730000023, -114.5328726999945, 610.8700561523438], + [36.486549000000224, -114.53287779999451, 610.8429565429688], + [36.4865907, -114.53288289999999, 610.7888793945312], + [36.486633500000345, -114.53288814999128, 610.7325439453125], + [36.48667630000045, -114.5328933999884, 610.701171875], + [36.48671910000034, -114.5328986499913, 610.6663818359375], + [36.4867619, -114.5329039, 610.6448364257812], + [36.48679770000032, -114.53290829999186, 610.6190185546875], + [36.486833500000486, -114.53291269998779, 610.6114501953125], + [36.48686930000048, -114.5329170999878, 610.6011352539062], + [36.48690510000032, -114.53292149999186, 610.5978393554688], + [36.4869409, -114.5329259, 610.5978393554688], + [36.48697310000013, -114.53292983333004, 610.5870971679688], + [36.48700530000013, -114.53293376666338, 610.522216796875], + [36.4870375, -114.53293769999999, 610.4641723632812], + [36.4870547, -114.53294079999999, 610.4386596679688], + [36.48708710000014, -114.53294654999759, 610.4063720703125], + [36.4871195, -114.53295229999999, 610.3834838867188], + [36.48715723333372, -114.53295906666008, 610.3411254882812], + [36.487194966667055, -114.53296583332674, 610.28271484375], + [36.4872327, -114.5329726, 610.2415771484375], + [36.4872582, -114.5329771, 610.2060546875], + [36.4872969, -114.53298819999999, 610.1924438476562], + [36.48733204000171, -114.53299831998162, 610.1393432617188], + [36.487367180002565, -114.53300843997246, 610.0961303710938], + [36.48740232000256, -114.53301855997246, 610.0714721679688], + [36.487437460001715, -114.53302867998164, 610.0458984375], + [36.4874726, -114.5330388, 610.0499877929688], + [36.487515750002885, -114.5330539999746, 610.1242065429688], + [36.487558900003854, -114.53306919996614, 610.2655639648438], + [36.4876020500029, -114.53308439997461, 610.2327270507812], + [36.487645199999996, -114.5330996, 610.1926879882812], + [36.487681699999996, -114.5331124, 609.7767333984375], + [36.4877176500011, -114.53312864999246, 609.3724365234375], + [36.4877536, -114.5331449, 608.7218017578125], + [36.48778435000081, -114.53315879999447, 607.5687255859375], + [36.4878151, -114.5331727, 606.4721069335938], + [36.48785303333578, -114.53318983331656, 604.9920654296875], + [36.48789096666911, -114.53320696664987, 603.1375732421875], + [36.4879289, -114.5332241, 601.6099853515625], + [36.4879572500004, -114.53323384999644, 600.9842529296875], + [36.4879856, -114.5332436, 600.2947998046875], + [36.48801145000033, -114.53325249999703, 599.8776245117188], + [36.488037299999995, -114.5332614, 599.7463989257812], + [36.488068400000365, -114.53327084999623, 599.589111328125], + [36.4880995, -114.5332803, 599.5237426757812], + [36.48813312500046, -114.53328639999206, 599.5816650390625], + [36.488166750000616, -114.53329249998941, 599.6360473632812], + [36.48820037500046, -114.53329859999205, 600.0775756835938], + [36.488234, -114.53330469999999, 600.5988159179688], + [36.4882746, -114.5333062, 601.6726684570312], + [36.48829805, -114.53330709999973, 602.600830078125], + [36.4883215, -114.53330799999999, 603.5348510742188], + [36.488340099999995, -114.53330729999999, 604.2611083984375], + [36.48838155, -114.53330570000084, 606.0333862304688], + [36.488423, -114.5333041, 607.8095703125], + [36.4884543, -114.5333009, 608.6356201171875], + [36.48848440000004, -114.53329775000122, 609.3355712890625], + [36.4885145, -114.53329459999999, 610.016357421875], + [36.488519499999995, -114.5332936, 609.9798583984375], + [36.488554633333784, -114.53328620000671, 609.6737670898438], + [36.488589766667126, -114.5332788000067, 609.292724609375], + [36.4886249, -114.53327139999999, 608.358642578125], + [36.4886323, -114.5332701, 608.1192016601562], + [36.48866475000011, -114.53326480000221, 607.0414428710938], + [36.4886972, -114.5332595, 605.9854736328125], + [36.4887255, -114.53325489999999, 605.1693725585938], + [36.48876826666671, -114.53325236666944, 604.1343383789062], + [36.48881103333338, -114.53324983333613, 603.5841674804688], + [36.4888538, -114.5332473, 603.088623046875], + [36.488889133333345, -114.53324823333247, 602.8900756835938], + [36.488924466666674, -114.5332491666658, 602.690673828125], + [36.488959799999996, -114.53325009999999, 602.5524291992188], + [36.48899863333335, -114.53325153333189, 602.4931640625], + [36.48903746666668, -114.53325296666523, 602.418212890625], + [36.4890763, -114.53325439999999, 602.3070678710938], + [36.48911116666685, -114.53325909999577, 602.30322265625], + [36.48914603333352, -114.53326379999574, 602.2916870117188], + [36.4891809, -114.53326849999999, 602.2610473632812], + [36.48920745000031, -114.53327699999707, 602.3361206054688], + [36.489233999999996, -114.53328549999999, 602.3655395507812], + [36.4892699, -114.5332969, 602.35302734375], + [36.48931030000096, -114.53331209999207, 602.3410034179688], + [36.489350699999996, -114.5333273, 602.2740478515625], + [36.48937775000077, -114.53334099999522, 602.2865600585938], + [36.489404799999996, -114.53335469999999, 602.3082885742188], + [36.48944580001005, -114.53337474279347, 602.2672119140625], + [36.489486800016756, -114.53339478560818, 602.1795043945312], + [36.48952780002011, -114.53341482844411, 602.0203857421875], + [36.48956880002011, -114.53343487130124, 601.8087158203125], + [36.48960980001676, -114.5334549141796, 601.5488891601562], + [36.48965080001006, -114.53347495707919, 601.2576904296875], + [36.489691799999996, -114.53349499999999, 600.9573364257812], + [36.48972500000119, -114.53351194999271, 600.6456909179688], + [36.4897582, -114.5335289, 600.3026123046875], + [36.489764799999996, -114.5335322, 600.2330932617188], + [36.48980525000176, -114.53355284998919, 599.7881469726562], + [36.4898457, -114.5335735, 599.2698974609375], + [36.48988290000076, -114.53358699999352, 598.8096313476562], + [36.4899201, -114.53360049999999, 598.3429565429688], + [36.4899346, -114.53360490000001, 598.163330078125], + [36.489958250000214, -114.53361204999783, 597.8648071289062], + [36.4899819, -114.53361919999999, 597.5830078125], + [36.49001490000038, -114.5336260666608, 597.2672729492188], + [36.490047900000384, -114.53363293332747, 596.9530639648438], + [36.490080899999995, -114.53363979999999, 596.7354736328125], + [36.4901115, -114.5336438, 596.59765625], + [36.4901436666668, -114.5336479666632, 596.4557495117188], + [36.490175833333474, -114.53365213332987, 596.4440307617188], + [36.490207999999996, -114.5336563, 596.4439697265625], + [36.49024930000006, -114.53366029999786, 596.4129028320312], + [36.4902906, -114.5336643, 596.3289794921875], + [36.49031940000002, -114.53366704999898, 596.28466796875], + [36.4903482, -114.5336698, 596.3543701171875], + [36.490388800000105, -114.53367326666303, 596.4501953125], + [36.490429400000096, -114.53367673332968, 596.5587158203125], + [36.490469999999995, -114.53368019999999, 596.6627197265625], + [36.4904811, -114.5336811, 596.689208984375], + [36.490519275000125, -114.53368429999526, 596.6802978515625], + [36.49055745000017, -114.53368749999366, 596.65087890625], + [36.49059562500013, -114.53369069999526, 596.62841796875], + [36.4906338, -114.53369389999999, 596.6173095703125], + [36.490649499999996, -114.53369490000001, 596.6168823242188], + [36.49069440000011, -114.5336978499949, 596.6243896484375], + [36.49073930000015, -114.53370079999317, 596.6395263671875], + [36.49078420000011, -114.53370374999487, 596.6598510742188], + [36.4908291, -114.5337067, 596.6790161132812], + [36.4908443, -114.5337077, 596.7035522460938], + [36.49088385000014, -114.53370215000282, 596.8092651367188], + [36.49092340000001, -114.5336966, 596.9305419921875], + [36.49096490000054, -114.5336852000061, 597.127197265625], + [36.491006399999996, -114.53367379999999, 597.3132934570312], + [36.4910316, -114.5336669, 597.4240112304688], + [36.49106715000427, -114.53364842502545, 597.5617065429688], + [36.49110270000569, -114.53362995003391, 597.7229614257812], + [36.49113825000427, -114.53361147502544, 597.9202270507812], + [36.4911738, -114.533593, 598.0486450195312], + [36.49120656666909, -114.53357593334778, 598.2030029296875], + [36.49123933333576, -114.53355886668112, 598.2914428710938], + [36.491272099999996, -114.53354180000001, 598.3670654296875], + [36.49130600000124, -114.53352455000757, 598.4764404296875], + [36.4913399, -114.5335073, 598.4662475585938], + [36.49136280000057, -114.53349565000345, 598.3819580078125], + [36.491385699999995, -114.533484, 598.267822265625], + [36.49142570000209, -114.53346813334971, 598.044677734375], + [36.491465700002095, -114.53345226668306, 597.142822265625], + [36.4915057, -114.5334364, 596.1199340820312], + [36.49151, -114.5334352, 596.020263671875], + [36.49155082500152, -114.53342417501743, 594.4295654296875], + [36.49159165000203, -114.53341315002324, 592.8047485351562], + [36.49163247500152, -114.53340212501742, 591.9872436523438], + [36.491673299999995, -114.53339109999999, 591.485595703125], + [36.4916861, -114.53339030000001, 591.31494140625], + [36.491722925000076, -114.53338792500338, 591.2001342773438], + [36.491759750000085, -114.53338555000452, 591.1235961914062], + [36.491796575000066, -114.53338317500338, 591.0684814453125], + [36.4918334, -114.53338079999999, 591.0263671875], + [36.491865499999996, -114.5333763, 590.9990844726562], + [36.49190016666685, -114.533371466671, 590.9343872070312], + [36.491934833333524, -114.53336663333766, 590.85498046875], + [36.491969499999996, -114.5333618, 590.7860107421875], + [36.492005150000935, -114.53334675000691, 590.7313842773438], + [36.4920408, -114.53333169999999, 590.6612548828125], + [36.4920689, -114.5333198, 590.6459350585938], + [36.49210510000381, -114.53328960001411, 590.625244140625], + [36.4921413, -114.53325939999999, 590.6181640625], + [36.492163150003314, -114.53323120000795, 590.6181640625], + [36.492185, -114.533203, 590.6181640625], + [36.492201, -114.5331822, 590.6181640625], + [36.49221916667716, -114.53314673334998, 590.6184692382812], + [36.49223733334383, -114.5331112666833, 590.6209716796875], + [36.4922555, -114.53307579999999, 590.6260375976562], + [36.492267500004004, -114.53304480000479, 590.6469116210938], + [36.492279499999995, -114.53301379999999, 590.6766967773438], + [36.49228965000287, -114.53298755000344, 590.7072143554688], + [36.4922998, -114.5329613, 590.8131713867188], + [36.49231753334439, -114.53292490001668, 591.0130004882812], + [36.49233526667773, -114.53288850001667, 591.2682495117188], + [36.49235300000001, -114.5328521, 591.5869750976562], + [36.4923748, -114.5328258, 591.8949584960938], + [36.49239723333946, -114.53279870001572, 592.1841430664062], + [36.492419666672795, -114.53277160001572, 592.46630859375], + [36.4924421, -114.5327445, 592.7440185546875], + [36.4924765000043, -114.53271240001428, 593.0635375976562], + [36.4925109, -114.5326803, 593.3238525390625], + [36.49253505000211, -114.53265780000702, 593.5089721679688], + [36.492559199999995, -114.5326353, 593.7033081054688], + [36.49259618573217, -114.53260857150516, 593.9615478515625], + [36.49263317145837, -114.53258184298477, 594.2051391601562], + [36.49267015717862, -114.53255511443888, 594.1968383789062], + [36.49270714289292, -114.53252838586745, 594.1263427734375], + [36.492744128601245, -114.53250165727049, 594.159423828125], + [36.492781114303604, -114.53247492864801, 594.2234497070312], + [36.4928181, -114.53244819999999, 594.2907104492188], + [36.4928504, -114.5324249, 594.3612060546875], + [36.492873700002576, -114.53240005000745, 594.4307861328125], + [36.492897, -114.5323752, 594.4933471679688], + [36.492921350006796, -114.53233485001269, 594.5867309570312], + [36.4929457, -114.53229449999999, 594.6288452148438], + [36.4929518, -114.53228440000001, 594.62548828125], + [36.49296420000976, -114.53225020001094, 594.63720703125], + [36.49297660000977, -114.53221600001095, 594.633056640625], + [36.492989, -114.53218179999999, 594.6336669921875], + [36.49299570000984, -114.53213325000421, 594.689697265625], + [36.49300240000001, -114.53208470000001, 594.78125], + [36.493005000000004, -114.53206589999999, 594.8195190429688], + [36.49300365001063, -114.53201544999911, 594.971923828125], + [36.4930023, -114.531965, 595.1360473632812], + [36.49299175001051, -114.53191479999316, 595.3327026367188], + [36.492981199999996, -114.53186459999999, 595.5309448242188], + [36.4929797, -114.53185739999999, 595.5637817382812], + [36.492963050008036, -114.53181349999058, 595.8275146484375], + [36.4929464, -114.5317696, 596.0831909179688], + [36.492929033342605, -114.53173626665172, 596.2579956054688], + [36.49291166667594, -114.53170293331837, 596.4017944335938], + [36.492894299999996, -114.5316696, 596.5194702148438], + [36.492877899999996, -114.5316434, 596.6635131835938], + [36.4928594, -114.53161379999999, 596.8441772460938], + [36.492831133347245, -114.53157296663683, 597.0999145507812], + [36.49280286668058, -114.53153213330351, 597.3184814453125], + [36.4927746, -114.5314913, 597.5545043945312], + [36.4927510500127, -114.53145944997092, 597.7633056640625], + [36.492727500016926, -114.53142759996123, 597.9462280273438], + [36.49270395001269, -114.53139574997093, 598.0411987304688], + [36.4926804, -114.53136389999999, 598.1040649414062], + [36.4926786, -114.5313616, 598.1079711914062] + ], + "mountain_pass": [ + [34.2279388, -117.67129419999998, 1380.194213867188], + [34.22791465000351, -117.6713236000084, 1380.776123046875], + [34.2278905, -117.671353, 1380.732543945312], + [34.22786053334543, -117.67139160002746, 1380.067016601562], + [34.227830566678755, -117.67143020002746, 1379.976928710938], + [34.2278006, -117.6714688, 1379.608764648438], + [34.22777936667401, -117.67149886668183, 1379.615234375], + [34.22775813334067, -117.6715289333485, 1379.594116210938], + [34.2277369, -117.67155899999999, 1379.283447265625], + [34.22771725000454, -117.67159245000781, 1379.603637695312], + [34.2276976, -117.67162590000001, 1379.157348632812], + [34.22768235000405, -117.67165750000571, 1378.308349609375], + [34.2276671, -117.6716891, 1377.725463867188], + [34.2276543, -117.6717266, 1376.941528320312], + [34.22764390002029, -117.67176742501512, 1376.18896484375], + [34.22763350002706, -117.67180825002018, 1375.3818359375], + [34.227623100020296, -117.67184907501513, 1374.865356445312], + [34.2276127, -117.6718899, 1374.552856445312], + [34.227604525019146, -117.67192955001154, 1374.277587890625], + [34.22759635002552, -117.67196920001538, 1374.500610351562], + [34.22758817501914, -117.67200885001155, 1374.51171875], + [34.227579999999996, -117.67204849999997, 1374.449584960938], + [34.22757050006129, -117.67209867146249, 1374.250732421875], + [34.22756100010216, -117.67214884291373, 1373.894775390625], + [34.22755150012258, -117.67219901435362, 1373.613525390625], + [34.22754200012259, -117.6722491857822, 1373.440185546875], + [34.22753250010215, -117.67229935719946, 1373.0751953125], + [34.227523000061296, -117.67234952860541, 1372.576782226562], + [34.2275135, -117.67239970000001, 1372.23681640625], + [34.227511199999995, -117.67241179999998, 1372.180908203125], + [34.227502199999996, -117.6724557, 1372.140869140625], + [34.2274905750308, -117.67250600002083, 1372.088989257812], + [34.22747895004106, -117.67255630002776, 1371.890747070312], + [34.2274673250308, -117.67260660002081, 1371.732543945312], + [34.2274557, -117.67265689999998, 1371.50048828125], + [34.22744235000815, -117.67270170000708, 1371.29931640625], + [34.227429, -117.67274649999999, 1371.010620117188], + [34.22741440000668, -117.67278705000705, 1370.771728515625], + [34.2273998, -117.6728276, 1370.501708984375], + [34.227384900004374, -117.67286045000581, 1370.568359375], + [34.22737, -117.67289329999998, 1370.767822265625], + [34.22735655000219, -117.6729165500037, 1370.821411132812], + [34.227343100000006, -117.67293979999998, 1370.678955078125], + [34.2273273500018, -117.67296090000393, 1370.476684570312], + [34.2273116, -117.67298199999999, 1370.15869140625], + [34.227293800001455, -117.673000950004, 1370.312133789062], + [34.227275999999996, -117.6730199, 1370.157348632812], + [34.227246166672, -117.67304553335147, 1369.610107421875], + [34.22721633333867, -117.6730711666848, 1368.901000976562], + [34.2271865, -117.6730968, 1368.5673828125], + [34.227147866670535, -117.67311863335337, 1368.384765625], + [34.22710923333721, -117.67314046668672, 1367.978637695312], + [34.2270706, -117.67316230000002, 1368.293090820312], + [34.227031700001994, -117.67318445001025, 1368.173828125], + [34.2269928, -117.6732066, 1367.601196289062], + [34.2269849, -117.67321109999997, 1367.455688476562], + [34.22695234000555, -117.67322960002859, 1366.952514648438], + [34.22691978000834, -117.6732481000429, 1367.227172851562], + [34.22688722000833, -117.67326660004291, 1367.3134765625], + [34.22685466000556, -117.67328510002862, 1367.021850585938], + [34.22682210000001, -117.6733036, 1366.551025390625], + [34.2267967, -117.673318, 1366.0888671875], + [34.22676125000191, -117.67333965000913, 1366.273681640625], + [34.2267258, -117.67336130000001, 1366.334106445312], + [34.226697700003406, -117.67338180001369, 1366.262817382812], + [34.22666960000341, -117.67340230001369, 1365.853149414062], + [34.2266415, -117.67342280000001, 1366.064575195312], + [34.22660905000396, -117.67345405001204, 1365.79833984375], + [34.2265766, -117.6734853, 1365.334594726562], + [34.22655965000198, -117.67350745000445, 1365.49658203125], + [34.226542699999996, -117.67352960000001, 1365.235595703125], + [34.2265346, -117.67354069999999, 1365.115966796875], + [34.2265302, -117.6735468, 1365.086181640625], + [34.2265221, -117.67355749999999, 1365.147094726562], + [34.226502950003926, -117.67358860000706, 1365.223388671875], + [34.2264838, -117.6736197, 1365.6328125], + [34.22646910001396, -117.67366116668116, 1365.545776367188], + [34.22645440001396, -117.6737026333478, 1364.76611328125], + [34.2264397, -117.67374410000001, 1364.224975585938], + [34.226429100009724, -117.67379305000615, 1364.079833984375], + [34.2264185, -117.67384199999998, 1364.031127929688], + [34.226417366680764, -117.67388366666778, 1363.724243164062], + [34.22641623334743, -117.67392533333445, 1363.53564453125], + [34.2264151, -117.67396699999999, 1363.4716796875], + [34.22641922502739, -117.67401442499302, 1363.184448242188], + [34.22642335003651, -117.67406184999071, 1362.898315429688], + [34.22642747502738, -117.67410927499301, 1362.609497070312], + [34.2264316, -117.67415669999998, 1362.33056640625], + [34.2264324, -117.67416629999998, 1362.277587890625], + [34.22643762004622, -117.67421965998678, 1362.029541015625], + [34.226442840069325, -117.67427301998015, 1361.64794921875], + [34.22644806006933, -117.67432637998014, 1361.221923828125], + [34.226453280046215, -117.67437973998678, 1360.98974609375], + [34.2264585, -117.6744331, 1360.756713867188], + [34.22646156667893, -117.67447196666383, 1360.602172851562], + [34.2264646333456, -117.67451083333049, 1360.4765625], + [34.2264677, -117.67454969999999, 1360.427734375], + [34.2264674, -117.674599, 1360.393676757812], + [34.2264646, -117.6746513, 1360.392333984375], + [34.22645823334474, -117.67468876667232, 1360.358276367188], + [34.226451866678055, -117.67472623333899, 1360.191650390625], + [34.2264455, -117.6747637, 1360.052612304688], + [34.2264349, -117.67480239999998, 1360.415283203125], + [34.2264325, -117.6748111, 1360.477661132812], + [34.226419799999995, -117.67484479999999, 1360.713989257812], + [34.22640111432861, -117.67488677148444, 1360.661254882812], + [34.22638242864292, -117.67492874295026, 1360.065673828125], + [34.22636374294294, -117.67497071439746, 1359.17724609375], + [34.22634505722865, -117.67501268582602, 1358.903198242188], + [34.226326371500065, -117.67505465723598, 1358.659301757812], + [34.22630768575718, -117.6750966286273, 1358.614135742188], + [34.226289, -117.67513860000001, 1358.592041015625], + [34.22627510000316, -117.6751665000046, 1358.5849609375], + [34.226261199999996, -117.67519440000001, 1359.293823242188], + [34.22624110002017, -117.67523510002913, 1359.36181640625], + [34.2262210000269, -117.67527580003883, 1358.679077148438], + [34.22620090002017, -117.67531650002913, 1358.63427734375], + [34.2261808, -117.6753572, 1358.738525390625], + [34.226168, -117.6753809, 1358.625], + [34.22614916667695, -117.67541646668256, 1358.336059570312], + [34.226130333343605, -117.67545203334923, 1358.11572265625], + [34.2261115, -117.67548759999998, 1357.983520507812], + [34.22609810000212, -117.67551045000363, 1357.813232421875], + [34.22608470000001, -117.6755333, 1357.4296875], + [34.2260729, -117.67555300000001, 1357.021118164062], + [34.2260677, -117.6755616, 1357.000854492188], + [34.2260542, -117.67557879999998, 1357.277587890625], + [34.2260272, -117.675609, 1357.772583007812], + [34.22600805000135, -117.67562725000414, 1357.617065429688], + [34.2259889, -117.6756455, 1357.697143554688], + [34.22595666667085, -117.67566820001738, 1358.109497070312], + [34.22592443333751, -117.67569090001737, 1357.408447265625], + [34.225892200000004, -117.6757136, 1355.622314453125], + [34.225860850003926, -117.67573157502007, 1355.2294921875], + [34.22582950000524, -117.67574955002675, 1355.425659179688], + [34.225798150003925, -117.67576752502008, 1355.394409179688], + [34.225766799999995, -117.6757855, 1354.823852539062], + [34.225733240004374, -117.6758019400262, 1353.981323242188], + [34.225699680006564, -117.6758183800393, 1354.595336914062], + [34.22566612000657, -117.67583482003931, 1355.79248046875], + [34.22563256000438, -117.6758512600262, 1356.493041992188], + [34.225598999999995, -117.67586770000001, 1354.88427734375], + [34.2255896, -117.67587219999999, 1354.384887695312], + [34.22555533333532, -117.6758878666794, 1352.797485351562], + [34.225521066668655, -117.67590353334609, 1353.17236328125], + [34.2254868, -117.6759192, 1354.128051757812], + [34.2254615000005, -117.67593030000334, 1354.727783203125], + [34.2254362, -117.6759414, 1355.232788085938], + [34.2254299, -117.67594419999999, 1355.291259765625], + [34.225398000001626, -117.67596420000758, 1354.341064453125], + [34.225366099999995, -117.6759842, 1353.332885742188], + [34.22534535000122, -117.67600150000426, 1353.32421875], + [34.2253246, -117.6760188, 1353.368041992188], + [34.22530460000199, -117.67604090000525, 1353.485595703125], + [34.2252846, -117.67606300000001, 1353.087646484375], + [34.2252811, -117.6760676, 1353.259887695312], + [34.22526590000227, -117.67609125000426, 1353.782958984375], + [34.2252507, -117.67611489999999, 1354.003051757812], + [34.22523770000387, -117.67614580000476, 1353.976684570312], + [34.2252247, -117.67617670000001, 1353.724853515625], + [34.22521575000502, -117.67621190000375, 1352.446044921875], + [34.225206799999995, -117.6762471, 1351.164428710938], + [34.2252046, -117.6762661, 1350.892700195312], + [34.22520165000396, -117.67629735000111, 1350.432861328125], + [34.2251987, -117.6763286, 1350.259765625], + [34.22520145000914, -117.67637604999845, 1349.530029296875], + [34.2252042, -117.6764235, 1349.155395507812], + [34.22521145000377, -117.67645399999738, 1349.088256835938], + [34.2252187, -117.67648450000002, 1349.0048828125], + [34.22522680000272, -117.6765103999975, 1349.210083007812], + [34.2252349, -117.6765363, 1349.495971679688], + [34.22524990000659, -117.67657659999283, 1349.88525390625], + [34.2252649, -117.6766169, 1350.369262695312], + [34.22527880000467, -117.67665084999437, 1350.568359375], + [34.2252927, -117.67668479999999, 1350.76416015625], + [34.2253129, -117.67672809999999, 1350.854614257812], + [34.22533400000662, -117.67676849998986, 1350.260131835938], + [34.2253551, -117.67680889999998, 1349.287231445312], + [34.2253806, -117.67685019999999, 1348.389892578125], + [34.225391699999996, -117.67686799999998, 1348.168212890625], + [34.22540996667353, -117.67689709998739, 1347.749755859375], + [34.22542823334021, -117.67692619998738, 1347.521484375], + [34.2254465, -117.6769553, 1347.4521484375], + [34.22547455000731, -117.67699774998586, 1347.142944335938], + [34.2255026, -117.67704020000001, 1347.025146484375], + [34.2255154, -117.67705790000001, 1346.991333007812], + [34.22554140001107, -117.67709483331053, 1346.797973632812], + [34.22556740001108, -117.67713176664387, 1347.024780273438], + [34.2255934, -117.6771687, 1346.969848632812], + [34.2256068, -117.67718819999997, 1346.964233398438], + [34.2256188, -117.67720569999999, 1346.91748046875], + [34.225641599999996, -117.6772368, 1346.68505859375], + [34.22566360000542, -117.67727334999046, 1346.632446289062], + [34.2256856, -117.67730990000001, 1346.666625976562], + [34.2256993000027, -117.67733569999582, 1346.709350585938], + [34.225713, -117.67736149999999, 1346.557373046875], + [34.225722499999996, -117.6773802, 1346.606567382812], + [34.225737599999995, -117.6774106, 1346.643920898438], + [34.22575020000551, -117.67744744999449, 1346.425537109375], + [34.2257628, -117.6774843, 1345.801635742188], + [34.22577010000552, -117.67752119999679, 1345.7724609375], + [34.2257774, -117.6775581, 1345.790893554688], + [34.22577866667811, -117.67759566666555, 1345.929077148438], + [34.225779933344775, -117.67763323333222, 1346.049072265625], + [34.2257812, -117.67767080000002, 1346.151489257812], + [34.22577655000872, -117.6777171500026, 1346.209716796875], + [34.2257719, -117.67776350000001, 1345.926635742188], + [34.2257685, -117.6777804, 1345.776611328125], + [34.225765599999995, -117.6777945, 1345.699462890625], + [34.2257546, -117.67783759999999, 1345.7099609375], + [34.225745750002964, -117.67786465000285, 1345.807495117188], + [34.2257369, -117.6778917, 1345.747680664062], + [34.225716350007595, -117.67793495001055, 1345.627197265625], + [34.2256958, -117.6779782, 1345.56396484375], + [34.2256906, -117.677989, 1345.505249023438], + [34.2256849, -117.67800030000001, 1345.4296875], + [34.22565730000736, -117.67804290001398, 1345.333862304688], + [34.2256297, -117.67808550000001, 1344.899536132812], + [34.22560580000697, -117.67811480001664, 1344.627319335938], + [34.225581900006965, -117.67814410001662, 1344.265991210938], + [34.225558, -117.6781734, 1343.849609375], + [34.22552776667364, -117.6782027333544, 1343.836181640625], + [34.22549753334032, -117.67823206668773, 1343.722900390625], + [34.2254673, -117.6782614, 1343.585815429688], + [34.2254403333392, -117.67828830001721, 1343.76416015625], + [34.22541336667254, -117.67831520001721, 1343.578979492188], + [34.2253864, -117.6783421, 1343.086059570312], + [34.225356400007435, -117.67837236668822, 1342.884033203125], + [34.22532640000743, -117.67840263335489, 1342.773071289062], + [34.2252964, -117.6784329, 1343.143798828125], + [34.225273300007466, -117.67846323334997, 1343.206176757812], + [34.225250200007466, -117.6784935666833, 1343.071166992188], + [34.2252271, -117.6785239, 1343.230590820312], + [34.225206200005935, -117.67856215000948, 1343.087768554688], + [34.2251853, -117.67860039999998, 1343.165161132812], + [34.22516273334862, -117.67864380002324, 1343.194946289062], + [34.225140166681946, -117.67868720002323, 1343.068969726562], + [34.2251176, -117.67873059999998, 1342.967895507812], + [34.22509458002731, -117.67877162004481, 1343.213989257812], + [34.22507156004096, -117.67881264006724, 1343.039306640625], + [34.225048540040966, -117.67885366006726, 1342.641357421875], + [34.22502552002731, -117.67889468004483, 1342.703491210938], + [34.225002499999995, -117.67893570000001, 1342.831909179688], + [34.224978020018604, -117.67896956003938, 1343.557495117188], + [34.22495354002791, -117.67900342005905, 1344.460083007812], + [34.22492906002791, -117.67903728005905, 1345.273681640625], + [34.22490458001861, -117.67907114003935, 1345.346435546875], + [34.2248801, -117.679105, 1345.935180664062], + [34.22485485001416, -117.67913910003068, 1346.639892578125], + [34.22482960001887, -117.67917320004088, 1346.4599609375], + [34.22480435001415, -117.67920730003065, 1346.280151367188], + [34.2247791, -117.6792414, 1346.788940429688], + [34.22475513334538, -117.67927993335526, 1346.446533203125], + [34.224731166678716, -117.67931846668858, 1346.65771484375], + [34.2247072, -117.67935700000001, 1347.6513671875], + [34.22468545000678, -117.67939790001058, 1347.236694335938], + [34.2246637, -117.67943880000001, 1347.056030273438], + [34.22465030001086, -117.67949055000823, 1345.776611328125], + [34.2246369, -117.6795423, 1344.378295898438], + [34.22463285000821, -117.67958730000215, 1343.48828125], + [34.2246288, -117.6796323, 1342.81201171875], + [34.22463120000327, -117.67966069999919, 1342.842041015625], + [34.2246336, -117.6796891, 1342.873657226562], + [34.22463860000467, -117.67972304999797, 1342.894287109375], + [34.2246436, -117.679757, 1342.946899414062], + [34.22465655000787, -117.67980104999322, 1342.849731445312], + [34.2246695, -117.67984510000001, 1342.710083007812], + [34.22468747501861, -117.67988419997496, 1342.294799804688], + [34.22470545002481, -117.67992329996662, 1342.273559570312], + [34.22472342501861, -117.67996239997497, 1342.05859375], + [34.2247414, -117.6800015, 1342.036010742188], + [34.22476185000661, -117.6800418499902, 1341.811889648438], + [34.2247823, -117.6800822, 1341.671630859375], + [34.2247998, -117.68011510000001, 1341.408325195312], + [34.22482413335025, -117.68016076664027, 1341.156372070312], + [34.224848466683596, -117.68020643330694, 1341.14404296875], + [34.2248728, -117.68025209999999, 1341.490966796875], + [34.224879699999995, -117.68026510000001, 1341.606811523438], + [34.22490263643658, -117.68030749988455, 1341.434936523438], + [34.22492557285859, -117.68034989979219, 1341.543823242188], + [34.22494850926601, -117.68039229972291, 1340.869995117188], + [34.224971445658824, -117.68043469967671, 1340.57470703125], + [34.22499438203704, -117.68047709965363, 1340.006225585938], + [34.225017318400695, -117.68051949965363, 1339.843383789062], + [34.22504025474972, -117.6805618996767, 1339.668823242188], + [34.22506319108418, -117.68060429972289, 1339.869873046875], + [34.22508612740404, -117.68064669979216, 1339.823364257812], + [34.22510906370931, -117.68068909988455, 1339.909423828125], + [34.225131999999995, -117.6807315, 1339.99072265625], + [34.22514760000328, -117.68075999999469, 1340.16845703125], + [34.2251632, -117.68078849999998, 1340.231567382812], + [34.2251817, -117.68082369999999, 1340.552490234375], + [34.22520140000672, -117.68086439999045, 1340.79541015625], + [34.2252211, -117.68090509999999, 1341.077514648438], + [34.22523355000377, -117.6809355499955, 1341.11865234375], + [34.225246, -117.68096600000001, 1341.102416992188], + [34.2252589, -117.681, 1341.478393554688], + [34.225268899999996, -117.681047, 1341.801147460938], + [34.2252721, -117.6810908, 1342.025146484375], + [34.225268799999995, -117.6811428, 1342.207885742188], + [34.225259799999996, -117.68118489999999, 1342.309326171875], + [34.225252499999996, -117.68120750000001, 1342.358154296875], + [34.2252365, -117.68123969999998, 1342.528442382812], + [34.225220500000006, -117.6812628, 1342.62060546875], + [34.225195299999996, -117.6812943, 1342.566650390625], + [34.2251585, -117.68132150000001, 1342.47607421875], + [34.225134499999996, -117.68133549999999, 1342.417114257812], + [34.2251141, -117.68134459999999, 1342.334350585938], + [34.2250721, -117.68136050000001, 1342.206176757812], + [34.225035100002046, -117.68137174001977, 1342.080444335938], + [34.224998100003084, -117.68138298002964, 1341.9306640625], + [34.22496110000308, -117.68139422002965, 1341.920288085938], + [34.22492410000205, -117.68140546001977, 1341.922973632812], + [34.2248871, -117.6814167, 1341.906860351562], + [34.22484560000422, -117.68143532502756, 1341.595581054688], + [34.22480410000563, -117.68145395003673, 1341.320190429688], + [34.224762600004226, -117.68147257502754, 1341.105224609375], + [34.2247211, -117.6814912, 1341.338256835938], + [34.2246821333405, -117.68151000004347, 1341.650268554688], + [34.22464316667814, -117.68152880006957, 1341.84033203125], + [34.22460420001291, -117.68154760007828, 1341.902587890625], + [34.224565233344805, -117.68156640006958, 1342.013549804688], + [34.22452626667384, -117.68158520004347, 1341.997680664062], + [34.2244873, -117.681604, 1341.882934570312], + [34.224455733334445, -117.68161570000875, 1341.966186523438], + [34.22442416666778, -117.68162740000878, 1342.149169921875], + [34.2243926, -117.6816391, 1342.802368164062], + [34.22435010000117, -117.6816511000121, 1343.31591796875], + [34.22430760000116, -117.6816631000121, 1343.348022460938], + [34.2242651, -117.68167509999999, 1342.882202148438], + [34.2242472, -117.68167989999999, 1342.68896484375], + [34.224214066667415, -117.68168950000754, 1342.414672851562], + [34.224180933334075, -117.68169910000755, 1342.407958984375], + [34.2241478, -117.6817087, 1342.329345703125], + [34.22411940000048, -117.68171965000369, 1342.078002929688], + [34.224091, -117.6817306, 1341.91552734375], + [34.2240695, -117.68174119999999, 1342.340942382812], + [34.22404150000219, -117.68175760001088, 1342.592529296875], + [34.22401350000219, -117.68177400001088, 1342.460693359375], + [34.2239855, -117.6817904, 1342.42138671875], + [34.22395870000169, -117.68181075000646, 1342.047119140625], + [34.2239319, -117.6818311, 1342.146240234375], + [34.2239139, -117.6818472, 1342.089721679688], + [34.223886733338865, -117.68187330001685, 1342.025024414062], + [34.22385956667219, -117.68189940001685, 1342.409790039062], + [34.2238324, -117.6819255, 1343.158447265625], + [34.22381200000183, -117.68194670000511, 1343.579467773438], + [34.2237916, -117.68196789999998, 1343.869018554688], + [34.22376665000288, -117.68199455000789, 1345.553955078125], + [34.2237417, -117.68202120000001, 1346.573608398438], + [34.22371580000893, -117.68205436668708, 1347.279296875], + [34.22368990000893, -117.68208753335374, 1348.129028320312], + [34.223664, -117.6821207, 1349.186767578125], + [34.2236523, -117.682136, 1349.23779296875], + [34.22363132501673, -117.68217307502772, 1348.251098632812], + [34.22361035002231, -117.68221015003694, 1347.786499023438], + [34.223589375016736, -117.6822472250277, 1348.87939453125], + [34.2235684, -117.6822843, 1349.915771484375], + [34.2235608, -117.6823, 1350.5224609375], + [34.22354250000761, -117.68234330000942, 1351.793090820312], + [34.2235242, -117.68238660000002, 1352.610229492188], + [34.22351505000585, -117.68242455000414, 1351.990234375], + [34.2235059, -117.68246250000001, 1351.1845703125], + [34.2235004, -117.6824962, 1350.272216796875], + [34.2234988, -117.6825057, 1350.023071289062], + [34.22349610001268, -117.68254523333587, 1348.6142578125], + [34.223493400012686, -117.6825847666692, 1347.548828125], + [34.2234907, -117.6826243, 1346.728149414062], + [34.2234950500101, -117.68267419999744, 1345.217041015625], + [34.2234994, -117.68272410000002, 1344.126708984375], + [34.2234999, -117.682726, 1344.097412109375], + [34.22350965000569, -117.68276344999566, 1343.63525390625], + [34.2235194, -117.68280089999999, 1343.6435546875], + [34.223532500004445, -117.68283399999486, 1343.877685546875], + [34.2235456, -117.6828671, 1344.609741210938], + [34.223558050002254, -117.68289064999651, 1345.11669921875], + [34.2235705, -117.6829142, 1346.089721679688], + [34.2235788, -117.68292549999998, 1346.365234375], + [34.22359820000197, -117.6829475499949, 1346.724365234375], + [34.2236176, -117.6829696, 1346.42041015625], + [34.22364270000254, -117.68299464999254, 1345.712036132812], + [34.2236678, -117.6830197, 1345.0146484375], + [34.22368895000109, -117.68303609999587, 1344.365234375], + [34.2237101, -117.68305249999997, 1343.689331054688], + [34.22373560000101, -117.68306824999522, 1343.33447265625], + [34.2237611, -117.683084, 1343.277709960938], + [34.22378993333444, -117.68309569999198, 1343.029052734375], + [34.223818766667776, -117.68310739999198, 1342.949584960938], + [34.2238476, -117.6831191, 1342.826293945312], + [34.2238732, -117.6831297, 1342.6455078125], + [34.2238938, -117.6831383, 1342.521118164062], + [34.2239346, -117.68315640000002, 1342.537475585938], + [34.223972633336246, -117.68317536664956, 1342.61083984375], + [34.22401066666958, -117.6831943333162, 1342.458984375], + [34.2240487, -117.68321329999998, 1341.767333984375], + [34.2240585, -117.68321889999999, 1341.60400390625], + [34.22409045000143, -117.6832375999929, 1341.09912109375], + [34.2241224, -117.6832563, 1340.8583984375], + [34.22415040000222, -117.68327969999221, 1340.881103515625], + [34.2241784, -117.68330309999999, 1340.752563476562], + [34.22419755000172, -117.68332369999528, 1340.619018554688], + [34.2242167, -117.68334429999999, 1340.237915039062], + [34.22423755000346, -117.68337354999277, 1340.109252929688], + [34.2242584, -117.68340280000001, 1339.961303710938], + [34.224273300002345, -117.68342684999575, 1339.971801757812], + [34.2242882, -117.6834509, 1339.989624023438], + [34.22430090000285, -117.68347744999598, 1340.361694335938], + [34.224313599999995, -117.683504, 1340.6005859375], + [34.2243268, -117.68353289999999, 1340.614624023438], + [34.22433800000387, -117.68356374999587, 1340.696899414062], + [34.2243492, -117.68359459999998, 1340.625], + [34.22436390000877, -117.68364109999186, 1340.450073242188], + [34.2243786, -117.6836876, 1339.975341796875], + [34.224380499999995, -117.68369349999999, 1339.914672851562], + [34.2243834, -117.68370329999999, 1339.831909179688], + [34.224396200009174, -117.68375084999275, 1339.993286132812], + [34.224409, -117.6837984, 1340.354858398438], + [34.224416050006084, -117.68383714999676, 1340.569091796875], + [34.224423099999996, -117.68387589999999, 1340.514038085938], + [34.224426699999995, -117.6838968, 1340.259887695312], + [34.224429199999996, -117.68391159999999, 1340.048217773438], + [34.224430399999996, -117.6839184, 1339.991943359375], + [34.224433300006645, -117.68395884999859, 1339.743408203125], + [34.2244362, -117.68399929999998, 1339.498291015625], + [34.2244338000071, -117.68404110000118, 1339.395385742188], + [34.2244314, -117.68408289999999, 1339.263427734375], + [34.22442505000565, -117.68412020000281, 1339.560302734375], + [34.2244187, -117.6841575, 1339.8505859375], + [34.22440965000326, -117.68418585000306, 1340.22705078125], + [34.224400599999996, -117.6842142, 1340.3720703125], + [34.22438290000408, -117.68424590000667, 1340.694213867188], + [34.2243652, -117.68427760000002, 1340.78466796875], + [34.22434402501587, -117.68431370002722, 1340.504516601562], + [34.224322850021146, -117.68434980003632, 1340.964599609375], + [34.224301675015866, -117.68438590002725, 1340.216674804688], + [34.2242805, -117.68442200000001, 1340.792358398438], + [34.22426273334284, -117.68445623334779, 1340.636352539062], + [34.224244966676174, -117.6844904666811, 1340.4189453125], + [34.2242272, -117.6845247, 1340.158325195312], + [34.22421496667722, -117.68456076667714, 1339.27587890625], + [34.224202733343894, -117.6845968333438, 1340.358520507812], + [34.2241905, -117.68463290000001, 1341.089599609375], + [34.224184350003945, -117.68466410000228, 1340.844848632812], + [34.2241782, -117.68469529999999, 1340.313598632812], + [34.2241765, -117.68470250000001, 1340.172485351562], + [34.22417110001048, -117.68475330000325, 1338.946655273438], + [34.2241657, -117.6848041, 1338.400756835938], + [34.2241640000097, -117.68485300000097, 1337.96923828125], + [34.224162299999996, -117.6849019, 1337.701171875], + [34.2241621, -117.6849091, 1337.683471679688], + [34.22416258003507, -117.68495557999894, 1337.519409179688], + [34.22416306005261, -117.6850020599984, 1337.427978515625], + [34.22416354005261, -117.6850485399984, 1337.4453125], + [34.22416402003507, -117.68509501999891, 1337.519287109375], + [34.2241645, -117.6851415, 1337.648803710938], + [34.22416626668397, -117.68518766666473, 1337.771850585938], + [34.22416803335063, -117.6852338333314, 1337.950439453125], + [34.2241698, -117.68527999999999, 1338.2392578125], + [34.22417200000517, -117.68531569999905, 1338.494873046875], + [34.2241742, -117.68535139999999, 1338.79345703125], + [34.22417983334825, -117.68539426666092, 1339.1015625], + [34.22418546668159, -117.68543713332758, 1339.380859375], + [34.2241911, -117.68548, 1339.533447265625], + [34.224196500002854, -117.68550649999831, 1339.55224609375], + [34.2242019, -117.685533, 1339.595092773438], + [34.224209, -117.68555969999998, 1339.509033203125], + [34.22422220000724, -117.68560194999338, 1339.378662109375], + [34.2242354, -117.6856442, 1339.543212890625], + [34.22424987501719, -117.68568177498062, 1339.599243164062], + [34.224264350022914, -117.68571934997418, 1339.6005859375], + [34.224278825017194, -117.68575692498061, 1339.491455078125], + [34.2242933, -117.6857945, 1339.285888671875], + [34.22430813334493, -117.68583229998667, 1339.196411132812], + [34.224322966678265, -117.68587009998667, 1339.454345703125], + [34.2243378, -117.68590789999999, 1339.355224609375], + [34.2243482000038, -117.68593849999623, 1339.1767578125], + [34.224358599999995, -117.6859691, 1339.308715820312], + [34.2243719, -117.68601840000001, 1339.560180664062], + [34.2243816, -117.68606729999999, 1339.756591796875], + [34.22438600000564, -117.68610454999806, 1339.885498046875], + [34.2243904, -117.6861418, 1339.963500976562], + [34.2243906, -117.68615899999999, 1340.013793945312], + [34.22439090000386, -117.68618984999989, 1340.090698242188], + [34.2243912, -117.68622069999998, 1340.041381835938], + [34.22438850000432, -117.68625335000104, 1339.94921875], + [34.2243858, -117.68628600000001, 1339.767944335938], + [34.2243776, -117.68633190000001, 1339.560791015625], + [34.224367099999995, -117.68637629999999, 1339.240112304688], + [34.22435935000272, -117.68640220000239, 1339.048217773438], + [34.2243516, -117.6864281, 1339.036499023438], + [34.224337900011136, -117.6864651333454, 1339.237915039062], + [34.22432420001113, -117.68650216667871, 1339.41845703125], + [34.2243105, -117.68653920000001, 1339.318725585938], + [34.2242938, -117.68657439999998, 1339.136596679688], + [34.22427570000396, -117.6866056000067, 1339.117553710938], + [34.2242576, -117.6866368, 1338.978515625], + [34.2242315500062, -117.68667590001209, 1338.867919921875], + [34.2242055, -117.68671499999999, 1338.689575195312], + [34.224180550005194, -117.68675080001059, 1338.370971679688], + [34.224155599999996, -117.68678659999998, 1338.087890625], + [34.2241271, -117.6868274, 1337.743774414062], + [34.22409670000648, -117.68686735001442, 1337.197265625], + [34.2240663, -117.68690730000002, 1336.663208007812], + [34.224037714312104, -117.68694022863849, 1336.304321289062], + [34.22400912861542, -117.68697315725461, 1335.872680664062], + [34.22398054290994, -117.6870060858484, 1335.531982421875], + [34.22395195719565, -117.6870390144198, 1335.365356445312], + [34.22392337147257, -117.6870719429689, 1335.419067382812], + [34.22389478574069, -117.68710487149563, 1335.983764648438], + [34.223866199999996, -117.68713779999999, 1336.538208007812], + [34.22384390000268, -117.68716345000679, 1336.920532226562], + [34.2238216, -117.68718910000001, 1337.459716796875], + [34.22379990000263, -117.68721460000658, 1338.209716796875], + [34.2237782, -117.68724010000001, 1338.651245117188], + [34.2237574500024, -117.687264450006, 1338.823364257812], + [34.223736699999996, -117.6872888, 1339.286499023438], + [34.223709899999996, -117.68732039999999, 1339.58642578125], + [34.22368073335749, -117.68735490005972, 1340.640625], + [34.223651566705314, -117.68738940009555, 1341.7060546875], + [34.223622400043475, -117.6874239001075, 1341.928344726562], + [34.22359323337198, -117.68745840009557, 1342.32177734375], + [34.22356406669081, -117.68749290005971, 1342.426635742188], + [34.2235349, -117.6875274, 1342.453735351562], + [34.22351360000482, -117.68756190000873, 1342.734008789062], + [34.2234923, -117.68759639999999, 1342.510498046875], + [34.2234909, -117.68759980000002, 1342.39453125] + ], + "coastal_drive": [ + [37.8699704, -122.5898797, 139.2668762207031], + [37.8700088, -122.5898988, 139.3808288574219], + [37.870030250000475, -122.58990939999693, 139.4592437744141], + [37.8700517, -122.58991999999999, 139.5515899658203], + [37.8700537, -122.58992090000001, 139.5605773925781], + [37.870078899999996, -122.58993339999999, 139.6815948486328], + [37.870101999999996, -122.5899447, 139.8131256103516], + [37.870115999999996, -122.58995150000001, 139.8943023681641], + [37.8701211, -122.58995390000001, 139.9245758056641], + [37.8701383, -122.58996200000001, 140.0317840576172], + [37.87017195000321, -122.58997789997821, 140.2114715576172], + [37.87020560000428, -122.58999379997096, 140.4249114990234], + [37.87023925000321, -122.59000969997822, 140.8113861083984], + [37.870272899999996, -122.59002559999999, 141.1221618652344], + [37.87031158000566, -122.59004389996157, 141.4852447509766], + [37.870350260008486, -122.59006219994237, 141.8438720703125], + [37.870388940008496, -122.59008049994235, 142.2044525146484], + [37.87042762000566, -122.59009879996157, 142.5549163818359], + [37.8704663, -122.59011710000001, 142.9037780761719], + [37.87049650000172, -122.59013136665499, 143.1712646484375], + [37.87052670000172, -122.59014563332163, 143.3362884521484], + [37.8705569, -122.59015989999997, 143.3687133789062], + [37.8705737, -122.59016789999998, 143.5116119384766], + [37.870577999999995, -122.59016989999999, 143.5699768066406], + [37.8706074, -122.5901839, 143.8582000732422], + [37.8706153, -122.5901876, 143.8991546630859], + [37.870626699999995, -122.59019289999999, 143.9502868652344], + [37.8706375, -122.5901981, 143.9929504394531], + [37.8706732, -122.59021489999999, 144.1171569824219], + [37.870699100000685, -122.59022764999551, 144.1875], + [37.870725, -122.59024040000001, 144.2410278320312], + [37.870741699999996, -122.59024869999998, 144.2684478759766], + [37.870773450001046, -122.5902644499932, 144.2989349365234], + [37.8708052, -122.5902802, 144.3264770507812], + [37.870846075012665, -122.59030088741966, 144.4583587646484], + [37.87088695002171, -122.59032157486226, 144.5715637207031], + [37.87092782502715, -122.59034226232784, 144.6453857421875], + [37.87096870002895, -122.59036294981637, 144.7246704101562], + [37.87100957502715, -122.59038363732785, 144.7752227783203], + [37.87105045002172, -122.59040432486226, 144.8228607177734], + [37.87109132501267, -122.59042501241967, 144.8872833251953], + [37.8711322, -122.5904457, 144.9492645263672], + [37.8711341, -122.5904469, 144.9521331787109], + [37.87117060000164, -122.59046664999023, 145.0173645019531], + [37.8712071, -122.59048639999999, 145.0965423583984], + [37.8712246, -122.59049730000001, 145.1271057128906], + [37.8712476, -122.59051140000001, 145.1688995361328], + [37.8712574, -122.5905172, 145.1858215332031], + [37.8712733, -122.59052799999998, 145.2043609619141], + [37.8712912, -122.5905402, 145.22509765625], + [37.8712947, -122.5905427, 145.2284851074219], + [37.8713294, -122.5905674, 145.2555541992188], + [37.87135100000144, -122.59058584999458, 145.2322540283203], + [37.8713726, -122.5906043, 145.1908874511719], + [37.8713802, -122.59061090000002, 145.1723022460938], + [37.8713951, -122.5906234, 145.1327667236328], + [37.871398899999996, -122.59062660000001, 145.1212768554688], + [37.871424399999995, -122.59065109999999, 145.0202789306641], + [37.8714436, -122.59066949999999, 144.9228820800781], + [37.8714636, -122.5906895, 144.7778167724609], + [37.87149600000952, -122.59072306663714, 144.4983367919922], + [37.87152840000953, -122.59075663330378, 144.1609802246094], + [37.8715608, -122.5907902, 143.8954315185547], + [37.87158650000649, -122.59081789998068, 143.6995086669922], + [37.87161220000648, -122.59084559998067, 143.4120941162109], + [37.871637899999996, -122.5908733, 143.3045959472656], + [37.87165735000186, -122.59089429999445, 143.2604675292969], + [37.871676799999996, -122.5909153, 143.2271881103516], + [37.8717013, -122.5909418, 143.1889038085938], + [37.8717341000053, -122.59097719998425, 143.1304473876953], + [37.8717669, -122.5910126, 143.087646484375], + [37.871796599999996, -122.5910446, 143.0469818115234], + [37.871813599999996, -122.59106299999999, 142.9962921142578], + [37.8718382500029, -122.59108919999125, 142.9203186035156], + [37.871862899999996, -122.5911154, 142.8158416748047], + [37.87189398335631, -122.59114836659711, 142.4819488525391], + [37.87192506670343, -122.59118133322205, 141.909912109375], + [37.87195615004136, -122.5912142998748, 141.7748565673828], + [37.8719872333701, -122.59124726655538, 141.6630859375], + [37.87201831668965, -122.59128023326377, 141.5357818603516], + [37.8720494, -122.59131319999997, 141.4462280273438], + [37.87207680001034, -122.59134174996812, 141.3396453857422], + [37.87210420001379, -122.5913702999575, 141.3012847900391], + [37.87213160001034, -122.59139884996814, 141.2279510498047], + [37.872158999999996, -122.59142739999999, 141.1172485351562], + [37.87218250000477, -122.5914511666515, 141.0647583007812], + [37.87220600000477, -122.59147493331815, 141.0160064697266], + [37.872229499999996, -122.59149869999999, 140.9673919677734], + [37.87225255000205, -122.59152074999311, 140.9174499511719], + [37.8722756, -122.59154280000001, 140.8699798583984], + [37.872306950002965, -122.59156929998872, 140.7961730957031], + [37.872338299999996, -122.5915958, 140.61767578125], + [37.8723599, -122.59161239999999, 140.4214019775391], + [37.8723643, -122.5916155, 140.3779144287109], + [37.8723828, -122.59163029999998, 140.2505493164062], + [37.872394799999995, -122.5916388, 140.2142486572266], + [37.8724064, -122.5916465, 140.1669921875], + [37.872425199999995, -122.5916591, 140.0680847167969], + [37.8724631, -122.59168329999999, 139.7920227050781], + [37.872501850002294, -122.59170659998773, 139.4237670898438], + [37.8725406, -122.59172989999998, 139.0224761962891], + [37.872571799999996, -122.59174879999998, 138.5486755371094], + [37.87260125000177, -122.5917692999918, 138.1658325195312], + [37.872630699999995, -122.59178979999999, 137.7831115722656], + [37.87265320000117, -122.59180644999492, 137.4523773193359], + [37.872675699999995, -122.59182310000001, 137.1529998779297], + [37.87270150000222, -122.59184604999196, 136.9841156005859], + [37.87272730000001, -122.59186899999999, 136.8066253662109], + [37.872758250003876, -122.59189929998726, 136.5533447265625], + [37.8727892, -122.59192960000001, 136.2085113525391], + [37.8728172, -122.59195679999999, 135.7433166503906], + [37.8728188, -122.59195840000001, 135.7232208251953], + [37.8728367, -122.59197599999999, 135.5324554443359], + [37.872864799999995, -122.5920023, 135.207763671875], + [37.8728799, -122.5920165, 135.0271148681641], + [37.8728986, -122.5920332, 134.7916259765625], + [37.87293210000335, -122.59206129998721, 134.3502502441406], + [37.87296560000001, -122.59208939999999, 134.0279083251953], + [37.87300290000269, -122.59211459998723, 133.7022399902344], + [37.8730402, -122.5921398, 133.3154907226562], + [37.87307680000239, -122.5921635499882, 132.6793518066406], + [37.8731134, -122.59218729999999, 131.9938812255859], + [37.87313996666995, -122.59220699998578, 131.4798126220703], + [37.87316653333662, -122.5922266999858, 131.0049591064453], + [37.8731931, -122.5922464, 130.6920013427734], + [37.873223850002574, -122.5922710499897, 130.3738555908203], + [37.873254599999996, -122.59229569999998, 130.1225128173828], + [37.87328895000402, -122.59232654998561, 129.9240570068359], + [37.873323299999996, -122.59235740000001, 129.7574615478516], + [37.873350600003214, -122.59238494998979, 129.4264373779297], + [37.87337790000001, -122.5924125, 129.0366058349609], + [37.873400000002974, -122.59243899999204, 128.6384582519531], + [37.873422100000006, -122.5924655, 128.2119903564453], + [37.87344340000347, -122.59249409999174, 127.9988861083984], + [37.87346470000001, -122.59252269999999, 127.7901077270508], + [37.87349360000707, -122.59256359998395, 127.4938201904297], + [37.8735225, -122.5926045, 127.262092590332], + [37.87354735000531, -122.59263994998803, 126.9716491699219], + [37.8735722, -122.59267539999999, 126.4322891235352], + [37.87360095000622, -122.59271374998501, 126.0223922729492], + [37.873629699999995, -122.5927521, 125.4188461303711], + [37.87365340000326, -122.59277989999106, 125.1554107666016], + [37.873677099999995, -122.5928077, 124.9933242797852], + [37.873680199999995, -122.5928115, 124.9607620239258], + [37.873703500002634, -122.5928364499921, 124.7290115356445], + [37.8737268, -122.59286139999999, 124.474235534668], + [37.87375710000329, -122.59288929998853, 124.1464691162109], + [37.8737874, -122.59291719999999, 123.5675811767578], + [37.87381980000267, -122.59294229998898, 123.1463317871094], + [37.8738522, -122.5929674, 122.7514038085938], + [37.8738542, -122.5929688, 122.7256164550781], + [37.8738706, -122.59298030000001, 122.5083465576172], + [37.87390705000247, -122.59300444998806, 121.9783935546875], + [37.8739435, -122.59302860000001, 121.6485214233398], + [37.873964199999996, -122.59304239999999, 121.4044418334961], + [37.8739722, -122.5930477, 121.3100967407227], + [37.873993399999996, -122.59306169999998, 121.0428009033203], + [37.874013399999995, -122.59307489999998, 120.7349090576172], + [37.8740301, -122.593086, 120.407096862793], + [37.87406780000529, -122.59311099997441, 119.8296508789062], + [37.87410550000529, -122.59313599997438, 119.1005477905273], + [37.8741432, -122.593161, 118.6158142089844], + [37.8741779, -122.59318400000001, 118.094123840332], + [37.87420875000569, -122.59320517497339, 117.4615020751953], + [37.874239600007584, -122.59322634996452, 116.675407409668], + [37.87427045000569, -122.59324752497339, 115.9668884277344], + [37.8743013, -122.5932687, 115.4145050048828], + [37.874309800000006, -122.59327449999999, 115.2670822143555], + [37.8743462, -122.59330130000001, 114.6388854980469], + [37.87436795000121, -122.59331819999501, 114.1576614379883], + [37.8743897, -122.59333509999999, 113.7611694335938], + [37.8744154, -122.5933558, 113.2295303344727], + [37.8744326, -122.59337119999999, 112.9268341064453], + [37.874446899999995, -122.5933839, 112.7619400024414], + [37.8744583, -122.5933939, 112.6538467407227], + [37.874477999999996, -122.5934118, 112.4513473510742], + [37.8744808, -122.5934148, 112.4150238037109], + [37.8745141, -122.5934517, 112.0075607299805], + [37.874536299999995, -122.59348179999999, 111.6967468261719], + [37.874548100000005, -122.593499, 111.5194778442383], + [37.87456585000278, -122.59352464999382, 111.110221862793], + [37.8745836, -122.59355029999999, 110.7789077758789], + [37.87460740000503, -122.59358479998885, 110.3793792724609], + [37.874631199999996, -122.59361930000001, 110.018196105957], + [37.8746532, -122.5936516, 109.7550430297852], + [37.8746633, -122.59366580000001, 109.6642456054688], + [37.8746697, -122.59367510000001, 109.6100387573242], + [37.8746924, -122.59370349999999, 109.4697952270508], + [37.874720499999995, -122.59373450000001, 109.1399230957031], + [37.8747294, -122.5937428, 108.8625335693359], + [37.874743099999996, -122.59375550000001, 108.4254531860352], + [37.8747518, -122.5937629, 108.1962356567383], + [37.8747837, -122.59378949999999, 107.7074890136719], + [37.87480730000001, -122.5938061, 107.4170989990234], + [37.8748243, -122.59381820000002, 107.2365264892578], + [37.8748323, -122.59382389999999, 107.1425399780273], + [37.8748548500011, -122.59384004999504, 106.8412857055664], + [37.874877399999995, -122.59385619999999, 106.6889266967773], + [37.8749077, -122.5938778, 106.5659484863281], + [37.874930299999995, -122.59389519999999, 106.3973388671875], + [37.874957699999996, -122.59392120000001, 106.0722503662109], + [37.8749764, -122.59394209999999, 105.8590698242188], + [37.8749971, -122.5939728, 105.5732421875], + [37.875012700003936, -122.59400329999355, 105.2327346801758], + [37.8750283, -122.59403379999998, 104.9926071166992], + [37.87504470000601, -122.59407149999159, 104.8389739990234], + [37.875061099999996, -122.59410919999999, 104.6134719848633], + [37.87508120001068, -122.5941594499863, 104.1042022705078], + [37.8751013, -122.5942097, 103.6386642456055], + [37.875105399999995, -122.5942201, 103.5710983276367], + [37.87511980001096, -122.59425609998593, 103.3568878173828], + [37.875134200010955, -122.5942920999859, 103.1318588256836], + [37.875148599999996, -122.5943281, 102.8871612548828], + [37.87516590003153, -122.59437127995943, 102.5152893066406], + [37.875183200047296, -122.59441445993914, 101.6698913574219], + [37.87520050004729, -122.59445763993915, 101.1034851074219], + [37.875217800031535, -122.59450081995946, 100.665885925293], + [37.8752351, -122.59454400000001, 100.4282608032227], + [37.8752474, -122.5945753, 100.1729583740234], + [37.8752557, -122.5946029, 99.92610931396484], + [37.8752661, -122.59464000000001, 99.67705535888672], + [37.8752721, -122.59467719999999, 99.32769012451172], + [37.875277, -122.5947266, 98.74391174316406], + [37.8752767, -122.59477500000001, 98.39933776855469], + [37.8752728, -122.59481790000001, 97.9571304321289], + [37.8752692, -122.5948444, 97.65776062011719], + [37.875264050004105, -122.59487555000217, 97.48764038085938], + [37.8752589, -122.5949067, 97.37482452392578], + [37.875250950004556, -122.59493955000355, 97.20121765136719], + [37.875243, -122.59497239999999, 97.02064514160156], + [37.8752349000036, -122.59500155000322, 96.80912780761719], + [37.8752268, -122.59503070000001, 96.60395050048828], + [37.87521840000001, -122.5950609, 95.8960189819336], + [37.875211000003056, -122.5950878000027, 95.58219909667969], + [37.8752036, -122.59511470000001, 95.38833618164062], + [37.875193025020984, -122.59515537501753, 94.92228698730469], + [37.87518245002798, -122.59519605002335, 94.6475601196289], + [37.875171875020975, -122.59523672501751, 94.5272216796875], + [37.875161299999995, -122.5952774, 94.43235778808594], + [37.87515010001018, -122.59532650000746, 94.25396728515625], + [37.875138899999996, -122.5953756, 94.2153549194336], + [37.87513240000471, -122.59540900000296, 94.23241424560547], + [37.8751259, -122.5954424, 94.2251205444336], + [37.875122999999995, -122.59545779999999, 94.2007827758789], + [37.87511640000668, -122.59549755000354, 94.08373260498047], + [37.8751098, -122.59553729999999, 94.10836029052734], + [37.87510280001336, -122.59559350000531, 93.98908996582031], + [37.8750958, -122.5956497, 93.64400482177734], + [37.875090966691246, -122.59570360000708, 92.28421020507812], + [37.875086133357904, -122.59575750000705, 91.99584197998047], + [37.8750813, -122.5958114, 91.60118865966797], + [37.87507720003824, -122.59586630000919, 91.49998474121094], + [37.87507310005098, -122.59592120001223, 91.42377471923828], + [37.87506900003824, -122.59597610000915, 91.36930847167969], + [37.8750649, -122.59603099999998, 91.28282928466797], + [37.87506057504004, -122.59608717500988, 91.21282958984375], + [37.875056250053376, -122.59614335001318, 91.1760025024414], + [37.87505192504003, -122.59619952500988, 90.82484436035156], + [37.875047599999995, -122.59625569999999, 89.94856262207031], + [37.875043100034, -122.59630747500948, 89.65546417236328], + [37.87503860004534, -122.59635925001265, 89.3915786743164], + [37.875034100034, -122.59641102500947, 89.10657501220703], + [37.8750296, -122.5964628, 88.66755676269531], + [37.875023400011926, -122.59651590000445, 88.44486236572266], + [37.875017199999995, -122.59656899999997, 88.33326721191406], + [37.8750108000356, -122.59662197501378, 88.2305679321289], + [37.87500440004747, -122.5966749500184, 88.05628967285156], + [37.8749980000356, -122.59672792501381, 87.96021270751953], + [37.8749916, -122.5967809, 87.80856323242188], + [37.8749834, -122.59682919999999, 86.94217681884766], + [37.874975899999995, -122.59686649999999, 86.67511749267578], + [37.874965650007894, -122.59690970000601, 86.47374725341797], + [37.8749554, -122.59695289999999, 86.24285888671875], + [37.8749451500052, -122.59698800000488, 85.91161346435547], + [37.8749349, -122.59702310000002, 85.69009399414062], + [37.87492175000529, -122.59705850000633, 85.52470397949219], + [37.8749086, -122.59709389999999, 85.43883514404297], + [37.8749002, -122.5971162, 85.38558197021484], + [37.874896799999995, -122.59712519999998, 85.36620330810547], + [37.874885450003084, -122.59715220000416, 85.3329086303711], + [37.8748741, -122.5971792, 85.3190689086914], + [37.87485980000464, -122.59721230000642, 85.32176971435547], + [37.8748455, -122.59724539999999, 85.31382751464844], + [37.8748383, -122.59726229999998, 85.32086944580078], + [37.874822933344156, -122.59729806668155, 85.28536987304688], + [37.87480756667749, -122.59733383334824, 85.23974609375], + [37.8747922, -122.5973696, 84.8681411743164], + [37.874781950003644, -122.59739895000409, 84.76154327392578], + [37.8747717, -122.59742829999999, 84.73399353027344], + [37.87476300000434, -122.5974603500038, 84.65728759765625], + [37.8747543, -122.59749240000001, 84.61331176757812], + [37.87474735000456, -122.59752525000309, 84.5819320678711], + [37.8747404, -122.59755809999999, 84.55724334716797], + [37.87473435000645, -122.59759715000321, 84.5339126586914], + [37.8747283, -122.5976362, 84.54029846191406], + [37.87472575000579, -122.59767320000128, 84.53975677490234], + [37.8747232, -122.59771020000001, 84.7155990600586], + [37.87472420000829, -122.59775444999941, 85.09952545166016], + [37.8747252, -122.59779870000001, 84.98198699951172], + [37.87473000001374, -122.59783899999476, 84.89041137695312], + [37.874734800013734, -122.59787929999476, 84.83177947998047], + [37.8747396, -122.59791960000001, 84.56102752685547], + [37.8747468, -122.59796999999999, 83.96664428710938], + [37.8747531000236, -122.59801312498895, 83.8592758178711], + [37.87475940003146, -122.59805624998523, 83.76145935058594], + [37.874765700023595, -122.59809937498895, 83.68009185791016], + [37.874772, -122.59814250000001, 83.60236358642578], + [37.874779499999995, -122.59819410000001, 83.48705291748047], + [37.8747816, -122.59820859999998, 83.4320297241211], + [37.87478820001376, -122.59824893332608, 83.2935791015625], + [37.87479480001376, -122.59828926665942, 83.21829986572266], + [37.874801399999996, -122.5983296, 83.1310043334961], + [37.8748059, -122.59835169999998, 83.07566833496094], + [37.87481220000447, -122.59838424999721, 82.99113464355469], + [37.874818499999996, -122.59841679999998, 82.89720916748047], + [37.87482425000457, -122.59844969999743, 82.71021270751953], + [37.874829999999996, -122.5984826, 82.42647552490234], + [37.874838150009495, -122.59852999999475, 82.35710144042969], + [37.8748463, -122.5985774, 82.37773895263672], + [37.874854200005906, -122.59861474999599, 82.36186218261719], + [37.8748621, -122.5986521, 82.34906768798828], + [37.87487025000376, -122.5986818999967, 82.31961059570312], + [37.8748784, -122.59871170000001, 82.29129028320312], + [37.87488745000341, -122.59874009999649, 82.25433349609375], + [37.8748965, -122.59876849999999, 82.2078857421875], + [37.87490785000326, -122.5987962499957, 82.16609954833984], + [37.8749192, -122.598824, 82.11095428466797], + [37.87493380000339, -122.59885229999436, 82.06906127929688], + [37.8749484, -122.59888059999999, 82.02014923095703], + [37.87496740000398, -122.59891129999208, 81.961669921875], + [37.8749864, -122.598942, 81.86585998535156], + [37.8750158, -122.59898, 81.73291015625], + [37.8750339, -122.59900019999999, 81.59130096435547], + [37.87504500000001, -122.59901199999999, 81.31362915039062], + [37.8750534, -122.5990202, 80.98114776611328], + [37.875054899999995, -122.59902159999999, 80.9353256225586], + [37.8750584, -122.59902519999999, 80.86068725585938], + [37.875081300001504, -122.59904409999412, 80.67971801757812], + [37.875104199999996, -122.59906299999999, 80.60419464111328], + [37.87513150000123, -122.59908009999367, 80.48074340820312], + [37.8751588, -122.59909719999999, 80.37382507324219], + [37.8751624, -122.59909990000001, 80.3638687133789], + [37.8752008, -122.5991195, 80.25958251953125], + [37.8752433, -122.59913320000001, 80.22148895263672], + [37.875264800000004, -122.59913929999998, 80.24620819091797], + [37.875299450000256, -122.59914709999633, 80.33087158203125], + [37.875334099999996, -122.5991549, 81.19083404541016], + [37.8753769, -122.59916260000001, 81.43265533447266], + [37.87542053333396, -122.5991712666564, 81.28951263427734], + [37.87546416666731, -122.59917993332304, 81.03874206542969], + [37.8755078, -122.59918859999999, 81.3040771484375], + [37.87554080000035, -122.5991977499959, 81.36746215820312], + [37.8755738, -122.5992069, 81.475830078125], + [37.8756008, -122.5992164, 81.6194839477539], + [37.8756384, -122.599233, 81.80723571777344], + [37.8756783, -122.5992531, 81.99449920654297], + [37.87570035000104, -122.59926874999532, 82.05106353759766], + [37.8757224, -122.59928439999999, 82.10525512695312], + [37.8757247, -122.5992863, 82.1114501953125], + [37.875731099999996, -122.59929199999998, 82.1287841796875], + [37.8757571, -122.59931530000001, 82.15972137451172], + [37.8757641, -122.59932160000001, 82.1656265258789], + [37.8757788, -122.59933559999999, 82.17739868164062], + [37.87578140000001, -122.59933799999999, 82.17864227294922], + [37.8757903, -122.59934639999999, 82.17743682861328], + [37.8758018, -122.59935730000001, 82.18016052246094], + [37.875827400000006, -122.59938400000001, 82.15970611572266], + [37.875828899999995, -122.59938550000001, 82.15828704833984], + [37.875850500002144, -122.59940804999339, 82.13775634765625], + [37.875872099999995, -122.5994306, 82.10380554199219], + [37.875904100013706, -122.59946347495716, 82.03092956542969], + [37.87593610001827, -122.59949634994287, 81.94249725341797], + [37.87596810001371, -122.59952922495718, 81.81480407714844], + [37.8760001, -122.5995621, 81.69308471679688], + [37.87602295000189, -122.59958324999344, 81.62130737304688], + [37.8760458, -122.59960439999999, 81.57073211669922], + [37.8760608, -122.59961829999999, 81.54045867919922], + [37.876078799999995, -122.59963499999999, 81.51771545410156], + [37.8761028, -122.59965729999999, 81.48796081542969], + [37.8761107, -122.59966459999998, 81.47360229492188], + [37.876112299999996, -122.59966610000001, 81.4713134765625], + [37.876131650001376, -122.59968414999527, 81.44222259521484], + [37.876151, -122.5997022, 81.42250061035156], + [37.8761584, -122.599709, 81.41771697998047], + [37.876163999999996, -122.5997142, 81.4151611328125], + [37.8761771, -122.59972630000001, 81.41920471191406], + [37.8761911, -122.59973929999998, 81.43757629394531], + [37.8762058, -122.5997531, 81.46727752685547], + [37.8762085, -122.5997555, 81.47528839111328], + [37.8762207, -122.5997671, 81.51010131835938], + [37.8762494, -122.59979410000001, 81.61426544189453], + [37.876255, -122.5998005, 81.62471008300781], + [37.8762565, -122.59980209999999, 81.62897491455078], + [37.87628035000313, -122.59982934999115, 81.69186401367188], + [37.8763042, -122.59985659999998, 81.77251434326172], + [37.87632505000407, -122.5998875999912, 81.76936340332031], + [37.8763459, -122.5999186, 81.82862091064453], + [37.87636580000623, -122.5999569999896, 81.95818328857422], + [37.8763857, -122.5999954, 82.15127563476562], + [37.8763973, -122.6000196, 82.23616790771484], + [37.87641075000393, -122.60005004999442, 82.29195404052734], + [37.876424199999995, -122.60008049999998, 82.3173599243164], + [37.8764319, -122.6000999, 82.32704162597656], + [37.87644643334473, -122.60013659998552, 82.33269500732422], + [37.87646096667807, -122.60017329998551, 82.32066345214844], + [37.8764755, -122.60021, 82.29862976074219], + [37.876491350009886, -122.6002583499896, 82.23788452148438], + [37.8765072, -122.60030669999998, 82.20567321777344], + [37.8765086, -122.6003109, 82.2064208984375], + [37.876515, -122.6003305, 82.21946716308594], + [37.876524499999995, -122.60035970000001, 82.25032043457031] + ] +} diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/HillyRoadAlertViewModelTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/HillyRoadAlertViewModelTest.kt new file mode 100644 index 00000000..a1d7d11a --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/HillyRoadAlertViewModelTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * JVM Unit Tests for [HillyRoadAlertViewModel]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class HillyRoadAlertViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + private lateinit var viewModel: HillyRoadAlertViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + viewModel = HillyRoadAlertViewModel() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun beforeMapReady_doesNotCalculateAlertsOrAdvanceRoute() { + val preReadyState = viewModel.currentState + assertFalse(preReadyState.isMapReady) + assertFalse(preReadyState.isPlaying) + assertEquals(0.0, preReadyState.currentDistanceMeters, 0.001) + assertEquals(TerrainAlertType.NONE, preReadyState.terrainAlert.alertType) + + // Frame ticks before map is ready must not advance distance + viewModel.advance(0.5) + assertEquals(0.0, viewModel.currentState.currentDistanceMeters, 0.001) + } + + @Test + fun initialState_afterMapReady_usesRollerCoasterPresetAnd500mLookahead() { + viewModel.onMapReady() + val state = viewModel.currentState + assertTrue(state.isMapReady) + assertEquals(HillyRoutePreset.ROLLER_COASTER, state.selectedPreset) + assertEquals(500.0, state.lookaheadHorizonMeters, 0.001) + assertTrue(state.isPlaying) + assertEquals(0.0, state.currentDistanceMeters, 0.001) + // Starts ~179m before the first roller-coaster dip/crest section -> early CAUTION alert + assertEquals(TerrainAlertType.UNDULATING_ROLLER_COASTER, state.terrainAlert.alertType) + assertEquals(AlertSeverity.CAUTION, state.terrainAlert.severity) + } + + @Test + fun selectRoute_switchesPresetsAndUpdatesTerrainAlert() { + viewModel.onMapReady() + viewModel.seekToDistance(120.0) + + viewModel.selectRoute(HillyRoutePreset.COASTAL_DRIVE) + val coastalState = viewModel.currentState + assertEquals(HillyRoutePreset.COASTAL_DRIVE, coastalState.selectedPreset) + assertEquals(0.0, coastalState.currentDistanceMeters, 0.001) + assertEquals(HillyRouteData.COASTAL_DRIVE_PATH.size, coastalState.routePoints.size) + assertTrue(coastalState.terrainAlert.lookaheadProfile.isNotEmpty()) + + viewModel.selectRoute(HillyRoutePreset.MOUNTAIN_PASS) + val mountainState = viewModel.currentState + assertEquals(HillyRoutePreset.MOUNTAIN_PASS, mountainState.selectedPreset) + assertEquals(0.0, mountainState.currentDistanceMeters, 0.001) + assertEquals(HillyRouteData.MOUNTAIN_PASS_PATH.size, mountainState.routePoints.size) + assertTrue(mountainState.terrainAlert.lookaheadProfile.isNotEmpty()) + } + + @Test + fun setLookaheadHorizon_shiftsEarlyAlertTriggerDistance() { + viewModel.onMapReady() + // Past the undulating zone (at 760m, after the 737m cluster end) with 250m horizon -> NONE + viewModel.seekToDistance(760.0) + viewModel.setLookaheadHorizon(250.0) + assertEquals(250.0, viewModel.currentState.lookaheadHorizonMeters, 0.001) + assertEquals(TerrainAlertType.NONE, viewModel.currentState.terrainAlert.alertType) + + // At 0m, 500m lookahead horizon ([0m, 500m]) reaches the ~179.4m roller-coaster section -> CAUTION + viewModel.seekToDistance(0.0) + viewModel.setLookaheadHorizon(500.0) + assertEquals(500.0, viewModel.currentState.lookaheadHorizonMeters, 0.001) + assertEquals( + TerrainAlertType.UNDULATING_ROLLER_COASTER, + viewModel.currentState.terrainAlert.alertType + ) + assertEquals(AlertSeverity.CAUTION, viewModel.currentState.terrainAlert.severity) + val countAt500 = viewModel.currentState.terrainAlert.upcomingExtrema.size + + // 750m lookahead horizon previews additional upcoming extrema (dip_82 at ~725.8m) + viewModel.setLookaheadHorizon(750.0) + assertEquals(750.0, viewModel.currentState.lookaheadHorizonMeters, 0.001) + val countAt750 = viewModel.currentState.terrainAlert.upcomingExtrema.size + assertTrue(countAt750 > countAt500) + } + + @Test + fun jumpToHillyZone_seeksBeforeFirstCrestAndTriggersEarlyAlert() { + viewModel.onMapReady() + viewModel.setLookaheadHorizon(250.0) + viewModel.seekToDistance(760.0) + assertEquals(TerrainAlertType.NONE, viewModel.currentState.terrainAlert.alertType) + + // Jump to the approach before the first roller-coaster section (~179m ahead at 0m) + viewModel.jumpToHillyZone() + val jumpedState = viewModel.currentState + assertTrue(jumpedState.currentDistanceMeters in 0.0..50.0) + assertEquals( + TerrainAlertType.UNDULATING_ROLLER_COASTER, + jumpedState.terrainAlert.alertType + ) + assertEquals(AlertSeverity.CAUTION, jumpedState.terrainAlert.severity) + } + + @Test + fun playbackScrubbing_speedMultiplier_and_autoCameraPitchPreview() { + viewModel.onMapReady() + // Scrubbing to 50% of route (actively inside the roller-coaster undulations) + viewModel.setScrubbing(true) + assertTrue(viewModel.currentState.isUserScrubbing) + viewModel.seekToProgress(0.5f) + assertEquals(0.5f, viewModel.currentState.progressRatio, 0.01f) + assertEquals(AlertSeverity.WARNING, viewModel.currentState.terrainAlert.severity) + viewModel.setScrubbing(false) + assertFalse(viewModel.currentState.isUserScrubbing) + + // Auto camera pitch preview widens range (230m) and lowers tilt (66 deg) during roller-coaster alert + assertEquals( + HillyRoadAlertViewModel.PREVIEW_CAMERA_RANGE, + viewModel.currentState.cameraRange, + 0.01 + ) + assertEquals( + HillyRoadAlertViewModel.PREVIEW_CAMERA_TILT, + viewModel.currentState.cameraTilt, + 0.01 + ) + + // Toggling off auto camera pitch preview restores normal chase range (160m) and tilt (74 deg) + viewModel.toggleAutoCameraPitchPreview() + assertFalse(viewModel.currentState.autoCameraPitchPreview) + assertEquals( + HillyRoadAlertViewModel.NORMAL_CAMERA_RANGE, + viewModel.currentState.cameraRange, + 0.01 + ) + assertEquals( + HillyRoadAlertViewModel.NORMAL_CAMERA_TILT, + viewModel.currentState.cameraTilt, + 0.01 + ) + + // Speed multiplier and advance + val distBefore = viewModel.currentState.currentDistanceMeters + viewModel.setSpeedMultiplier(2.0) + assertEquals(2.0, viewModel.currentState.speedMultiplier, 0.001) + viewModel.advance(0.05) + assertTrue(viewModel.currentState.currentDistanceMeters > distBefore) + + // Pause prevents advance + viewModel.togglePlayPause() + assertFalse(viewModel.currentState.isPlaying) + val pausedDist = viewModel.currentState.currentDistanceMeters + viewModel.advance(0.05) + assertEquals(pausedDist, viewModel.currentState.currentDistanceMeters, 0.001) + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/RoadTerrainAnalyzerTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/RoadTerrainAnalyzerTest.kt new file mode 100644 index 00000000..47829d78 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/RoadTerrainAnalyzerTest.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps3d.model.LatLngAltitude +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * JVM Unit Tests for [RoadTerrainAnalyzer]. + */ +class RoadTerrainAnalyzerTest { + + @Test + fun flatAndGentleRoute_emitsNoneAlertAndNoneSeverity() { + val flatGentleRoute = listOf( + LatLngAltitude(37.80600, -122.44800, 8.0), + LatLngAltitude(37.80600, -122.44630, 8.5), + LatLngAltitude(37.80600, -122.44460, 9.2), + LatLngAltitude(37.80600, -122.44290, 9.8), + LatLngAltitude(37.80600, -122.44120, 9.4), + LatLngAltitude(37.80600, -122.43950, 8.8), + LatLngAltitude(37.80600, -122.43780, 8.3), + LatLngAltitude(37.80600, -122.43610, 8.0) + ) + val state = RoadTerrainAnalyzer.analyze( + path = flatGentleRoute, + currentDistanceMeters = 0.0, + lookaheadHorizonMeters = 500.0 + ) + + assertEquals(TerrainAlertType.NONE, state.alertType) + assertEquals(AlertSeverity.NONE, state.severity) + assertTrue(state.upcomingExtrema.isEmpty()) + assertTrue(state.hazardPolylineCoordinates.isEmpty()) + assertEquals(80, state.recommendedSpeedKmh) + } + + @Test + fun approachingUndulatingSection_emitsCautionEarlyAndEscalatesToWarning() { + val route = HillyRouteData.ROLLER_COASTER_PATH + + // 1. At 0m with a 150m horizon (before the ~179.4m roller-coaster dip/crest zone): -> NONE + val shortHorizonAtFlatSection = RoadTerrainAnalyzer.analyze( + path = route, + currentDistanceMeters = 0.0, + lookaheadHorizonMeters = 150.0 + ) + assertEquals(TerrainAlertType.NONE, shortHorizonAtFlatSection.alertType) + assertEquals(AlertSeverity.NONE, shortHorizonAtFlatSection.severity) + + // 2. Approaching the ~179.4m roller-coaster section from 0m with 500m horizon: + // hazard enters lookahead window -> UNDULATING_ROLLER_COASTER with CAUTION (>= 150m ahead) + val firstCrestEntering = RoadTerrainAnalyzer.analyze( + path = route, + currentDistanceMeters = 0.0, + lookaheadHorizonMeters = 500.0 + ) + assertEquals(TerrainAlertType.UNDULATING_ROLLER_COASTER, firstCrestEntering.alertType) + assertEquals(AlertSeverity.CAUTION, firstCrestEntering.severity) + assertTrue(firstCrestEntering.distanceToHazardMeters >= 150.0) + assertTrue(firstCrestEntering.upcomingExtrema.isNotEmpty()) + assertEquals(ExtremaType.DIP, firstCrestEntering.upcomingExtrema.first().type) + + // 3. Within 150m of the hazard zone (at 60m, ~119m before ~179.4m) -> + // escalates to AlertSeverity.WARNING + val imminentState = RoadTerrainAnalyzer.analyze( + path = route, + currentDistanceMeters = 60.0, + lookaheadHorizonMeters = 500.0 + ) + assertEquals(TerrainAlertType.UNDULATING_ROLLER_COASTER, imminentState.alertType) + assertEquals(AlertSeverity.WARNING, imminentState.severity) + assertTrue(imminentState.distanceToHazardMeters in 1.0..149.9) + + // 4. Actively inside the undulating hazard zone (at 250m) -> + // distanceToHazardMeters == 0.0 and AlertSeverity.WARNING + val activeInsideState = RoadTerrainAnalyzer.analyze( + path = route, + currentDistanceMeters = 250.0, + lookaheadHorizonMeters = 500.0 + ) + assertEquals(TerrainAlertType.UNDULATING_ROLLER_COASTER, activeInsideState.alertType) + assertEquals(AlertSeverity.WARNING, activeInsideState.severity) + assertEquals(0.0, activeInsideState.distanceToHazardMeters, 0.001) + assertTrue(activeInsideState.remainingHazardDistanceMeters > 100.0) + assertTrue(activeInsideState.recommendedSpeedKmh < 80) + } + + @Test + fun crestAndDipInflectionPoints_detectedWithStableIdsAndExpectedGradeDeltas() { + val route = HillyRouteData.ROLLER_COASTER_PATH + + val stateAt0m = RoadTerrainAnalyzer.analyze( + path = route, + currentDistanceMeters = 0.0, + lookaheadHorizonMeters = 800.0 + ) + val stateAt70m = RoadTerrainAnalyzer.analyze( + path = route, + currentDistanceMeters = 70.0, + lookaheadHorizonMeters = 800.0 + ) + + assertTrue(stateAt0m.crestCount >= 1) + assertTrue(stateAt0m.dipCount >= 2) + + val firstCrestAt0 = stateAt0m.upcomingExtrema.first { it.type == ExtremaType.CREST } + val firstDipAt0 = stateAt0m.upcomingExtrema.first { it.type == ExtremaType.DIP } + + val firstCrestAt70 = stateAt70m.upcomingExtrema.first { it.type == ExtremaType.CREST } + val firstDipAt70 = stateAt70m.upcomingExtrema.first { it.type == ExtremaType.DIP } + + // Stable IDs across frames as the vehicle advances + assertEquals(firstCrestAt0.id, firstCrestAt70.id) + assertEquals(firstDipAt0.id, firstDipAt70.id) + + // Distance ahead decreases by exactly 70m as vehicle moves from 0m to 70m + assertEquals( + firstCrestAt0.distanceAheadMeters - 70.0, + firstCrestAt70.distanceAheadMeters, + 0.05 + ) + + // Expected grade deltas: positive for CREST, negative for DIP + assertTrue(firstCrestAt0.gradeDeltaPercent >= 15.0) + assertTrue(firstDipAt0.gradeDeltaPercent <= -15.0) + } + + @Test + fun sustainedSteepClimb_blindCrest_and_steepDescent_classifiedAccurately() { + val mountainRoute = listOf( + LatLngAltitude(37.85500, -122.48500, 120.0), + LatLngAltitude(37.85626, -122.48500, 122.0), + LatLngAltitude(37.85761, -122.48500, 138.0), + LatLngAltitude(37.85896, -122.48500, 155.0), + LatLngAltitude(37.86031, -122.48500, 172.0), + LatLngAltitude(37.86085, -122.48500, 178.0), // Summit Crest + LatLngAltitude(37.86220, -122.48500, 161.0), + LatLngAltitude(37.86355, -122.48500, 144.0), + LatLngAltitude(37.86490, -122.48500, 127.0), + LatLngAltitude(37.86616, -122.48500, 125.0) + ) + + // 1. On the initial climb before the summit (~650m) enters a 250m lookahead window + val climbState = RoadTerrainAnalyzer.analyze( + path = mountainRoute, + currentDistanceMeters = 160.0, + lookaheadHorizonMeters = 250.0 + ) + assertEquals(TerrainAlertType.STEEP_CLIMB, climbState.alertType) + assertTrue(climbState.maxPositiveGradePercent >= 9.0) + + // 2. Approaching the single sharp summit crest (~650m) within a 250m window + val summitCrestState = RoadTerrainAnalyzer.analyze( + path = mountainRoute, + currentDistanceMeters = 450.0, + lookaheadHorizonMeters = 250.0 + ) + assertEquals(TerrainAlertType.BLIND_CREST, summitCrestState.alertType) + assertEquals(1, summitCrestState.crestCount) + assertEquals(0, summitCrestState.dipCount) + + // 3. Past the summit (~700m), descending the sustained -10.9% grade + val descentState = RoadTerrainAnalyzer.analyze( + path = mountainRoute, + currentDistanceMeters = 700.0, + lookaheadHorizonMeters = 250.0 + ) + assertEquals(TerrainAlertType.STEEP_DESCENT, descentState.alertType) + assertTrue(descentState.maxNegativeGradePercent <= -9.0) + } + + @Test + fun duplicateConsecutivePoints_and_boundaryDistances_neverProduceNaNOrCrash() { + val pathWithDuplicates = listOf( + LatLngAltitude(37.78500, -122.41850, 40.0), + LatLngAltitude(37.78500, -122.41850, 45.0), // Duplicate 2D coordinate + LatLngAltitude(37.78563, -122.41860, 48.0), + LatLngAltitude(37.78563, -122.41860, 48.0), // Duplicate 3D coordinate + LatLngAltitude(37.78626, -122.41870, 40.0) + ) + + // Zero horizontal distance guard + assertEquals(0.0, RoadTerrainAnalyzer.computeGradePercent(10.0, 0.0), 0.0001) + assertEquals(0.0, RoadTerrainAnalyzer.computeGradePercent(10.0, 1e-6), 0.0001) + + // Start, negative, middle, and past-end distances + val testDistances = listOf(-50.0, 0.0, 35.0, 141.0, 5000.0) + for (dist in testDistances) { + val state = RoadTerrainAnalyzer.analyze( + path = pathWithDuplicates, + currentDistanceMeters = dist, + lookaheadHorizonMeters = 500.0 + ) + assertFalse(state.currentGradePercent.isNaN()) + assertFalse(state.maxPositiveGradePercent.isNaN()) + assertFalse(state.maxNegativeGradePercent.isNaN()) + assertFalse(state.oscillationScore.isNaN()) + assertFalse(state.distanceToHazardMeters.isNaN()) + for (pt in state.lookaheadProfile) { + assertFalse(pt.altitudeMeters.isNaN()) + assertFalse(pt.distanceOffsetMeters.isNaN()) + } + } + + // Empty and single-point routes + val emptyState = RoadTerrainAnalyzer.analyze(emptyList(), 0.0, 500.0) + assertEquals(TerrainAlertType.NONE, emptyState.alertType) + + val singlePointState = RoadTerrainAnalyzer.analyze( + listOf(LatLngAltitude(37.0, -122.0, 10.0)), + 0.0, + 500.0 + ) + assertEquals(TerrainAlertType.NONE, singlePointState.alertType) + } +} diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml b/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml index 483e62f9..5ee2c9ca 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml @@ -17,6 +17,8 @@ + + + + diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/hillyroadalerts/HillyRoadAlertsActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/hillyroadalerts/HillyRoadAlertsActivity.java new file mode 100644 index 00000000..ce971a72 --- /dev/null +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/hillyroadalerts/HillyRoadAlertsActivity.java @@ -0,0 +1,721 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3djava.hillyroadalerts; + +import android.content.res.Configuration; +import android.graphics.Color; +import android.os.Bundle; +import android.view.Choreographer; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProvider; +import com.example.maps3d.common.AlertSeverity; +import com.example.maps3d.common.ElevationSparklineView; +import com.example.maps3d.common.ExtremaType; +import com.example.maps3d.common.HillyRoadAlertViewModel; +import com.example.maps3d.common.HillyRoadUiState; +import com.example.maps3d.common.HillyRoutePreset; +import com.example.maps3d.common.MaxHeightScrollView; +import com.example.maps3d.common.TerrainAlertState; +import com.example.maps3d.common.TerrainAlertType; +import com.example.maps3d.common.TerrainExtremaPoint; +import com.example.maps3dcommon.R; +import com.example.maps3djava.sampleactivity.SampleBaseActivity; +import com.google.android.gms.maps3d.GoogleMap3D; +import com.google.android.gms.maps3d.model.AltitudeMode; +import com.google.android.gms.maps3d.model.Camera; +import com.google.android.gms.maps3d.model.CollisionBehavior; +import com.google.android.gms.maps3d.model.Glyph; +import com.google.android.gms.maps3d.model.LatLngAltitude; +import com.google.android.gms.maps3d.model.Map3DMode; +import com.google.android.gms.maps3d.model.Marker; +import com.google.android.gms.maps3d.model.MarkerOptions; +import com.google.android.gms.maps3d.model.Model; +import com.google.android.gms.maps3d.model.ModelOptions; +import com.google.android.gms.maps3d.model.Orientation; +import com.google.android.gms.maps3d.model.PinConfiguration; +import com.google.android.gms.maps3d.model.Polyline; +import com.google.android.gms.maps3d.model.PolylineOptions; +import com.google.android.gms.maps3d.model.Vector3D; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.materialswitch.MaterialSwitch; +import com.google.android.material.slider.Slider; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Java Views showcase sample for "Hilly & Undulating (Roller-Coaster) Road Early Alerts". + * + *

Maintains 1:1 behavioral and visual parity with Kotlin Views and Jetpack Compose by observing + * {@link HillyRoadAlertViewModel#getUiStateLiveData()}. + */ +public class HillyRoadAlertsActivity extends SampleBaseActivity + implements Choreographer.FrameCallback { + + private static final String TAG = HillyRoadAlertsActivity.class.getSimpleName(); + + private HillyRoadAlertViewModel viewModel; + + // HUD Views + private MaterialCardView hudAlertCard; + private TextView tvAlertTitle; + private TextView tvAlertDistancePill; + private TextView tvChipExtrema; + private TextView tvChipMaxGrade; + private TextView tvChipAdvisorySpeed; + private TextView tvSparklineLabel; + private ElevationSparklineView sparklineView; + + // Bottom Sheet Controls + private MaxHeightScrollView controlsScroll; + private MaterialButton btnCollapse; + private TextView tvCurrentTelemetry; + private Chip chipJumpToHills; + private MaterialButton btnPlayPause; + private Slider progressSlider; + private ChipGroup chipGroupRoute; + private ChipGroup chipGroupHorizon; + private ChipGroup chipGroupSpeed; + private MaterialSwitch switchCameraPreview; + + // 3D Map Overlays & Diffing State + @Nullable private Polyline baseRoutePolyline; + @Nullable private Polyline traveledRoutePolyline; + @Nullable private Polyline hazardRoutePolyline; + @Nullable private Model vehicleModel; + private final Map activeExtremaMarkers = new HashMap<>(); + + @Nullable private HillyRoutePreset renderedPreset; + private double lastRenderedTraveledDist = -1.0; + private List lastRenderedHazardCoords = Collections.emptyList(); + @Nullable private AlertSeverity renderedHazardSeverity; + @Nullable private LatLngAltitude lastRenderedVehiclePos; + private double lastRenderedVehicleHeading = Double.NaN; + private boolean isControlsExpanded = true; + private boolean isUpdatingUiFromState = false; + private long lastFrameTimeNanos = 0L; + + @Override + public String getTAG() { + return TAG; + } + + @Override + protected int getLayoutResId() { + return R.layout.activity_hilly_road_alerts; + } + + @Override + public Camera getInitialCamera() { + return new Camera( + new LatLngAltitude(36.485940, -114.532890, 614.4), + 0.0, + HillyRoadAlertViewModel.NORMAL_CAMERA_TILT, + 0.0, + HillyRoadAlertViewModel.NORMAL_CAMERA_RANGE); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Note: SampleBaseActivity already inflated getLayoutResId() and initialized map3DView. + viewModel = new ViewModelProvider(this).get(HillyRoadAlertViewModel.class); + + bindViews(); + configureViewportGuardrails(); + setupControlListeners(); + + viewModel.getUiStateLiveData().observe(this, this::renderUiState); + } + + @Override + protected void onMapReady(@NonNull GoogleMap3D googleMap3D) { + super.onMapReady(googleMap3D); + googleMap3D.setMapMode(Map3DMode.HYBRID); + googleMap3D.setOnMapSteadyListener( + isSceneSteady -> { + if (isSceneSteady) { + if (map3DView != null) { + map3DView.setContentDescription("MapSteady"); + } + runOnUiThread(() -> startRouteIfMapReady(googleMap3D)); + } + }); + if (HillyRoadAlertViewModel.isInternetAvailable(this)) { + startRouteIfMapReady(googleMap3D); + } + } + + private void startRouteIfMapReady(@NonNull GoogleMap3D map) { + if (viewModel == null || viewModel.getCurrentState().isMapReady) { + return; + } + lastFrameTimeNanos = 0L; + viewModel.onMapReady(); + renderMapScene(map, viewModel.getCurrentState()); + } + + @Override + protected void onResume() { + super.onResume(); + lastFrameTimeNanos = 0L; + Choreographer.getInstance().postFrameCallback(this); + } + + @Override + protected void onPause() { + Choreographer.getInstance().removeFrameCallback(this); + super.onPause(); + } + + @Override + protected void onDestroy() { + Choreographer.getInstance().removeFrameCallback(this); + clearMapOverlays(); + super.onDestroy(); + } + + @Override + public void doFrame(long frameTimeNanos) { + boolean isReady = + googleMap3D != null && viewModel != null && viewModel.getCurrentState().isMapReady; + if (isReady && lastFrameTimeNanos != 0L) { + double dtSeconds = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000_000.0; + viewModel.advance(dtSeconds); + } + if (isReady) { + lastFrameTimeNanos = frameTimeNanos; + } + Choreographer.getInstance().postFrameCallback(this); + } + + private void bindViews() { + hudAlertCard = findViewById(R.id.hud_alert_card); + tvAlertTitle = findViewById(R.id.tv_alert_title); + tvAlertDistancePill = findViewById(R.id.tv_alert_distance_pill); + tvChipExtrema = findViewById(R.id.tv_chip_extrema); + tvChipMaxGrade = findViewById(R.id.tv_chip_max_grade); + tvChipAdvisorySpeed = findViewById(R.id.tv_chip_advisory_speed); + tvSparklineLabel = findViewById(R.id.tv_sparkline_label); + sparklineView = findViewById(R.id.sparkline_view); + + controlsScroll = findViewById(R.id.controls_scroll); + btnCollapse = findViewById(R.id.btn_collapse); + tvCurrentTelemetry = findViewById(R.id.tv_current_telemetry); + chipJumpToHills = findViewById(R.id.chip_jump_to_hills); + btnPlayPause = findViewById(R.id.btn_play_pause); + progressSlider = findViewById(R.id.progress_slider); + chipGroupRoute = findViewById(R.id.chip_group_route); + chipGroupHorizon = findViewById(R.id.chip_group_horizon); + chipGroupSpeed = findViewById(R.id.chip_group_speed); + switchCameraPreview = findViewById(R.id.switch_camera_preview); + } + + private void configureViewportGuardrails() { + int screenHeight = getResources().getDisplayMetrics().heightPixels; + boolean isLandscape = + getResources().getConfiguration().orientation + == Configuration.ORIENTATION_LANDSCAPE; + int maxScrollHeight = (int) (screenHeight * (isLandscape ? 0.28f : 0.36f)); + controlsScroll.setMaxHeightPx(maxScrollHeight); + + setControlsExpanded(!isLandscape); + } + + private void setControlsExpanded(boolean expanded) { + isControlsExpanded = expanded; + controlsScroll.setVisibility(expanded ? View.VISIBLE : View.GONE); + btnCollapse.setIconResource( + expanded ? R.drawable.expand_more_24px : R.drawable.expand_less_24px); + btnCollapse.setContentDescription( + getString(expanded ? R.string.collapse_controls : R.string.expand_controls)); + } + + private void setupControlListeners() { + View.OnClickListener toggleCollapseListener = + v -> setControlsExpanded(!isControlsExpanded); + FrameLayout dragHandle = findViewById(R.id.layout_drag_handle); + LinearLayout cardHeader = findViewById(R.id.card_header); + dragHandle.setOnClickListener(toggleCollapseListener); + cardHeader.setOnClickListener(toggleCollapseListener); + btnCollapse.setOnClickListener(toggleCollapseListener); + + btnPlayPause.setOnClickListener(v -> viewModel.togglePlayPause()); + chipJumpToHills.setOnClickListener(v -> viewModel.jumpToHillyZone()); + + progressSlider.addOnSliderTouchListener( + new Slider.OnSliderTouchListener() { + @Override + public void onStartTrackingTouch(@NonNull Slider slider) { + viewModel.setScrubbing(true); + } + + @Override + public void onStopTrackingTouch(@NonNull Slider slider) { + viewModel.setScrubbing(false); + } + }); + + progressSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser && !isUpdatingUiFromState) { + viewModel.seekToProgress(value); + } + }); + + chipGroupRoute.setOnCheckedStateChangeListener( + (group, checkedIds) -> { + if (isUpdatingUiFromState || checkedIds.isEmpty()) { + return; + } + int checkedId = checkedIds.get(0); + if (checkedId == R.id.chip_route_roller_coaster) { + viewModel.selectRoute(HillyRoutePreset.ROLLER_COASTER); + } else if (checkedId == R.id.chip_route_mountain_pass) { + viewModel.selectRoute(HillyRoutePreset.MOUNTAIN_PASS); + } else if (checkedId == R.id.chip_route_coastal) { + viewModel.selectRoute(HillyRoutePreset.COASTAL_DRIVE); + } + }); + + chipGroupHorizon.setOnCheckedStateChangeListener( + (group, checkedIds) -> { + if (isUpdatingUiFromState || checkedIds.isEmpty()) { + return; + } + int checkedId = checkedIds.get(0); + if (checkedId == R.id.chip_horizon_250) { + viewModel.setLookaheadHorizon(250.0); + } else if (checkedId == R.id.chip_horizon_500) { + viewModel.setLookaheadHorizon(500.0); + } else if (checkedId == R.id.chip_horizon_750) { + viewModel.setLookaheadHorizon(750.0); + } + }); + + chipGroupSpeed.setOnCheckedStateChangeListener( + (group, checkedIds) -> { + if (isUpdatingUiFromState || checkedIds.isEmpty()) { + return; + } + int checkedId = checkedIds.get(0); + if (checkedId == R.id.chip_speed_05x) { + viewModel.setSpeedMultiplier(0.5); + } else if (checkedId == R.id.chip_speed_1x) { + viewModel.setSpeedMultiplier(1.0); + } else if (checkedId == R.id.chip_speed_2x) { + viewModel.setSpeedMultiplier(2.0); + } else if (checkedId == R.id.chip_speed_3x) { + viewModel.setSpeedMultiplier(3.0); + } else if (checkedId == R.id.chip_speed_5x) { + viewModel.setSpeedMultiplier(5.0); + } + }); + + switchCameraPreview.setOnCheckedChangeListener( + (buttonView, isChecked) -> { + if (!isUpdatingUiFromState) { + viewModel.setAutoCameraPitchPreview(isChecked); + } + }); + } + + private void renderUiState(@NonNull HillyRoadUiState state) { + isUpdatingUiFromState = true; + try { + TerrainAlertState alert = state.terrainAlert; + + tvAlertTitle.setText(getAlertTitleText(alert.alertType)); + if (alert.alertType == TerrainAlertType.NONE) { + tvAlertDistancePill.setText(getString(R.string.hilly_alert_distance_clear)); + } else if (alert.distanceToHazardMeters <= 0.5) { + tvAlertDistancePill.setText( + getString( + R.string.hilly_alert_distance_active_format, + (int) Math.round(alert.remainingHazardDistanceMeters))); + } else { + tvAlertDistancePill.setText( + getString( + R.string.hilly_alert_distance_approaching_format, + (int) Math.round(alert.distanceToHazardMeters))); + } + + int strokeColor; + int pillTextColor; + if (alert.severity == AlertSeverity.WARNING) { + strokeColor = Color.rgb(234, 67, 53); // Canonical Google Red (#EA4335) + pillTextColor = Color.rgb(254, 202, 202); + } else if (alert.severity == AlertSeverity.CAUTION) { + strokeColor = Color.rgb(251, 188, 4); // Canonical Google Amber (#FBBC04) + pillTextColor = Color.rgb(254, 240, 138); + } else { + strokeColor = Color.rgb(16, 185, 129); // Emerald (#10B981) + pillTextColor = Color.rgb(167, 243, 208); + } + hudAlertCard.setStrokeColor(strokeColor); + tvAlertDistancePill.setTextColor(pillTextColor); + + tvChipExtrema.setText( + getString( + R.string.hilly_telemetry_extrema_format, + alert.crestCount, + alert.dipCount)); + tvChipMaxGrade.setText( + String.format( + Locale.US, + getString(R.string.hilly_telemetry_max_grade_format), + alert.maxPositiveGradePercent, + alert.maxNegativeGradePercent)); + tvChipAdvisorySpeed.setText( + getString( + R.string.hilly_telemetry_advisory_speed_format, + alert.recommendedSpeedKmh)); + + tvSparklineLabel.setText( + getString( + R.string.hilly_sparkline_label_format, + (int) Math.round(state.lookaheadHorizonMeters))); + sparklineView.updateData( + alert.lookaheadProfile, + alert.upcomingExtrema, + alert.severity, + state.lookaheadHorizonMeters); + + tvCurrentTelemetry.setText( + String.format( + Locale.US, + getString(R.string.hilly_current_telemetry_format), + alert.currentGradePercent, + (int) Math.round(state.currentDistanceMeters), + (int) Math.round(state.totalDistanceMeters))); + + btnPlayPause.setIconResource( + state.isPlaying ? R.drawable.pause_24px : R.drawable.play_arrow_24px); + if (!state.isUserScrubbing) { + float clampedProgress = Math.max(0.0f, Math.min(1.0f, state.progressRatio)); + progressSlider.setValue(clampedProgress); + } + + int expectedRouteChipId; + if (state.selectedPreset == HillyRoutePreset.MOUNTAIN_PASS) { + expectedRouteChipId = R.id.chip_route_mountain_pass; + } else if (state.selectedPreset == HillyRoutePreset.COASTAL_DRIVE) { + expectedRouteChipId = R.id.chip_route_coastal; + } else { + expectedRouteChipId = R.id.chip_route_roller_coaster; + } + if (chipGroupRoute.getCheckedChipId() != expectedRouteChipId) { + chipGroupRoute.check(expectedRouteChipId); + } + + int roundedHorizon = (int) Math.round(state.lookaheadHorizonMeters); + int expectedHorizonChipId; + if (roundedHorizon == 250) { + expectedHorizonChipId = R.id.chip_horizon_250; + } else if (roundedHorizon == 750) { + expectedHorizonChipId = R.id.chip_horizon_750; + } else { + expectedHorizonChipId = R.id.chip_horizon_500; + } + if (chipGroupHorizon.getCheckedChipId() != expectedHorizonChipId) { + chipGroupHorizon.check(expectedHorizonChipId); + } + + int expectedSpeedChipId; + if (Math.abs(state.speedMultiplier - 0.5) < 0.05) { + expectedSpeedChipId = R.id.chip_speed_05x; + } else if (Math.abs(state.speedMultiplier - 2.0) < 0.05) { + expectedSpeedChipId = R.id.chip_speed_2x; + } else if (Math.abs(state.speedMultiplier - 3.0) < 0.05) { + expectedSpeedChipId = R.id.chip_speed_3x; + } else if (Math.abs(state.speedMultiplier - 5.0) < 0.05) { + expectedSpeedChipId = R.id.chip_speed_5x; + } else { + expectedSpeedChipId = R.id.chip_speed_1x; + } + if (chipGroupSpeed.getCheckedChipId() != expectedSpeedChipId) { + chipGroupSpeed.check(expectedSpeedChipId); + } + + if (switchCameraPreview.isChecked() != state.autoCameraPitchPreview) { + switchCameraPreview.setChecked(state.autoCameraPitchPreview); + } + } finally { + isUpdatingUiFromState = false; + } + + if (googleMap3D != null && state.isMapReady) { + renderMapScene(googleMap3D, state); + } + } + + private String getAlertTitleText(@NonNull TerrainAlertType alertType) { + switch (alertType) { + case UNDULATING_ROLLER_COASTER: + return getString(R.string.hilly_alert_title_roller_coaster); + case BLIND_CREST: + return getString(R.string.hilly_alert_title_blind_crest); + case STEEP_CLIMB: + return getString(R.string.hilly_alert_title_steep_climb); + case STEEP_DESCENT: + return getString(R.string.hilly_alert_title_steep_descent); + case NONE: + default: + return getString(R.string.hilly_alert_title_clear); + } + } + + private void renderMapScene(@NonNull GoogleMap3D map, @NonNull HillyRoadUiState state) { + try { + // 1. Update Driver Chase Camera + Camera cam = + new Camera( + state.cameraCenter, + state.cameraHeading, + state.cameraTilt, + 0.0, + state.cameraRange); + map.setCamera(cam); + + // 2. Rebuild Base Route Polyline & clear extrema markers if preset changed + if (renderedPreset != state.selectedPreset || baseRoutePolyline == null) { + renderedPreset = state.selectedPreset; + lastRenderedTraveledDist = -1.0; + lastRenderedHazardCoords = Collections.emptyList(); + if (baseRoutePolyline != null) { + baseRoutePolyline.remove(); + } + PolylineOptions baseOpts = new PolylineOptions(); + baseOpts.setId(HillyRoadAlertViewModel.BASE_ROUTE_POLYLINE_ID); + baseOpts.setPath(state.staticRouteVertices); + baseOpts.setStrokeColor(Color.argb(210, 56, 189, 248)); + baseOpts.setStrokeWidth(14.0); + baseOpts.setAltitudeMode(AltitudeMode.ABSOLUTE); + baseOpts.setZIndex(2); + baseOpts.setDrawsOccludedSegments(true); + baseRoutePolyline = map.addPolyline(baseOpts); + clearExtremaMarkers(); + } + + // 3. Update Traveled Progress Polyline + List traveledCoords = state.traveledRouteVertices; + if (traveledCoords.size() >= 2 + && (traveledRoutePolyline == null + || Math.abs(state.currentDistanceMeters - lastRenderedTraveledDist) + > 0.05)) { + lastRenderedTraveledDist = state.currentDistanceMeters; + PolylineOptions traveledOpts = new PolylineOptions(); + traveledOpts.setId(HillyRoadAlertViewModel.TRAVELED_ROUTE_POLYLINE_ID); + traveledOpts.setPath(traveledCoords); + traveledOpts.setStrokeColor(Color.rgb(16, 185, 129)); + traveledOpts.setStrokeWidth(16.0); + traveledOpts.setAltitudeMode(AltitudeMode.ABSOLUTE); + traveledOpts.setZIndex(3); + traveledOpts.setDrawsOccludedSegments(true); + traveledRoutePolyline = map.addPolyline(traveledOpts); + } + + // 4. Update Active Hazard Highlight Polyline + List hazardCoords = state.terrainAlert.hazardPolylineCoordinates; + AlertSeverity severity = state.terrainAlert.severity; + if (hazardCoords.size() >= 2 && severity != AlertSeverity.NONE) { + if (hazardRoutePolyline == null + || renderedHazardSeverity != severity + || !lastRenderedHazardCoords.equals(hazardCoords)) { + int hazardColor = + (severity == AlertSeverity.WARNING) + ? Color.rgb(234, 67, 53) + : Color.rgb(251, 188, 4); + renderedHazardSeverity = severity; + lastRenderedHazardCoords = hazardCoords; + PolylineOptions hazardOpts = new PolylineOptions(); + hazardOpts.setId(HillyRoadAlertViewModel.HAZARD_ROUTE_POLYLINE_ID); + hazardOpts.setPath(hazardCoords); + hazardOpts.setStrokeColor(hazardColor); + hazardOpts.setStrokeWidth(20.0); + hazardOpts.setAltitudeMode(AltitudeMode.ABSOLUTE); + hazardOpts.setZIndex(5); + hazardOpts.setDrawsOccludedSegments(true); + hazardRoutePolyline = map.addPolyline(hazardOpts); + } + } else { + if (hazardRoutePolyline != null) { + hazardRoutePolyline.remove(); + hazardRoutePolyline = null; + } + renderedHazardSeverity = null; + lastRenderedHazardCoords = Collections.emptyList(); + } + + // 5. Update Simulated Vehicle 3D Model (Red Car .glb from Routes API sample) + if (vehicleModel == null + || !state.vehiclePosition.equals(lastRenderedVehiclePos) + || Double.compare(state.currentHeadingDegrees, lastRenderedVehicleHeading) + != 0) { + lastRenderedVehiclePos = state.vehiclePosition; + lastRenderedVehicleHeading = state.currentHeadingDegrees; + ModelOptions vehicleOpts = new ModelOptions(); + vehicleOpts.setId(HillyRoadAlertViewModel.VEHICLE_MODEL_ID); + vehicleOpts.setPosition(state.vehiclePosition); + vehicleOpts.setAltitudeMode(AltitudeMode.ABSOLUTE); + vehicleOpts.setUrl(HillyRoadAlertViewModel.VEHICLE_MODEL_URL); + vehicleOpts.setOrientation( + new Orientation( + state.currentHeadingDegrees, + HillyRoadAlertViewModel.VEHICLE_MODEL_TILT, + 0.0)); + vehicleOpts.setScale( + new Vector3D( + HillyRoadAlertViewModel.VEHICLE_MODEL_SCALE, + HillyRoadAlertViewModel.VEHICLE_MODEL_SCALE, + HillyRoadAlertViewModel.VEHICLE_MODEL_SCALE)); + vehicleModel = map.addModel(vehicleOpts); + } + + // 6. Diff 3D Crest & Dip Markers by stable ID + syncExtremaMarkers(map, state.terrainAlert.upcomingExtrema); + } catch (Exception ignored) { + // Safely ignore map scene updates while the map is offline or not yet ready + } + } + + private void syncExtremaMarkers( + @NonNull GoogleMap3D map, @NonNull List upcomingExtrema) { + Set desiredIds = new HashSet<>(); + for (TerrainExtremaPoint ext : upcomingExtrema) { + desiredIds.add(ext.id); + } + + Iterator> iterator = activeExtremaMarkers.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (!desiredIds.contains(entry.getKey())) { + entry.getValue().remove(); + iterator.remove(); + } + } + + for (TerrainExtremaPoint extremum : upcomingExtrema) { + if (activeExtremaMarkers.containsKey(extremum.id)) { + continue; + } + + boolean isCrest = extremum.type == ExtremaType.CREST; + int pinBgColor = + isCrest ? Color.rgb(234, 67, 53) : Color.rgb(2, 132, 199); + int pinBorderColor = + isCrest ? Color.rgb(254, 240, 138) : Color.rgb(251, 188, 4); + String glyphSymbol = isCrest ? "^" : "v"; + double signedGrade = + isCrest + ? Math.abs(extremum.approachGradePercent) + : -Math.abs(extremum.approachGradePercent); + String markerLabel = + String.format( + Locale.US, + getString( + isCrest + ? R.string.hilly_marker_crest_format + : R.string.hilly_marker_dip_format), + signedGrade); + + Glyph glyph = Glyph.fromColor(Color.WHITE); + glyph.setText(glyphSymbol); + PinConfiguration pinStyle = + PinConfiguration.builder() + .setScale(1.2f) + .setBackgroundColor(pinBgColor) + .setBorderColor(pinBorderColor) + .setGlyph(glyph) + .build(); + + MarkerOptions opts = new MarkerOptions(); + opts.setId(extremum.id); + opts.setPosition(extremum.position); + opts.setLabel(markerLabel); + opts.setAltitudeMode(AltitudeMode.ABSOLUTE); + opts.setZIndex(8); + opts.setExtruded(true); + opts.setDrawnWhenOccluded(true); + opts.setCollisionBehavior(CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL); + opts.setStyle(pinStyle); + + Marker marker = map.addMarker(opts); + if (marker != null) { + final String toastLabel = markerLabel; + final int distMeters = (int) Math.round(extremum.distanceAheadMeters); + final int elevMeters = (int) Math.round(extremum.position.getAltitude()); + marker.setClickListener( + () -> + runOnUiThread( + () -> + Toast.makeText( + this, + getString( + R.string + .hilly_marker_toast_format, + toastLabel, + distMeters, + elevMeters), + Toast.LENGTH_SHORT) + .show())); + activeExtremaMarkers.put(extremum.id, marker); + } + } + } + + private void clearExtremaMarkers() { + for (Marker marker : activeExtremaMarkers.values()) { + marker.remove(); + } + activeExtremaMarkers.clear(); + } + + private void clearMapOverlays() { + if (baseRoutePolyline != null) { + baseRoutePolyline.remove(); + baseRoutePolyline = null; + } + if (traveledRoutePolyline != null) { + traveledRoutePolyline.remove(); + traveledRoutePolyline = null; + } + if (hazardRoutePolyline != null) { + hazardRoutePolyline.remove(); + hazardRoutePolyline = null; + } + if (vehicleModel != null) { + vehicleModel.remove(); + vehicleModel = null; + } + clearExtremaMarkers(); + } +} diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java index 98776a1d..00eec2b9 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java @@ -38,6 +38,7 @@ import com.example.maps3djava.fieldofview.FieldOfViewActivity; import com.example.maps3djava.flightsimulator.FlightSimulatorActivity; import com.example.maps3djava.hellomap.HelloMapActivity; +import com.example.maps3djava.hillyroadalerts.HillyRoadAlertsActivity; import com.example.maps3djava.mapinteractions.MapInteractionsActivity; import com.example.maps3djava.mapslocalization.MapsLocalizationActivity; import com.example.maps3djava.markers.MarkersActivity; @@ -81,6 +82,7 @@ public class MainActivity extends AppCompatActivity { put(R.string.feature_title_camera_restrictions, CameraRestrictionsActivity.class); put(R.string.feature_title_flight_simulator, FlightSimulatorActivity.class); put(R.string.feature_title_path_following, PathFollowingActivity.class); + put(R.string.feature_title_hilly_road_alerts, HillyRoadAlertsActivity.class); put(R.string.feature_title_path_styling, PathStylingActivity.class); put(R.string.feature_title_animating_models, AnimatingModelsActivity.class); put(R.string.feature_title_place_details, PlaceDetailsActivity.class); diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml b/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml index 8ee6f516..ae339103 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml @@ -17,6 +17,8 @@ + + + + diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/hillyroadalerts/HillyRoadAlertsActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/hillyroadalerts/HillyRoadAlertsActivity.kt new file mode 100644 index 00000000..b0041a57 --- /dev/null +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/hillyroadalerts/HillyRoadAlertsActivity.kt @@ -0,0 +1,668 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3dkotlin.hillyroadalerts + +import android.content.res.Configuration +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.Choreographer +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.example.maps3d.common.AlertSeverity +import com.example.maps3d.common.ElevationSparklineView +import com.example.maps3d.common.ExtremaType +import com.example.maps3d.common.HillyRoadAlertViewModel +import com.example.maps3d.common.HillyRoadUiState +import com.example.maps3d.common.HillyRoutePreset +import com.example.maps3d.common.MaxHeightScrollView +import com.example.maps3d.common.TerrainAlertType +import com.example.maps3d.common.TerrainExtremaPoint +import com.example.maps3dcommon.R +import com.example.maps3dkotlin.sampleactivity.SampleBaseActivity +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.CollisionBehavior +import com.google.android.gms.maps3d.model.Glyph +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.Marker +import com.google.android.gms.maps3d.model.MarkerOptions +import com.google.android.gms.maps3d.model.Model +import com.google.android.gms.maps3d.model.ModelOptions +import com.google.android.gms.maps3d.model.Orientation +import com.google.android.gms.maps3d.model.PinConfiguration +import com.google.android.gms.maps3d.model.Polyline +import com.google.android.gms.maps3d.model.PolylineOptions +import com.google.android.gms.maps3d.model.Vector3D +import com.google.android.gms.maps3d.model.camera +import com.google.android.material.button.MaterialButton +import com.google.android.material.card.MaterialCardView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.slider.Slider +import java.util.Locale +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.launch + +/** + * Kotlin Views showcase sample for **"Hilly & Undulating (Roller-Coaster) Road Early Alerts"**. + * + * Demonstrates real-time forward terrain analysis along 3D routes, early warning HUD alerts, + * live lookahead elevation sparklines, dynamic hazard polylines, and diffed 3D crest/dip pins. + */ +class HillyRoadAlertsActivity : + SampleBaseActivity(), + Choreographer.FrameCallback { + + override val TAG: String = HillyRoadAlertsActivity::class.java.simpleName + + override val layoutResId: Int = R.layout.activity_hilly_road_alerts + + override val initialCamera: Camera = camera { + center = LatLngAltitude(36.485940, -114.532890, 614.4) + heading = 0.0 + tilt = HillyRoadAlertViewModel.NORMAL_CAMERA_TILT + range = HillyRoadAlertViewModel.NORMAL_CAMERA_RANGE + roll = 0.0 + } + + private val viewModel: HillyRoadAlertViewModel by viewModels() + + // HUD Views + private lateinit var hudAlertCard: MaterialCardView + private lateinit var tvAlertTitle: TextView + private lateinit var tvAlertDistancePill: TextView + private lateinit var tvChipExtrema: TextView + private lateinit var tvChipMaxGrade: TextView + private lateinit var tvChipAdvisorySpeed: TextView + private lateinit var tvSparklineLabel: TextView + private lateinit var sparklineView: ElevationSparklineView + + // Bottom Sheet Controls + private lateinit var controlsScroll: MaxHeightScrollView + private lateinit var btnCollapse: MaterialButton + private lateinit var tvCurrentTelemetry: TextView + private lateinit var chipJumpToHills: Chip + private lateinit var btnPlayPause: MaterialButton + private lateinit var progressSlider: Slider + private lateinit var chipGroupRoute: ChipGroup + private lateinit var chipGroupHorizon: ChipGroup + private lateinit var chipGroupSpeed: ChipGroup + private lateinit var switchCameraPreview: MaterialSwitch + + // 3D Map Overlays & Diffing State + private var baseRoutePolyline: Polyline? = null + private var traveledRoutePolyline: Polyline? = null + private var hazardRoutePolyline: Polyline? = null + private var vehicleModel: Model? = null + private val activeExtremaMarkers: MutableMap = mutableMapOf() + + private var renderedPreset: HillyRoutePreset? = null + private var lastRenderedTraveledDist: Double = -1.0 + private var lastRenderedHazardCoords: List = emptyList() + private var renderedHazardSeverity: AlertSeverity? = null + private var lastRenderedVehiclePos: LatLngAltitude? = null + private var lastRenderedVehicleHeading: Double = Double.NaN + private var isControlsExpanded: Boolean = true + private var isUpdatingUiFromState: Boolean = false + private var lastFrameTimeNanos: Long = 0L + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Note: SampleBaseActivity already inflated layoutResId and initialized map3DView. + bindViews() + configureViewportGuardrails() + setupControlListeners() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + renderUiState(state) + } + } + } + } + + override fun onMapReady(googleMap3D: GoogleMap3D) { + super.onMapReady(googleMap3D) + googleMap3D.setMapMode(Map3DMode.HYBRID) + googleMap3D.setOnMapSteadyListener { isSceneSteady -> + if (isSceneSteady) { + map3DView.contentDescription = "MapSteady" + runOnUiThread { + startRouteIfMapReady(googleMap3D) + } + } + } + if (HillyRoadAlertViewModel.isInternetAvailable(this)) { + startRouteIfMapReady(googleMap3D) + } + } + + private fun startRouteIfMapReady(map: GoogleMap3D) { + if (viewModel.currentState.isMapReady) return + lastFrameTimeNanos = 0L + viewModel.onMapReady() + renderMapScene(map, viewModel.currentState) + } + + override fun onResume() { + super.onResume() + lastFrameTimeNanos = 0L + Choreographer.getInstance().postFrameCallback(this) + } + + override fun onPause() { + Choreographer.getInstance().removeFrameCallback(this) + super.onPause() + } + + override fun onDestroy() { + Choreographer.getInstance().removeFrameCallback(this) + clearMapOverlays() + super.onDestroy() + } + + override fun doFrame(frameTimeNanos: Long) { + val isReady = googleMap3D != null && viewModel.currentState.isMapReady + if (isReady && lastFrameTimeNanos != 0L) { + val dtSeconds = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000_000.0 + viewModel.advance(dtSeconds) + } + if (isReady) { + lastFrameTimeNanos = frameTimeNanos + } + Choreographer.getInstance().postFrameCallback(this) + } + + private fun bindViews() { + hudAlertCard = findViewById(R.id.hud_alert_card) + tvAlertTitle = findViewById(R.id.tv_alert_title) + tvAlertDistancePill = findViewById(R.id.tv_alert_distance_pill) + tvChipExtrema = findViewById(R.id.tv_chip_extrema) + tvChipMaxGrade = findViewById(R.id.tv_chip_max_grade) + tvChipAdvisorySpeed = findViewById(R.id.tv_chip_advisory_speed) + tvSparklineLabel = findViewById(R.id.tv_sparkline_label) + sparklineView = findViewById(R.id.sparkline_view) + + controlsScroll = findViewById(R.id.controls_scroll) + btnCollapse = findViewById(R.id.btn_collapse) + tvCurrentTelemetry = findViewById(R.id.tv_current_telemetry) + chipJumpToHills = findViewById(R.id.chip_jump_to_hills) + btnPlayPause = findViewById(R.id.btn_play_pause) + progressSlider = findViewById(R.id.progress_slider) + chipGroupRoute = findViewById(R.id.chip_group_route) + chipGroupHorizon = findViewById(R.id.chip_group_horizon) + chipGroupSpeed = findViewById(R.id.chip_group_speed) + switchCameraPreview = findViewById(R.id.switch_camera_preview) + } + + private fun configureViewportGuardrails() { + val screenHeight = resources.displayMetrics.heightPixels + val isLandscape = + resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val maxScrollHeight = (screenHeight * if (isLandscape) 0.28f else 0.36f).toInt() + controlsScroll.setMaxHeightPx(maxScrollHeight) + + // Collapse expandable controls by default in landscape so 3D viewport is never occluded + setControlsExpanded(!isLandscape) + } + + private fun setControlsExpanded(expanded: Boolean) { + isControlsExpanded = expanded + controlsScroll.visibility = if (expanded) View.VISIBLE else View.GONE + btnCollapse.setIconResource( + if (expanded) R.drawable.expand_more_24px else R.drawable.expand_less_24px + ) + btnCollapse.contentDescription = getString( + if (expanded) R.string.collapse_controls else R.string.expand_controls + ) + } + + private fun setupControlListeners() { + val toggleCollapseListener = View.OnClickListener { + setControlsExpanded(!isControlsExpanded) + } + findViewById(R.id.layout_drag_handle).setOnClickListener(toggleCollapseListener) + findViewById(R.id.card_header).setOnClickListener(toggleCollapseListener) + btnCollapse.setOnClickListener(toggleCollapseListener) + + btnPlayPause.setOnClickListener { + viewModel.togglePlayPause() + } + + chipJumpToHills.setOnClickListener { + viewModel.jumpToHillyZone() + } + + progressSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: Slider) { + viewModel.setScrubbing(true) + } + + override fun onStopTrackingTouch(slider: Slider) { + viewModel.setScrubbing(false) + } + }) + + progressSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser && !isUpdatingUiFromState) { + viewModel.seekToProgress(value) + } + } + + chipGroupRoute.setOnCheckedStateChangeListener { _, checkedIds -> + if (isUpdatingUiFromState || checkedIds.isEmpty()) return@setOnCheckedStateChangeListener + when (checkedIds.first()) { + R.id.chip_route_roller_coaster -> viewModel.selectRoute(HillyRoutePreset.ROLLER_COASTER) + R.id.chip_route_mountain_pass -> viewModel.selectRoute(HillyRoutePreset.MOUNTAIN_PASS) + R.id.chip_route_coastal -> viewModel.selectRoute(HillyRoutePreset.COASTAL_DRIVE) + } + } + + chipGroupHorizon.setOnCheckedStateChangeListener { _, checkedIds -> + if (isUpdatingUiFromState || checkedIds.isEmpty()) return@setOnCheckedStateChangeListener + when (checkedIds.first()) { + R.id.chip_horizon_250 -> viewModel.setLookaheadHorizon(250.0) + R.id.chip_horizon_500 -> viewModel.setLookaheadHorizon(500.0) + R.id.chip_horizon_750 -> viewModel.setLookaheadHorizon(750.0) + } + } + + chipGroupSpeed.setOnCheckedStateChangeListener { _, checkedIds -> + if (isUpdatingUiFromState || checkedIds.isEmpty()) return@setOnCheckedStateChangeListener + when (checkedIds.first()) { + R.id.chip_speed_05x -> viewModel.setSpeedMultiplier(0.5) + R.id.chip_speed_1x -> viewModel.setSpeedMultiplier(1.0) + R.id.chip_speed_2x -> viewModel.setSpeedMultiplier(2.0) + R.id.chip_speed_3x -> viewModel.setSpeedMultiplier(3.0) + R.id.chip_speed_5x -> viewModel.setSpeedMultiplier(5.0) + } + } + + switchCameraPreview.setOnCheckedChangeListener { _, isChecked -> + if (!isUpdatingUiFromState) { + viewModel.setAutoCameraPitchPreview(isChecked) + } + } + } + + private fun renderUiState(state: HillyRoadUiState) { + isUpdatingUiFromState = true + try { + val alert = state.terrainAlert + + // 1. Top Early Alert Banner + Sparkline + tvAlertTitle.text = getAlertTitleText(alert.alertType) + tvAlertDistancePill.text = when { + alert.alertType == TerrainAlertType.NONE -> + getString(R.string.hilly_alert_distance_clear) + alert.distanceToHazardMeters <= 0.5 -> + getString( + R.string.hilly_alert_distance_active_format, + alert.remainingHazardDistanceMeters.roundToInt() + ) + else -> + getString( + R.string.hilly_alert_distance_approaching_format, + alert.distanceToHazardMeters.roundToInt() + ) + } + + // Canonical alert border colors: Red (#EA4335), Amber (#FBBC04), Emerald (#10B981) + val strokeColor = when (alert.severity) { + AlertSeverity.WARNING -> Color.rgb(234, 67, 53) + AlertSeverity.CAUTION -> Color.rgb(251, 188, 4) + AlertSeverity.NONE -> Color.rgb(16, 185, 129) + } + hudAlertCard.strokeColor = strokeColor + tvAlertDistancePill.setTextColor( + when (alert.severity) { + AlertSeverity.WARNING -> Color.rgb(254, 202, 202) + AlertSeverity.CAUTION -> Color.rgb(254, 240, 138) + AlertSeverity.NONE -> Color.rgb(167, 243, 208) + } + ) + + tvChipExtrema.text = getString( + R.string.hilly_telemetry_extrema_format, + alert.crestCount, + alert.dipCount + ) + tvChipMaxGrade.text = String.format( + Locale.US, + getString(R.string.hilly_telemetry_max_grade_format), + alert.maxPositiveGradePercent, + alert.maxNegativeGradePercent + ) + tvChipAdvisorySpeed.text = getString( + R.string.hilly_telemetry_advisory_speed_format, + alert.recommendedSpeedKmh + ) + + tvSparklineLabel.text = getString( + R.string.hilly_sparkline_label_format, + state.lookaheadHorizonMeters.roundToInt() + ) + sparklineView.updateData( + profile = alert.lookaheadProfile, + extrema = alert.upcomingExtrema, + severity = alert.severity, + horizonMeters = state.lookaheadHorizonMeters + ) + + // 2. Bottom Sheet Controls + tvCurrentTelemetry.text = String.format( + Locale.US, + getString(R.string.hilly_current_telemetry_format), + alert.currentGradePercent, + state.currentDistanceMeters.roundToInt(), + state.totalDistanceMeters.roundToInt() + ) + + btnPlayPause.setIconResource( + if (state.isPlaying) R.drawable.pause_24px else R.drawable.play_arrow_24px + ) + if (!state.isUserScrubbing) { + progressSlider.value = state.progressRatio.coerceIn(0.0f, 1.0f) + } + + val expectedRouteChipId = when (state.selectedPreset) { + HillyRoutePreset.ROLLER_COASTER -> R.id.chip_route_roller_coaster + HillyRoutePreset.MOUNTAIN_PASS -> R.id.chip_route_mountain_pass + HillyRoutePreset.COASTAL_DRIVE -> R.id.chip_route_coastal + } + if (chipGroupRoute.checkedChipId != expectedRouteChipId) { + chipGroupRoute.check(expectedRouteChipId) + } + + val expectedHorizonChipId = when (state.lookaheadHorizonMeters.roundToInt()) { + 250 -> R.id.chip_horizon_250 + 750 -> R.id.chip_horizon_750 + else -> R.id.chip_horizon_500 + } + if (chipGroupHorizon.checkedChipId != expectedHorizonChipId) { + chipGroupHorizon.check(expectedHorizonChipId) + } + + val expectedSpeedChipId = when { + abs(state.speedMultiplier - 0.5) < 0.05 -> R.id.chip_speed_05x + abs(state.speedMultiplier - 2.0) < 0.05 -> R.id.chip_speed_2x + abs(state.speedMultiplier - 3.0) < 0.05 -> R.id.chip_speed_3x + abs(state.speedMultiplier - 5.0) < 0.05 -> R.id.chip_speed_5x + else -> R.id.chip_speed_1x + } + if (chipGroupSpeed.checkedChipId != expectedSpeedChipId) { + chipGroupSpeed.check(expectedSpeedChipId) + } + + if (switchCameraPreview.isChecked != state.autoCameraPitchPreview) { + switchCameraPreview.isChecked = state.autoCameraPitchPreview + } + } finally { + isUpdatingUiFromState = false + } + + val map = googleMap3D + if (map != null && state.isMapReady) { + renderMapScene(map, state) + } + } + + private fun getAlertTitleText(alertType: TerrainAlertType): String = when (alertType) { + TerrainAlertType.UNDULATING_ROLLER_COASTER -> getString(R.string.hilly_alert_title_roller_coaster) + TerrainAlertType.BLIND_CREST -> getString(R.string.hilly_alert_title_blind_crest) + TerrainAlertType.STEEP_CLIMB -> getString(R.string.hilly_alert_title_steep_climb) + TerrainAlertType.STEEP_DESCENT -> getString(R.string.hilly_alert_title_steep_descent) + TerrainAlertType.NONE -> getString(R.string.hilly_alert_title_clear) + } + + private fun renderMapScene(map: GoogleMap3D, state: HillyRoadUiState) { + try { + // 1. Update Driver Chase Camera + map.setCamera( + camera { + center = state.cameraCenter + heading = state.cameraHeading + tilt = state.cameraTilt + range = state.cameraRange + roll = 0.0 + } + ) + + // 2. Rebuild Base Route Polyline & clear extrema markers if preset changed + if (renderedPreset != state.selectedPreset || baseRoutePolyline == null) { + renderedPreset = state.selectedPreset + lastRenderedTraveledDist = -1.0 + lastRenderedHazardCoords = emptyList() + baseRoutePolyline?.remove() + baseRoutePolyline = map.addPolyline( + PolylineOptions().apply { + id = HillyRoadAlertViewModel.BASE_ROUTE_POLYLINE_ID + path = state.staticRouteVertices + strokeColor = Color.argb(210, 56, 189, 248) // Neutral Cyan (#38BDF8) + strokeWidth = 14.0 + altitudeMode = AltitudeMode.ABSOLUTE + zIndex = 2 + drawsOccludedSegments = true + } + ) + clearExtremaMarkers() + } + + // 3. Update Traveled Progress Polyline + val traveledCoords = state.traveledRouteVertices + if (traveledCoords.size >= 2 && + (traveledRoutePolyline == null || abs(state.currentDistanceMeters - lastRenderedTraveledDist) > 0.05) + ) { + lastRenderedTraveledDist = state.currentDistanceMeters + traveledRoutePolyline = map.addPolyline( + PolylineOptions().apply { + id = HillyRoadAlertViewModel.TRAVELED_ROUTE_POLYLINE_ID + path = traveledCoords + strokeColor = Color.rgb(16, 185, 129) // Emerald green (#10B981) + strokeWidth = 16.0 + altitudeMode = AltitudeMode.ABSOLUTE + zIndex = 3 + drawsOccludedSegments = true + } + ) + } + + // 4. Update Active Hazard Highlight Polyline + val hazardCoords = state.terrainAlert.hazardPolylineCoordinates + val severity = state.terrainAlert.severity + if (hazardCoords.size >= 2 && severity != AlertSeverity.NONE) { + if (hazardRoutePolyline == null || + renderedHazardSeverity != severity || + lastRenderedHazardCoords != hazardCoords + ) { + val hazardColor = if (severity == AlertSeverity.WARNING) { + Color.rgb(234, 67, 53) // Canonical Google Red (#EA4335) + } else { + Color.rgb(251, 188, 4) // Canonical Google Amber (#FBBC04) + } + renderedHazardSeverity = severity + lastRenderedHazardCoords = hazardCoords + hazardRoutePolyline = map.addPolyline( + PolylineOptions().apply { + id = HillyRoadAlertViewModel.HAZARD_ROUTE_POLYLINE_ID + path = hazardCoords + strokeColor = hazardColor + strokeWidth = 20.0 + altitudeMode = AltitudeMode.ABSOLUTE + zIndex = 5 + drawsOccludedSegments = true + } + ) + } + } else { + hazardRoutePolyline?.remove() + hazardRoutePolyline = null + renderedHazardSeverity = null + lastRenderedHazardCoords = emptyList() + } + + // 5. Update Simulated Vehicle 3D Model (Red Car .glb from Routes API sample) + if (vehicleModel == null || + lastRenderedVehiclePos != state.vehiclePosition || + lastRenderedVehicleHeading != state.currentHeadingDegrees + ) { + lastRenderedVehiclePos = state.vehiclePosition + lastRenderedVehicleHeading = state.currentHeadingDegrees + vehicleModel = map.addModel( + ModelOptions().apply { + id = HillyRoadAlertViewModel.VEHICLE_MODEL_ID + position = state.vehiclePosition + altitudeMode = AltitudeMode.ABSOLUTE + url = HillyRoadAlertViewModel.VEHICLE_MODEL_URL + orientation = Orientation( + state.currentHeadingDegrees, + HillyRoadAlertViewModel.VEHICLE_MODEL_TILT, + 0.0 + ) + scale = Vector3D( + HillyRoadAlertViewModel.VEHICLE_MODEL_SCALE, + HillyRoadAlertViewModel.VEHICLE_MODEL_SCALE, + HillyRoadAlertViewModel.VEHICLE_MODEL_SCALE + ) + } + ) + } + + // 6. Diff 3D Crest & Dip Markers by stable ID (never recreate unchanged markers) + syncExtremaMarkers(map, state.terrainAlert.upcomingExtrema) + } catch (e: Exception) { + Log.w(TAG, "Map scene update skipped while map is not ready: ${e.message}") + } + } + + private fun syncExtremaMarkers( + map: GoogleMap3D, + upcomingExtrema: List + ) { + val desiredIds = upcomingExtrema.map { it.id }.toSet() + + // Remove markers that exited the lookahead window + val iterator = activeExtremaMarkers.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.key !in desiredIds) { + entry.value.remove() + iterator.remove() + } + } + + // Add only newly entered extrema markers + for (extremum in upcomingExtrema) { + if (activeExtremaMarkers.containsKey(extremum.id)) continue + + val isCrest = extremum.type == ExtremaType.CREST + val pinBgColor = if (isCrest) { + Color.rgb(234, 67, 53) // Crest: Orange/Red (#EA4335) + } else { + Color.rgb(2, 132, 199) // Dip: Deep Sky Blue (#0284C7) + } + val pinBorderColor = if (isCrest) { + Color.rgb(254, 240, 138) + } else { + Color.rgb(251, 188, 4) // Amber border (#FBBC04) + } + val glyphSymbol = if (isCrest) "^" else "v" + val signedGrade = if (isCrest) { + abs(extremum.approachGradePercent) + } else { + -abs(extremum.approachGradePercent) + } + val markerLabel = String.format( + Locale.US, + getString( + if (isCrest) R.string.hilly_marker_crest_format else R.string.hilly_marker_dip_format + ), + signedGrade + ) + + val pinStyle = PinConfiguration.builder() + .setScale(1.2f) + .setBackgroundColor(pinBgColor) + .setBorderColor(pinBorderColor) + .setGlyph(Glyph.fromColor(Color.WHITE).apply { setText(glyphSymbol) }) + .build() + + val marker = map.addMarker( + MarkerOptions().apply { + id = extremum.id + position = extremum.position + label = markerLabel + altitudeMode = AltitudeMode.ABSOLUTE + zIndex = 8 + isExtruded = true + isDrawnWhenOccluded = true + collisionBehavior = CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL + setStyle(pinStyle) + } + ) + + if (marker != null) { + marker.setClickListener { + runOnUiThread { + Toast.makeText( + this, + getString( + R.string.hilly_marker_toast_format, + markerLabel, + extremum.distanceAheadMeters.roundToInt(), + extremum.position.altitude.roundToInt() + ), + Toast.LENGTH_SHORT + ).show() + } + } + activeExtremaMarkers[extremum.id] = marker + } + } + } + + private fun clearExtremaMarkers() { + for (marker in activeExtremaMarkers.values) { + marker.remove() + } + activeExtremaMarkers.clear() + } + + private fun clearMapOverlays() { + baseRoutePolyline?.remove() + baseRoutePolyline = null + traveledRoutePolyline?.remove() + traveledRoutePolyline = null + hazardRoutePolyline?.remove() + hazardRoutePolyline = null + vehicleModel?.remove() + vehicleModel = null + clearExtremaMarkers() + } +} diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt index 35693209..f18e6b5d 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt @@ -59,6 +59,7 @@ import com.example.maps3dkotlin.datavisualization.DataVisualizationActivity import com.example.maps3dkotlin.fieldofview.FieldOfViewActivity import com.example.maps3dkotlin.flightsimulator.FlightSimulatorActivity import com.example.maps3dkotlin.hellomap.HelloMapActivity +import com.example.maps3dkotlin.hillyroadalerts.HillyRoadAlertsActivity import com.example.maps3dkotlin.mapinteractions.MapInteractionsActivity import com.example.maps3dkotlin.mapslocalization.MapsLocalizationActivity import com.example.maps3dkotlin.markers.MarkersActivity @@ -116,6 +117,7 @@ class MainActivity : ComponentActivity() { Sample(R.string.feature_title_camera_restrictions, CameraRestrictionsActivity::class.java), Sample(R.string.feature_title_flight_simulator, FlightSimulatorActivity::class.java), Sample(R.string.feature_title_path_following, PathFollowingActivity::class.java), + Sample(R.string.feature_title_hilly_road_alerts, HillyRoadAlertsActivity::class.java), Sample(R.string.feature_title_path_styling, PathStylingActivity::class.java), Sample(R.string.feature_title_animating_models, AnimatingModelsActivity::class.java), Sample(R.string.feature_title_place_details, PlaceDetailsActivity::class.java), diff --git a/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml b/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml index 1ee480da..c334bd9e 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/ComposeDemos/app/src/main/AndroidManifest.xml @@ -17,6 +17,8 @@ + + + diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt index a6307b6f..60ad4b55 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt @@ -51,6 +51,7 @@ import com.example.composedemos.datavisualization.DataVisualizationActivity import com.example.composedemos.fieldofview.FieldOfViewActivity import com.example.composedemos.flightsimulator.FlightSimulatorActivity import com.example.composedemos.hellomap.HelloMapActivity +import com.example.composedemos.hillyroadalerts.HillyRoadAlertsActivity import com.example.composedemos.mapinteractions.MapInteractionsActivity import com.example.composedemos.mapslocalization.MapsLocalizationActivity import com.example.composedemos.markers.MarkersActivity @@ -193,6 +194,11 @@ fun CatalogScreen() { context.startActivity(Intent(context, PathFollowingActivity::class.java)) } } + item { + SampleItem("Hilly & Undulating Road Alerts") { + context.startActivity(Intent(context, HillyRoadAlertsActivity::class.java)) + } + } item { SampleItem("Path Styling") { context.startActivity(Intent(context, PathStylingActivity::class.java)) diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hillyroadalerts/HillyRoadAlertsActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hillyroadalerts/HillyRoadAlertsActivity.kt new file mode 100644 index 00000000..e5324b7b --- /dev/null +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hillyroadalerts/HillyRoadAlertsActivity.kt @@ -0,0 +1,1026 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.composedemos.hillyroadalerts + +import android.app.Activity +import android.content.res.Configuration +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.maps3d.common.AlertSeverity +import com.example.maps3d.common.ElevationProfilePoint +import com.example.maps3d.common.ExtremaType +import com.example.maps3d.common.HillyRoadAlertViewModel +import com.example.maps3d.common.HillyRoadUiState +import com.example.maps3d.common.HillyRoutePreset +import com.example.maps3d.common.Map3DQuickSettingsWidget +import com.example.maps3d.common.Map3DSessionTheme +import com.example.maps3d.common.TerrainAlertState +import com.example.maps3d.common.TerrainAlertType +import com.example.maps3d.common.TerrainExtremaPoint +import com.example.maps3d.common.rememberMap3DSessionSettings +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.CollisionBehavior +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.maps.android.compose3d.GlyphConfig +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.MarkerConfig +import com.google.maps.android.compose3d.ModelConfig +import com.google.maps.android.compose3d.ModelScale +import com.google.maps.android.compose3d.PinConfig +import com.google.maps.android.compose3d.PolylineConfig +import kotlinx.coroutines.isActive +import java.util.Locale +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt +import android.graphics.Color as AndroidColor +import com.example.maps3dcommon.R as CommonR + +/** + * Jetpack Compose showcase activity for **"Hilly & Undulating (Roller-Coaster) Road Early Alerts"**. + */ +class HillyRoadAlertsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + Map3DSessionTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + HillyRoadAlertsScreen() + } + } + } + } +} + +/** + * Main Jetpack Compose screen for the Hilly & Undulating Road Early Alerts sample. + */ +@Composable +fun HillyRoadAlertsScreen( + viewModel: HillyRoadAlertViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var googleMap3D by remember { mutableStateOf(null) } + val settings by rememberMap3DSessionSettings(defaultMapMode = Map3DMode.HYBRID) + val context = LocalContext.current + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE) { + viewModel.setPlaying(false) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + // Hardware VSYNC-synchronized frame ticker (starts only after 3D map is ready) + LaunchedEffect(state.isMapReady, state.isPlaying, state.isUserScrubbing) { + if (!state.isMapReady || !state.isPlaying || state.isUserScrubbing) return@LaunchedEffect + var lastTimeNanos = 0L + while (isActive) { + withFrameMillis { frameTimeMillis -> + val nowNanos = frameTimeMillis * 1_000_000L + if (lastTimeNanos != 0L) { + val dtSeconds = (nowNanos - lastTimeNanos) / 1_000_000_000.0 + viewModel.advance(dtSeconds) + } + lastTimeNanos = nowNanos + } + } + } + + val dynamicCamera = remember( + state.cameraCenter, + state.cameraHeading, + state.cameraTilt, + state.cameraRange, + ) { + camera { + center = state.cameraCenter + heading = state.cameraHeading + tilt = state.cameraTilt + range = state.cameraRange + roll = 0.0 + } + } + + val basePolylineConfig = remember(state.selectedPreset, state.staticRouteVertices) { + if (state.staticRouteVertices.size < 2) { + null + } else { + PolylineConfig( + key = HillyRoadAlertViewModel.BASE_ROUTE_POLYLINE_ID, + points = state.staticRouteVertices, + color = AndroidColor.argb(210, 56, 189, 248), // Sky cyan (#38BDF8) + width = 14f, + altitudeMode = AltitudeMode.ABSOLUTE, + zIndex = 2, + drawsOccludedSegments = true, + ) + } + } + + val traveledPolylineConfig = remember(state.traveledRouteVertices) { + if (state.traveledRouteVertices.size < 2) { + null + } else { + PolylineConfig( + key = HillyRoadAlertViewModel.TRAVELED_ROUTE_POLYLINE_ID, + points = state.traveledRouteVertices, + color = AndroidColor.rgb(16, 185, 129), // Emerald (#10B981) + width = 16f, + altitudeMode = AltitudeMode.ABSOLUTE, + zIndex = 3, + drawsOccludedSegments = true, + ) + } + } + + val hazardPolylineConfig = remember( + state.terrainAlert.hazardPolylineCoordinates, + state.terrainAlert.severity, + ) { + val coords = state.terrainAlert.hazardPolylineCoordinates + val severity = state.terrainAlert.severity + if (coords.size < 2 || severity == AlertSeverity.NONE) { + null + } else { + val hazardColor = if (severity == AlertSeverity.WARNING) { + AndroidColor.rgb(234, 67, 53) // Canonical Google Red (#EA4335) + } else { + AndroidColor.rgb(251, 188, 4) // Canonical Google Amber (#FBBC04) + } + PolylineConfig( + key = HillyRoadAlertViewModel.HAZARD_ROUTE_POLYLINE_ID, + points = coords, + color = hazardColor, + width = 20f, + altitudeMode = AltitudeMode.ABSOLUTE, + zIndex = 5, + drawsOccludedSegments = true, + ) + } + } + + val vehicleModelConfig = remember(state.vehiclePosition, state.currentHeadingDegrees) { + ModelConfig( + key = HillyRoadAlertViewModel.VEHICLE_MODEL_ID, + url = HillyRoadAlertViewModel.VEHICLE_MODEL_URL, + position = state.vehiclePosition, + altitudeMode = AltitudeMode.ABSOLUTE, + scale = ModelScale.Uniform(HillyRoadAlertViewModel.VEHICLE_MODEL_SCALE.toFloat()), + heading = state.currentHeadingDegrees, + tilt = HillyRoadAlertViewModel.VEHICLE_MODEL_TILT, + roll = 0.0, + ) + } + + val allModels = remember(vehicleModelConfig) { + listOf(vehicleModelConfig) + } + + // Cache MarkerConfig instances by stable extremum ID so Map3DState.syncMarkers only + // adds newly entered extrema and removes exited extrema without recreating unchanged markers. + val cachedExtremaMarkerConfigs = remember(state.selectedPreset) { + mutableMapOf() + } + val crestFormat = stringResource(CommonR.string.hilly_marker_crest_format) + val dipFormat = stringResource(CommonR.string.hilly_marker_dip_format) + val toastFormat = stringResource(CommonR.string.hilly_marker_toast_format) + + val extremaMarkerConfigs = remember(state.terrainAlert.upcomingExtrema) { + val upcoming = state.terrainAlert.upcomingExtrema + val activeIds = upcoming.map { it.id }.toSet() + cachedExtremaMarkerConfigs.keys.retainAll(activeIds) + + upcoming.map { extremum -> + cachedExtremaMarkerConfigs.getOrPut(extremum.id) { + val isCrest = extremum.type == ExtremaType.CREST + val bgColor = if (isCrest) { + AndroidColor.rgb(234, 67, 53) // Crest: Red-Orange (#EA4335) + } else { + AndroidColor.rgb(2, 132, 199) // Dip: Deep Sky Blue (#0284C7) + } + val borderColor = if (isCrest) { + AndroidColor.rgb(254, 240, 138) + } else { + AndroidColor.rgb(251, 188, 4) // Amber (#FBBC04) + } + val glyphText = if (isCrest) "^" else "v" + val signedGrade = if (isCrest) { + abs(extremum.approachGradePercent) + } else { + -abs(extremum.approachGradePercent) + } + val labelText = String.format( + Locale.US, + if (isCrest) crestFormat else dipFormat, + signedGrade, + ) + MarkerConfig( + key = extremum.id, + position = extremum.position, + altitudeMode = AltitudeMode.ABSOLUTE, + label = labelText, + zIndex = 8, + isExtruded = true, + isDrawnWhenOccluded = true, + collisionBehavior = CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL, + pinConfig = PinConfig( + scale = 1.2f, + backgroundColor = bgColor, + borderColor = borderColor, + glyph = GlyphConfig.Text(glyphText, AndroidColor.WHITE), + ), + onClick = { + (context as? Activity)?.runOnUiThread { + Toast.makeText( + context, + String.format( + Locale.US, + toastFormat, + labelText, + extremum.distanceAheadMeters.roundToInt(), + extremum.position.altitude.roundToInt(), + ), + Toast.LENGTH_SHORT, + ).show() + } + }, + ) + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + GoogleMap3D( + modifier = Modifier.fillMaxSize(), + camera = dynamicCamera, + mapMode = settings.mapMode, + polylines = if (state.isMapReady) { + listOfNotNull( + basePolylineConfig, + traveledPolylineConfig, + hazardPolylineConfig, + ) + } else { + emptyList() + }, + markers = if (state.isMapReady) extremaMarkerConfigs else emptyList(), + models = if (state.isMapReady) allModels else emptyList(), + onMapReady = { map -> + googleMap3D = map + if (HillyRoadAlertViewModel.isInternetAvailable(context)) { + viewModel.onMapReady() + } + }, + onMapSteady = { + viewModel.onMapReady() + }, + ) + + // Top Early Warning HUD Banner + Live Elevation Sparkline + EarlyWarningHudBanner( + alert = state.terrainAlert, + lookaheadHorizonMeters = state.lookaheadHorizonMeters, + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(start = 12.dp, top = 8.dp, end = 64.dp) + .fillMaxWidth(), + ) + + // 3D Map Quick Settings Button + Map3DQuickSettingsWidget( + googleMap3D = googleMap3D, + sampleDefaultMapMode = Map3DMode.HYBRID, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = 12.dp, end = 12.dp), + ) + + // Compact Collapsible Bottom Control Sheet + HillyRoadBottomControlsCard( + state = state, + onTogglePlayPause = viewModel::togglePlayPause, + onJumpToHills = viewModel::jumpToHillyZone, + onScrubbingChanged = viewModel::setScrubbing, + onSeekProgress = viewModel::seekToProgress, + onSelectRoute = viewModel::selectRoute, + onSelectHorizon = viewModel::setLookaheadHorizon, + onSelectSpeed = viewModel::setSpeedMultiplier, + onToggleCameraPreview = viewModel::setAutoCameraPitchPreview, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(12.dp) + .fillMaxWidth(), + ) + } +} + +@Composable +private fun EarlyWarningHudBanner( + alert: TerrainAlertState, + lookaheadHorizonMeters: Double, + modifier: Modifier = Modifier, +) { + // Canonical Google alert colors: Red (#EA4335), Amber (#FBBC04), Emerald (#10B981) + val borderColor = when (alert.severity) { + AlertSeverity.WARNING -> Color(0xFFEA4335) + AlertSeverity.CAUTION -> Color(0xFFFBBC04) + AlertSeverity.NONE -> Color(0xFF10B981) + } + val pillTextColor = when (alert.severity) { + AlertSeverity.WARNING -> Color(0xFFFECACA) + AlertSeverity.CAUTION -> Color(0xFFFEF08A) + AlertSeverity.NONE -> Color(0xFFA7F3D0) + } + + val titleText = when (alert.alertType) { + TerrainAlertType.UNDULATING_ROLLER_COASTER -> + stringResource(CommonR.string.hilly_alert_title_roller_coaster) + + TerrainAlertType.BLIND_CREST -> + stringResource(CommonR.string.hilly_alert_title_blind_crest) + + TerrainAlertType.STEEP_CLIMB -> + stringResource(CommonR.string.hilly_alert_title_steep_climb) + + TerrainAlertType.STEEP_DESCENT -> + stringResource(CommonR.string.hilly_alert_title_steep_descent) + + TerrainAlertType.NONE -> + stringResource(CommonR.string.hilly_alert_title_clear) + } + + val distancePillText = when { + alert.alertType == TerrainAlertType.NONE -> + stringResource(CommonR.string.hilly_alert_distance_clear) + + alert.distanceToHazardMeters <= 0.5 -> + stringResource( + CommonR.string.hilly_alert_distance_active_format, + alert.remainingHazardDistanceMeters.roundToInt(), + ) + + else -> + stringResource( + CommonR.string.hilly_alert_distance_approaching_format, + alert.distanceToHazardMeters.roundToInt(), + ) + } + + Card( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xE61E293B), + ), + border = BorderStroke(2.dp, borderColor), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = titleText, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .background( + color = Color(0x33FFFFFF), + shape = RoundedCornerShape(12.dp), + ) + .padding(horizontal = 10.dp, vertical = 3.dp), + ) { + Text( + text = distancePillText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = pillTextColor, + ) + } + } + + // Telemetry Chips + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + HudTelemetryPill( + text = stringResource( + CommonR.string.hilly_telemetry_extrema_format, + alert.crestCount, + alert.dipCount, + ), + ) + HudTelemetryPill( + text = String.format( + Locale.US, + stringResource(CommonR.string.hilly_telemetry_max_grade_format), + alert.maxPositiveGradePercent, + alert.maxNegativeGradePercent, + ), + ) + HudTelemetryPill( + text = stringResource( + CommonR.string.hilly_telemetry_advisory_speed_format, + alert.recommendedSpeedKmh, + ), + textColor = Color(0xFFFDE047), + isBold = true, + ) + } + + Text( + text = stringResource( + CommonR.string.hilly_sparkline_label_format, + lookaheadHorizonMeters.roundToInt(), + ), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF94A3B8), + modifier = Modifier.padding(top = 6.dp), + ) + + LookaheadElevationSparklineCanvas( + profile = alert.lookaheadProfile, + extrema = alert.upcomingExtrema, + severity = alert.severity, + horizonMeters = lookaheadHorizonMeters, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .padding(top = 2.dp), + ) + } + } +} + +@Composable +private fun HudTelemetryPill( + text: String, + textColor: Color = Color(0xFFE2E8F0), + isBold: Boolean = false, +) { + Box( + modifier = Modifier + .background( + color = Color(0x26FFFFFF), + shape = RoundedCornerShape(10.dp), + ) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal, + color = textColor, + ) + } +} + +/** + * Compose [Canvas] plotting the upcoming 3D terrain elevation profile (`250m–750m` ahead), + * shading undulating hazard zones in Amber/Red, marking upcoming `CREST` and `DIP` points, + * and pinning the vehicle indicator at `x = 0`. + */ +@Composable +private fun LookaheadElevationSparklineCanvas( + profile: List, + extrema: List, + severity: AlertSeverity, + horizonMeters: Double, + modifier: Modifier = Modifier, +) { + val curvePath = remember { Path() } + val fillPath = remember { Path() } + val hazardCurvePath = remember { Path() } + val hazardFillPath = remember { Path() } + + Canvas(modifier = modifier) { + if (profile.size < 2 || size.width <= 0f || size.height <= 0f) return@Canvas + + val padH = 10.dp.toPx() + val padV = 6.dp.toPx() + val plotLeft = padH + val plotRight = (size.width - padH).coerceAtLeast(plotLeft + 1f) + val plotTop = padV + val plotBottom = (size.height - padV).coerceAtLeast(plotTop + 1f) + val plotWidth = plotRight - plotLeft + val plotHeight = plotBottom - plotTop + + val midY = (plotTop + plotBottom) * 0.5f + drawLine( + color = Color(0x2DFFFFFF), + start = Offset(plotLeft, midY), + end = Offset(plotRight, midY), + strokeWidth = 1.dp.toPx(), + ) + drawLine( + color = Color(0x2DFFFFFF), + start = Offset(plotLeft, plotBottom), + end = Offset(plotRight, plotBottom), + strokeWidth = 1.dp.toPx(), + ) + + var minAlt = profile.first().altitudeMeters + var maxAlt = minAlt + for (pt in profile) { + if (pt.altitudeMeters < minAlt) minAlt = pt.altitudeMeters + if (pt.altitudeMeters > maxAlt) maxAlt = pt.altitudeMeters + } + val rawSpan = maxAlt - minAlt + val effectiveSpan = max(rawSpan, 12.0) + val lowAlt = minAlt - (effectiveSpan - rawSpan) * 0.5 + val maxDist = max(horizonMeters, profile.last().distanceOffsetMeters.coerceAtLeast(1.0)) + + fun mapX(distOffset: Double): Float { + val ratio = (distOffset / maxDist).coerceIn(0.0, 1.0).toFloat() + return plotLeft + ratio * plotWidth + } + + fun mapY(altitude: Double): Float { + val ratio = ((altitude - lowAlt) / effectiveSpan).coerceIn(0.0, 1.0).toFloat() + return plotBottom - ratio * plotHeight + } + + curvePath.reset() + fillPath.reset() + hazardCurvePath.reset() + hazardFillPath.reset() + + var inHazardRun = false + var hazardStartX = plotLeft + + for (i in profile.indices) { + val pt = profile[i] + val x = mapX(pt.distanceOffsetMeters) + val y = mapY(pt.altitudeMeters) + + if (i == 0) { + curvePath.moveTo(x, y) + fillPath.moveTo(x, plotBottom) + fillPath.lineTo(x, y) + } else { + curvePath.lineTo(x, y) + fillPath.lineTo(x, y) + } + + if (pt.isHazardZone) { + if (!inHazardRun) { + inHazardRun = true + hazardStartX = x + hazardCurvePath.moveTo(x, y) + hazardFillPath.moveTo(x, plotBottom) + hazardFillPath.lineTo(x, y) + } else { + hazardCurvePath.lineTo(x, y) + hazardFillPath.lineTo(x, y) + } + } else if (inHazardRun) { + val prevX = mapX(profile[i - 1].distanceOffsetMeters) + hazardFillPath.lineTo(prevX, plotBottom) + hazardFillPath.lineTo(hazardStartX, plotBottom) + hazardFillPath.close() + inHazardRun = false + } + } + + val lastX = mapX(profile.last().distanceOffsetMeters) + fillPath.lineTo(lastX, plotBottom) + fillPath.lineTo(plotLeft, plotBottom) + fillPath.close() + + if (inHazardRun) { + hazardFillPath.lineTo(lastX, plotBottom) + hazardFillPath.lineTo(hazardStartX, plotBottom) + hazardFillPath.close() + } + + drawPath(path = fillPath, color = Color(0x2D38BDF8)) + + if (severity != AlertSeverity.NONE) { + val isWarning = severity == AlertSeverity.WARNING + val hazardFillColor = if (isWarning) Color(0x55EA4335) else Color(0x55FBBC04) + val hazardStrokeColor = if (isWarning) Color(0xFFEA4335) else Color(0xFFFBBC04) + drawPath(path = hazardFillPath, color = hazardFillColor) + drawPath( + path = curvePath, + color = Color(0xFF38BDF8), + style = Stroke( + width = 2.2.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + drawPath( + path = hazardCurvePath, + color = hazardStrokeColor, + style = Stroke( + width = 3.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } else { + drawPath( + path = curvePath, + color = Color(0xFF38BDF8), + style = Stroke( + width = 2.2.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + + // Crest & Dip markers + val dotRadius = 3.8.dp.toPx() + val strokeWidth = 1.5.dp.toPx() + for (ext in extrema) { + val center = Offset(mapX(ext.distanceAheadMeters), mapY(ext.position.altitude)) + val dotColor = if (ext.type == ExtremaType.CREST) { + Color(0xFFEA4335) + } else { + Color(0xFFFBBC04) + } + drawCircle(color = dotColor, radius = dotRadius, center = center) + drawCircle( + color = Color.White, + radius = dotRadius, + center = center, + style = Stroke(width = strokeWidth), + ) + } + + // Vehicle dot pinned at x = 0 + val carPt = profile.first() + val carCenter = Offset(mapX(carPt.distanceOffsetMeters), mapY(carPt.altitudeMeters)) + val carRadius = 4.5.dp.toPx() + drawCircle(color = Color(0xFF34D399), radius = carRadius, center = carCenter) + drawCircle( + color = Color.White, + radius = carRadius, + center = carCenter, + style = Stroke(width = strokeWidth), + ) + } +} + +@Composable +private fun HillyRoadBottomControlsCard( + state: HillyRoadUiState, + onTogglePlayPause: () -> Unit, + onJumpToHills: () -> Unit, + onScrubbingChanged: (Boolean) -> Unit, + onSeekProgress: (Float) -> Unit, + onSelectRoute: (HillyRoutePreset) -> Unit, + onSelectHorizon: (Double) -> Unit, + onSelectSpeed: (Double) -> Unit, + onToggleCameraPreview: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + var isExpanded by rememberSaveable(isLandscape) { mutableStateOf(!isLandscape) } + + // Cap bottom sheet height to 42% of screen height so it never occludes the 3D map viewport + val maxCardHeight = (configuration.screenHeightDp * 0.42f).dp + val maxBodyScrollHeight = (configuration.screenHeightDp * if (isLandscape) 0.22f else 0.28f).dp + + val sliderInteractionSource = remember { MutableInteractionSource() } + val isDraggingSlider by sliderInteractionSource.collectIsDraggedAsState() + LaunchedEffect(isDraggingSlider) { + onScrubbingChanged(isDraggingSlider) + } + + Card( + modifier = modifier.heightIn(max = maxCardHeight), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Drag Handle + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(top = 6.dp, bottom = 2.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(width = 36.dp, height = 4.dp) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(2.dp), + ), + ) + } + + // Pinned Header: Title, Telemetry, Jump to Hills Chip, Collapse Button + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(start = 12.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(CommonR.string.hilly_road_controls_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = String.format( + Locale.US, + stringResource(CommonR.string.hilly_current_telemetry_format), + state.terrainAlert.currentGradePercent, + state.currentDistanceMeters.roundToInt(), + state.totalDistanceMeters.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AssistChip( + onClick = onJumpToHills, + label = { + Text( + text = stringResource(CommonR.string.hilly_jump_to_hills), + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + + IconButton(onClick = { isExpanded = !isExpanded }) { + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandMore else Icons.Default.ExpandLess, + contentDescription = stringResource( + if (isExpanded) CommonR.string.collapse_controls else CommonR.string.expand_controls, + ), + ) + } + } + + // Pinned Play/Pause & Progress Slider Row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilledIconButton( + onClick = onTogglePlayPause, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = stringResource(CommonR.string.play_or_pause_animation), + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Slider( + value = state.progressRatio.coerceIn(0.0f, 1.0f), + onValueChange = onSeekProgress, + valueRange = 0.0f..1.0f, + interactionSource = sliderInteractionSource, + modifier = Modifier.weight(1f), + ) + } + + // Expandable Body with internal vertical scroll + AnimatedVisibility(visible = isExpanded) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxBodyScrollHeight) + .verticalScroll(rememberScrollState()) + .padding(start = 12.dp, end = 12.dp, bottom = 10.dp), + ) { + // Route Preset Chips + Text( + text = stringResource(CommonR.string.hilly_route_preset_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.selectedPreset == HillyRoutePreset.ROLLER_COASTER, + onClick = { onSelectRoute(HillyRoutePreset.ROLLER_COASTER) }, + label = { Text(stringResource(CommonR.string.hilly_route_roller_coaster)) }, + ) + FilterChip( + selected = state.selectedPreset == HillyRoutePreset.MOUNTAIN_PASS, + onClick = { onSelectRoute(HillyRoutePreset.MOUNTAIN_PASS) }, + label = { Text(stringResource(CommonR.string.hilly_route_mountain_pass)) }, + ) + FilterChip( + selected = state.selectedPreset == HillyRoutePreset.COASTAL_DRIVE, + onClick = { onSelectRoute(HillyRoutePreset.COASTAL_DRIVE) }, + label = { Text(stringResource(CommonR.string.hilly_route_coastal)) }, + ) + } + + // Lookahead Horizon Chips + Text( + text = stringResource(CommonR.string.hilly_lookahead_horizon_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 4.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val horizonInt = state.lookaheadHorizonMeters.roundToInt() + FilterChip( + selected = horizonInt == 250, + onClick = { onSelectHorizon(250.0) }, + label = { Text(stringResource(CommonR.string.hilly_horizon_250m)) }, + ) + FilterChip( + selected = horizonInt == 500, + onClick = { onSelectHorizon(500.0) }, + label = { Text(stringResource(CommonR.string.hilly_horizon_500m)) }, + ) + FilterChip( + selected = horizonInt == 750, + onClick = { onSelectHorizon(750.0) }, + label = { Text(stringResource(CommonR.string.hilly_horizon_750m)) }, + ) + } + + // Speed Chips + Text( + text = stringResource(CommonR.string.hilly_speed_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 4.dp), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val speedOptions = listOf( + 0.5 to CommonR.string.speed_05x, + 1.0 to CommonR.string.speed_1x, + 2.0 to CommonR.string.speed_2x, + 3.0 to CommonR.string.speed_3x, + 5.0 to CommonR.string.speed_5x, + ) + for ((mult, labelRes) in speedOptions) { + FilterChip( + selected = abs(state.speedMultiplier - mult) < 0.05, + onClick = { onSelectSpeed(mult) }, + label = { Text(stringResource(labelRes)) }, + ) + } + } + + // Crest Camera Preview Switch + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(CommonR.string.hilly_crest_camera_preview), + style = MaterialTheme.typography.bodyMedium, + ) + Switch( + checked = state.autoCameraPitchPreview, + onCheckedChange = onToggleCameraPreview, + ) + } + } + } + } + } +}