diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt index 4e61583265..1d9b091da2 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt @@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.android.gms.maps.model.LatLng import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.Marker -import com.google.maps.android.compose.rememberComposeBitmapDescriptor +import com.google.maps.android.compose.MarkerComposable import com.google.maps.android.compose.rememberUpdatedMarkerState import org.jetbrains.compose.resources.stringResource import org.meshtastic.app.map.convertIntToEmoji @@ -67,10 +66,6 @@ fun WaypointMarkers( val iconCodePoint = waypoint.icon.waypointIconOrDefault() val emojiText = convertIntToEmoji(iconCodePoint) - val icon = - rememberComposeBitmapDescriptor(iconCodePoint) { - Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp)) - } // Non-visual cue: the geofence is otherwise only an orange overlay, so surface it in the marker's // accessible snippet for screen-reader and color-challenged users. @@ -88,12 +83,9 @@ fun WaypointMarkers( val cleanName = waypoint.name.replace('\n', ' ').replace('\b', ' ') val title = if (waypoint.isLocked) "${convertIntToEmoji(LOCK)} $cleanName" else cleanName - // rememberComposeBitmapDescriptor shares its applier with its content lambda, so the Text drawn into the - // icon makes this map-scope function infer as UI. - @Suppress("COMPOSE_APPLIER_CALL_MISMATCH") - Marker( + MarkerComposable( + iconCodePoint, state = markerState, - icon = icon, title = title, snippet = snippet, visible = true, @@ -109,7 +101,9 @@ fun WaypointMarkers( else -> onDeleteWaypointRequest(waypoint) } }, - ) + ) { + Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp)) + } } } } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/CameraPersistence.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/CameraPersistence.kt index b964d4c7ef..ead657e24f 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/CameraPersistence.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/CameraPersistence.kt @@ -50,7 +50,7 @@ internal fun rememberRestoredCamera(): RestoredCamera? { val mapPrefs: MapPrefs = koinInject() var restored by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { + LaunchedEffect(mapPrefs) { val saved = mapPrefs.awaitCameraPosition() restored = RestoredCamera( diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt index 02b78cf7f3..882342c469 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -232,7 +231,7 @@ private fun rememberMapScreenMapState( basemap = basemap, initialCameraPosition = restored.position ?: CameraPosition(), overlays = screen.overlays, - layerOpacity = koinInject().opacity.collectAsState().value, + layerOpacity = koinInject().opacity.collectAsStateWithLifecycle().value, customLayers = customLayers, onClusterMembers = { screen.clusterMembers = it }, onWaypointClick = { screen.infoWaypointId = it }, diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/TileEstimate.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/TileEstimate.kt index 0363dd3cae..a57d8e19ef 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/TileEstimate.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/TileEstimate.kt @@ -17,11 +17,8 @@ package org.meshtastic.feature.map.maplibre import org.maplibre.spatialk.geojson.BoundingBox -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.floor -import kotlin.math.ln -import kotlin.math.tan +import org.meshtastic.feature.map.maplibre.terrain.toGeoBounds +import org.meshtastic.feature.map.terrain.TerrainTileMath /** * How many tiles cover [this] region between [minZoom] and [maxZoom] inclusive. @@ -33,33 +30,6 @@ import kotlin.math.tan * Standard slippy-map arithmetic, so it matches what the renderer will actually request. */ internal fun BoundingBox.tileCount(minZoom: Int, maxZoom: Int): Long { - if (maxZoom < minZoom) return 0L - - return (minZoom..maxZoom).sumOf { zoom -> - val span = 1 shl zoom - val left = longitudeToTileX(west, span) - val right = longitudeToTileX(east, span) - // Tile rows run north to south, so the northern edge gives the lower index. - val top = latitudeToTileY(north, span) - val bottom = latitudeToTileY(south, span) - - // A box straddling the antimeridian arrives with west > east: its columns wrap around the tile grid, - // and the direct difference would go negative. - val columns = if (right >= left) right - left + 1 else span - left + right + 1 - columns.toLong() * (bottom - top + 1).toLong() - } + val bounds = toGeoBounds() + return (minZoom..maxZoom).sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) } } - -private fun longitudeToTileX(longitude: Double, span: Int): Int = - floor((longitude + HALF_TURN) / FULL_TURN * span).toInt().coerceIn(0, span - 1) - -private fun latitudeToTileY(latitude: Double, span: Int): Int { - // Clamped to the Mercator limit: the projection runs to infinity at the poles. - val radians = latitude.coerceIn(-MERCATOR_LIMIT, MERCATOR_LIMIT) * PI / HALF_TURN - val projected = ln(tan(radians) + 1.0 / cos(radians)) / PI - return floor((1.0 - projected) / 2.0 * span).toInt().coerceIn(0, span - 1) -} - -private const val HALF_TURN = 180.0 -private const val FULL_TURN = 360.0 -private const val MERCATOR_LIMIT = 85.05112878 diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/CacheUsage.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/CacheUsage.kt index 2535322733..c769f4c50a 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/CacheUsage.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/CacheUsage.kt @@ -19,9 +19,9 @@ package org.meshtastic.feature.map.maplibre.component import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import org.jetbrains.compose.resources.stringResource @@ -58,7 +58,7 @@ internal fun rememberCacheUsage(packs: Set): CacheUsage { } } } - .collectAsState(CacheUsage()) + .collectAsStateWithLifecycle(CacheUsage()) return usage } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ImportedLayersSlot.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ImportedLayersSlot.kt index b897384111..537600adec 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ImportedLayersSlot.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ImportedLayersSlot.kt @@ -17,8 +17,8 @@ package org.meshtastic.feature.map.maplibre.component import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.koinInject import org.meshtastic.feature.map.component.CustomMapLayersSheet import org.meshtastic.feature.map.layers.LayerOpacityStore @@ -35,8 +35,8 @@ import org.meshtastic.feature.map.layers.rememberMapLayerPicker fun ImportedLayersSlot() { val manager: MapLayersManager = koinInject() val opacityStore: LayerOpacityStore = koinInject() - val layers by manager.mapLayers.collectAsState() - val opacity by opacityStore.opacity.collectAsState() + val layers by manager.mapLayers.collectAsStateWithLifecycle() + val opacity by opacityStore.opacity.collectAsStateWithLifecycle() val picker = rememberMapLayerPicker(onPick = manager::addMapLayer) CustomMapLayersSheet( diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapLayersButton.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapLayersButton.kt index b9e7a01878..3e5d8a848f 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapLayersButton.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapLayersButton.kt @@ -25,13 +25,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.meshtastic.core.resources.Res @@ -68,7 +68,7 @@ internal fun MapLayersButton( ) { var sheetVisible by remember { mutableStateOf(false) } val opacityStore: LayerOpacityStore = koinInject() - val opacity by opacityStore.opacity.collectAsState() + val opacity by opacityStore.opacity.collectAsStateWithLifecycle() MapButton( icon = MeshtasticIcons.Layers, diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineMapTarget.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineMapTarget.kt index b0524a5328..4f67df2eca 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineMapTarget.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineMapTarget.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -37,6 +36,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -91,7 +91,7 @@ internal fun OfflineMapsSection(target: OfflineMapTarget, onShowRegion: (Boundin // the one every map here uses, so its packs are the ones the user sees on the map. val manager = DefaultMapRuntime.instance.offlineManager val scope = rememberCoroutineScope() - val packs by manager.packs.collectAsState() + val packs by manager.packs.collectAsStateWithLifecycle() // A pack definition now carries the pixel ratio it was downloaded at, so the tiles match this display. val pixelRatio = LocalDensity.current.density @@ -165,7 +165,7 @@ private fun OfflinePackRow( onToggle: () -> Unit, onDelete: () -> Unit, ) { - val progress = pack.downloadProgress.collectAsState().value + val progress = pack.downloadProgress.collectAsStateWithLifecycle().value val bounds = (pack.definition as? OfflinePackDefinition.TilePyramid)?.bounds Row( diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineTerrainSection.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineTerrainSection.kt index 8694e69f12..374a544c0a 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineTerrainSection.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineTerrainSection.kt @@ -30,13 +30,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.maplibre.spatialk.geojson.BoundingBox @@ -59,13 +59,13 @@ import org.meshtastic.core.ui.icon.Delete import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.map.maplibre.terrain.OfflineTerrainRegion import org.meshtastic.feature.map.maplibre.terrain.OfflineTerrainRepository -import org.meshtastic.feature.map.maplibre.terrain.estimateTerrainTiles import org.meshtastic.feature.map.maplibre.terrain.toBoundingBox import org.meshtastic.feature.map.maplibre.terrain.toGeoBounds import org.meshtastic.feature.map.terrain.GeoBounds import org.meshtastic.feature.map.terrain.MapterhornEndpoints import org.meshtastic.feature.map.terrain.TerrainDownloadState import org.meshtastic.feature.map.terrain.TerrainRegionExtractor +import org.meshtastic.feature.map.terrain.terrainTileCount /** * Offline terrain — hillshade and elevation contours for the viewport, downloaded as its own section of the layers @@ -80,17 +80,17 @@ import org.meshtastic.feature.map.terrain.TerrainRegionExtractor internal fun OfflineTerrainSection(target: OfflineMapTarget, onShowRegion: (BoundingBox) -> Unit) { val repository = remember { OfflineTerrainRepository.default } val scope = rememberCoroutineScope() - val region by repository.region.collectAsState() + val region by repository.region.collectAsStateWithLifecycle() // Not a local composable state: startDownload runs on the repository's own scope (see its doc comment for // why), so the state it reports has to be read from there too, or progress would appear to vanish the moment // this composable leaves composition and reappear wrong on the next one. - val downloadState by repository.downloadState.collectAsState() + val downloadState by repository.downloadState.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { repository.refresh() } + LaunchedEffect(repository) { repository.refresh() } val bounds = target.bounds() val maxZoom = target.terrainMaxZoom() - val estimate = bounds?.let { estimateTerrainTiles(it.toGeoBounds(), maxZoom) } ?: 0L + val estimate = bounds?.let { terrainTileCount(it.toGeoBounds(), maxZoom) } ?: 0L val overLimit = estimate > TerrainRegionExtractor.MAX_TILES val isDownloading = downloadState is TerrainDownloadState.InProgress @@ -224,8 +224,8 @@ private fun DownloadedTerrainRow(region: OfflineTerrainRegion, onShow: () -> Uni * The zoom levels a terrain download covers: the current level plus a couple deeper, mirroring [OfflineMapTarget]'s own * private `zoomRange` convention for the base map's offline packs. * - * Bounded by [MapterhornEndpoints.REGIONAL_MAX_ZOOM] rather than MapLibre's own 0..20, since [estimateTerrainTiles] and - * [TerrainRegionExtractor] never fetch anything deeper than that regardless of what is asked for. + * Bounded by [MapterhornEndpoints.REGIONAL_MAX_ZOOM] rather than MapLibre's own 0..20, since [terrainTileCount] and + * [TerrainRegionExtractor] never count or fetch anything deeper than that regardless of what is asked for. */ private fun OfflineMapTarget.terrainMaxZoom(): Int { val current = zoom().toInt().coerceIn(0, MapterhornEndpoints.REGIONAL_MAX_ZOOM) diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/CustomLayers.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/CustomLayers.kt index 8af629f772..b9afe321b2 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/CustomLayers.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/CustomLayers.kt @@ -17,7 +17,6 @@ package org.meshtastic.feature.map.maplibre.layers import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.produceState @@ -27,6 +26,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger import coil3.compose.AsyncImagePainter import coil3.compose.LocalPlatformContext @@ -185,7 +185,7 @@ private fun rememberLayerIcons(urls: Set): Map { key(url) { val painter = rememberAsyncImagePainter(ImageRequest.Builder(LocalPlatformContext.current).data(url).build()) - val state by painter.state.collectAsState() + val state by painter.state.collectAsStateWithLifecycle() // The loaded painter, not the async wrapper around it: MapLibre rasterizes a painter outside the // composition driving it, where an AsyncImagePainter draws nothing. (state as? AsyncImagePainter.State.Success)?.let { loaded[url] = it.painter } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/TerrainLayers.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/TerrainLayers.kt index 14b2acd0a7..8aa870b093 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/TerrainLayers.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/TerrainLayers.kt @@ -18,11 +18,11 @@ package org.meshtastic.feature.map.maplibre.layers import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import org.maplibre.compose.expressions.dsl.asNumber @@ -74,9 +74,9 @@ import org.meshtastic.feature.map.terrain.TerrainTileMath @Composable internal fun TerrainLayers(viewportBounds: BoundingBox?, zoom: Double, displayUnits: MeasurementSystem) { val repository = remember { OfflineTerrainRepository.default } - val region by repository.region.collectAsState() + val region by repository.region.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { repository.refresh() } + LaunchedEffect(repository) { repository.refresh() } // Two early returns (detekt's ReturnCount limit), kept inline rather than in a helper function: a helper // returning a resolved nullable can't hand the compiler back a smart-cast on *this* function's own diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/terrain/TerrainDownloadEstimate.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/terrain/TerrainDownloadEstimate.kt deleted file mode 100644 index c67fc1371f..0000000000 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/terrain/TerrainDownloadEstimate.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.map.maplibre.terrain - -import org.meshtastic.feature.map.terrain.GeoBounds -import org.meshtastic.feature.map.terrain.MapterhornEndpoints -import org.meshtastic.feature.map.terrain.TerrainTileMath - -/** - * How many tiles [TerrainRegionExtractor][org.meshtastic.feature.map.terrain.TerrainRegionExtractor] would fetch for - * [bounds] up to [maxZoom] — the global tier always, the regional tier only where [MapterhornEndpoints.regionalUrlFor] - * finds one. - * - * The counting itself is [TerrainTileMath.tileCountAt] — the same O(1)-per-zoom corner arithmetic the extractor uses to - * bound memory before it enumerates tiles, so this and the extractor can never silently disagree on *how many* a zoom - * level covers. What's re-derived here, network-free, is only the *which zoom levels / which tier* decision from the - * extractor's own suspending `download` [kotlinx.coroutines.flow.Flow] — matching - * [org.meshtastic.feature.map.maplibre.tileCount]'s own "shown before a download starts" role for the base offline - * layer. If the extractor's own tier/zoom-range logic ever changes, this must change with it — that coupling is - * intentional, not an oversight; a unit test pins it against [MapterhornEndpoints]'s real constants rather than a copy - * of them. - */ -internal fun estimateTerrainTiles(bounds: GeoBounds, maxZoom: Int): Long { - val globalZoomRange = 0..minOf(maxZoom, MapterhornEndpoints.GLOBAL_MAX_ZOOM) - val globalCount = globalZoomRange.sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) } - - val hasRegionalTier = - maxZoom > MapterhornEndpoints.GLOBAL_MAX_ZOOM && MapterhornEndpoints.regionalUrlFor(bounds) != null - val regionalCount = - if (hasRegionalTier) { - val regionalMaxZoom = minOf(maxZoom, MapterhornEndpoints.REGIONAL_MAX_ZOOM) - val regionalZoomRange = MapterhornEndpoints.REGIONAL_MIN_ZOOM..regionalMaxZoom - regionalZoomRange.sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) } - } else { - 0L - } - - return globalCount + regionalCount -} diff --git a/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/geojson/ContourFeaturesTest.kt b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/geojson/ContourFeaturesTest.kt index e54464b1ce..42545a0bba 100644 --- a/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/geojson/ContourFeaturesTest.kt +++ b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/geojson/ContourFeaturesTest.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.map.maplibre.geojson import kotlinx.serialization.json.float import kotlinx.serialization.json.jsonPrimitive -import org.maplibre.spatialk.geojson.LineString import org.meshtastic.feature.map.terrain.ContourLine import org.meshtastic.feature.map.terrain.ContourPoint import org.meshtastic.feature.map.terrain.TerrainTileMath @@ -38,7 +37,7 @@ class ContourFeaturesTest { val features = contourLinesToFeatures(tile, listOf(line), zoom = 12, metric = true) assertEquals(1, features.size) - assertEquals(2, (features.single().geometry as LineString).coordinates.size) + assertEquals(2, features.single().geometry.coordinates.size) } @Test @@ -51,7 +50,7 @@ class ContourFeaturesTest { fun `tile-local points convert to the same lat lon TerrainTileMath's own inverse would produce`() { val line = ContourLine(elevationMeters = 100f, points = listOf(ContourPoint(0f, 0f), ContourPoint(1f, 1f))) val feature = contourLinesToFeatures(tile, listOf(line), zoom = 12, metric = true).single() - val positions = (feature.geometry as LineString).coordinates + val positions = feature.geometry.coordinates val expectedFirst = TerrainTileMath.lonLatAt(tile, 0f, 0f) assertEquals(expectedFirst.longitude, positions.first().longitude) diff --git a/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/terrain/TerrainDownloadEstimateTest.kt b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/terrain/TerrainDownloadEstimateTest.kt deleted file mode 100644 index faeefeacf4..0000000000 --- a/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/terrain/TerrainDownloadEstimateTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.map.maplibre.terrain - -import org.meshtastic.feature.map.terrain.GeoBounds -import org.meshtastic.feature.map.terrain.MapterhornEndpoints -import org.meshtastic.feature.map.terrain.TerrainTileMath -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class TerrainDownloadEstimateTest { - - private val tinyBounds = GeoBounds(south = 47.6, west = -122.4, north = 47.61, east = -122.39) - - @Test - fun `matches a manual sum of tilesAt across the global zoom range only`() { - val maxZoom = MapterhornEndpoints.GLOBAL_MAX_ZOOM - val expected = (0..maxZoom).sumOf { zoom -> TerrainTileMath.tilesAt(zoom, tinyBounds).size.toLong() } - - assertEquals(expected, estimateTerrainTiles(tinyBounds, maxZoom)) - } - - @Test - fun `a maxZoom at or below the global ceiling never counts a regional tier`() { - val globalOnly = estimateTerrainTiles(tinyBounds, MapterhornEndpoints.GLOBAL_MAX_ZOOM) - val deeper = estimateTerrainTiles(tinyBounds, MapterhornEndpoints.GLOBAL_MAX_ZOOM + 1) - - // The regional tier only exists past the global ceiling — one more zoom level must add regional tiles too. - assertTrue(deeper > globalOnly) - } - - @Test - fun `a huge box that never fits a single regional archive gets no regional tier`() { - // Spans more than one z6 tile, so MapterhornEndpoints.regionalUrlFor returns null. - val huge = GeoBounds(south = -60.0, west = -170.0, north = 60.0, east = 170.0) - - val atGlobalCeiling = estimateTerrainTiles(huge, MapterhornEndpoints.GLOBAL_MAX_ZOOM) - val past = estimateTerrainTiles(huge, MapterhornEndpoints.GLOBAL_MAX_ZOOM + 1) - - // Past the global ceiling with no regional archive, nothing further is fetched — the count doesn't grow. - assertEquals(atGlobalCeiling, past) - } - - @Test - fun `negative maxZoom counts nothing`() { - assertEquals(0L, estimateTerrainTiles(tinyBounds, -1)) - } - - @Test - fun `maxZoom 0 counts the single world-covering z0 tile`() { - // Unlike a negative maxZoom, 0 is a real, valid zoom range (0..0) -- it legitimately counts the one z0 tile - // that covers the whole world, not nothing. - assertEquals(1L, estimateTerrainTiles(tinyBounds, 0)) - } -} diff --git a/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCountTest.kt b/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCountTest.kt index 6f1033cef1..58592ded8e 100644 --- a/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCountTest.kt +++ b/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCountTest.kt @@ -42,4 +42,14 @@ class TerrainTileCountTest { assertEquals(8_869_485L, globalOnly) assertEquals(globalOnly, terrainTileCount(continentalBox, maxZoom = MapterhornEndpoints.REGIONAL_MAX_ZOOM)) } + + @Test + fun `a negative maxZoom counts nothing`() { + assertEquals(0L, terrainTileCount(tinySeattleBox, maxZoom = -1)) + } + + @Test + fun `maxZoom 0 counts the one tile that covers the world`() { + assertEquals(1L, terrainTileCount(tinySeattleBox, maxZoom = 0)) + } }