diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/RegionTerrain.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/RegionTerrain.kt index e58e316d95d..1d1105187d5 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/RegionTerrain.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/RegionTerrain.kt @@ -36,5 +36,12 @@ internal fun TileIndex.bounds(): GeoBounds { ) } -internal fun GeoBounds.intersects(other: GeoBounds): Boolean = - west < other.east && other.west < east && south < other.north && other.south < north +/** Whether the two overlap. A box whose west is east of its east crosses the antimeridian. */ +internal fun GeoBounds.intersects(other: GeoBounds): Boolean = south < other.north && + other.south < north && + longitudeSpans().any { (w, e) -> other.longitudeSpans().any { (ow, oe) -> w < oe && ow < e } } + +private fun GeoBounds.longitudeSpans(): List> = + if (west <= east) listOf(west to east) else listOf(west to MAX_LONGITUDE, -MAX_LONGITUDE to east) + +private const val MAX_LONGITUDE = 180.0 diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/RegionTerrainTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/RegionTerrainTest.kt new file mode 100644 index 00000000000..661f769c2d8 --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/RegionTerrainTest.kt @@ -0,0 +1,44 @@ +/* + * 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.coverage + +import org.meshtastic.feature.map.terrain.GeoBounds +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RegionTerrainTest { + + // Fiji, west 177 to east -178: across the antimeridian. + private val acrossTheAntimeridian = GeoBounds(south = -19.0, west = 177.0, north = -16.0, east = -178.0) + + @Test + fun aWrappedRegionMeetsGroundOnBothSidesOfTheLine() { + assertTrue(acrossTheAntimeridian.intersects(GeoBounds(-18.0, 178.0, -17.0, 179.0))) + assertTrue(acrossTheAntimeridian.intersects(GeoBounds(-18.0, -179.5, -17.0, -179.0))) + } + + @Test + fun aWrappedRegionMissesGroundBetweenItsEnds() { + assertFalse(acrossTheAntimeridian.intersects(GeoBounds(-18.0, 0.0, -17.0, 1.0))) + } + + @Test + fun boxesThatOnlyTouchDoNotIntersect() { + assertFalse(GeoBounds(0.0, 0.0, 1.0, 1.0).intersects(GeoBounds(0.0, 1.0, 1.0, 2.0))) + } +} 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 77d8ac0c3ad..f1370fe6ad2 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 @@ -147,6 +147,7 @@ class MapLibreMapViewProvider( val location = rememberLocationControls() val waypoints = rememberWaypointEditing() val screen = rememberMapScreenState(waypointId = waypointId, sitePlannerNodeNum = sitePlannerNodeNum) + val layers = customLayers() val mapState = rememberMapScreenMapState( @@ -155,7 +156,7 @@ class MapLibreMapViewProvider( screen = screen, waypoints = waypoints, location = location, - customLayers = customLayers(), + customLayers = layers, navigateToNodeDetails = navigateToNodeDetails, ) @@ -170,6 +171,7 @@ class MapLibreMapViewProvider( mapState = mapState, modifier = Modifier.fillMaxSize(), basemap = basemaps.current, + extraCredits = layers.mapNotNull { it.attributionHtml }, onMapLongClick = waypoints.onLongPress, onMapClick = waypoints.onMapTap, ) diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MeshMap.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MeshMap.kt index aaaab783e5e..ae5f3c2c2fb 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MeshMap.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MeshMap.kt @@ -73,7 +73,7 @@ import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.MapNodePolicy import org.meshtastic.feature.map.component.MapEngineUnavailable import org.meshtastic.feature.map.component.MeshMapFitPadding -import org.meshtastic.feature.map.maplibre.component.MeshMapOrnaments +import org.meshtastic.feature.map.maplibre.component.meshMapOrnaments import org.meshtastic.feature.map.maplibre.geojson.ClusterMember import org.meshtastic.feature.map.maplibre.geojson.rememberFeatureSource import org.meshtastic.feature.map.maplibre.layers.CustomLayer @@ -222,6 +222,8 @@ fun MeshMap( mapState: MapState, modifier: Modifier = Modifier, basemap: Basemap = Basemaps.default, + /** HTML credits for drawn data no style source carries; see [meshMapOrnaments]. */ + extraCredits: List = emptyList(), /** Called with the pressed position on a long press, which is how a new waypoint gets placed. */ onMapLongClick: (Position) -> Unit = {}, /** Called with the tapped position. Used to collect the two corners of a waypoint's geofence bounding box. */ @@ -240,6 +242,8 @@ fun MeshMap( } val zoomRange = basemap.zoomRange() + val credits = extraCredits.distinct() + val ornaments = remember(credits) { meshMapOrnaments(credits) } MaplibreMap( modifier = modifier, state = mapState, @@ -267,7 +271,7 @@ fun MeshMap( } } }, - overlay = { include(MeshMapOrnaments) }, + overlay = { include(ornaments) }, ) } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt index 4ed730677ad..e3480dde7c0 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt @@ -173,7 +173,7 @@ fun MapLibreNodeTrackMap( ?.let { selected -> positions.firstOrNull { it.time == selected } }, displayUnits = displayUnits, // Clear of the wordmark and attribution button along the bottom edge, which every map shows. - // See MeshMapOrnaments. + // See meshMapOrnaments. modifier = Modifier.align(Alignment.BottomStart) .padding(start = CARD_INSET.dp, end = CARD_INSET.dp, bottom = ORNAMENT_CLEARANCE.dp), diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt index 0487bce5c09..59caae9545d 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt @@ -449,7 +449,7 @@ internal const val CARD_INSET = 8 /** * Height to leave for the map's wordmark and attribution button, which every map shows. * - * See [org.meshtastic.feature.map.maplibre.component.MeshMapOrnaments]. + * See [org.meshtastic.feature.map.maplibre.component.meshMapOrnaments]. */ internal const val ORNAMENT_CLEARANCE = 40 private const val CARD_PADDING = 8 diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt index c352a1b89e7..6c1285cd0fe 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt @@ -69,8 +69,11 @@ import org.maplibre.compose.overlay.MapOverlay as MaplibreOverlay * * Scale-bar units are left to the library, which picks them by region — the same locale-driven approach the rest of the * app takes through `localeUnitsProvider`. + * + * [extraCredits] are HTML credits for data no style source carries, such as the terrain under a coverage estimate, + * which is drawn from a GeoJSON source that has no attribution field. They join the style's own, deduplicated. */ -internal val MeshMapOrnaments: MaplibreOverlay = MaplibreOverlay { +internal fun meshMapOrnaments(extraCredits: List): MaplibreOverlay = MaplibreOverlay { val mapState = checkNotNull(LocalMapState.current) val viewportInsets = LocalViewportInsets.current @@ -89,18 +92,24 @@ internal val MeshMapOrnaments: MaplibreOverlay = MaplibreOverlay { zoom = { mapState.cameraPosition.zoom }, modifier = Modifier.align(Alignment.TopStart), ) + if (extraCredits.isNotEmpty()) { + // Ours rather than the library's button, which draws nothing when the style itself credits nobody, as a + // custom raster basemap may not. + MaplibreLogo(Modifier.align(Alignment.BottomStart)) + CreditButton(Modifier.align(Alignment.BottomEnd), extraCredits = extraCredits, startExpanded = true) + } } // The logo and the attribution button, in the places `MapOverlay.Default` puts them. - include(MaplibreOverlay.AttributionOnly) + if (extraCredits.isEmpty()) include(MaplibreOverlay.AttributionOnly) } /** * `MapOverlay.Default` with the credit collapsed, for a map too small to host the credit without it overflowing: the * scale bar, the wordmark and the button all stay, and the button is what reveals the text. * - * Composed rather than reached for because the library cannot be asked for it — [CollapsedAttributionButton] says why — - * and the insets are spelled out because its `DefaultControls` is internal to it. + * Composed rather than reached for because the library cannot be asked for it — [CreditButton] says why — and the + * insets are spelled out because its `DefaultControls` is internal to it. */ internal val CollapsedAttributionOrnaments: MaplibreOverlay = MaplibreOverlay { val mapState = checkNotNull(LocalMapState.current) @@ -119,7 +128,7 @@ internal val CollapsedAttributionOrnaments: MaplibreOverlay = MaplibreOverlay { modifier = Modifier.align(Alignment.TopStart), ) MaplibreLogo(Modifier.align(Alignment.BottomStart)) - CollapsedAttributionButton(Modifier.align(Alignment.BottomEnd)) + CreditButton(Modifier.align(Alignment.BottomEnd)) } } @@ -130,22 +139,28 @@ internal val CollapsedAttributionOrnaments: MaplibreOverlay = MaplibreOverlay { private const val CREDIT_WIDTH_FRACTION = 0.55f /** - * The library's attribution button, starting collapsed. + * The library's attribution button, with the expanded state and the credit list held here. * * `ExpandingAttributionButton` holds its own `expanded` flag, initialised to `true` and not settable from outside, so * `collapsedStyle` only styles the branch it is already in and the credit still opens. Holding the flag here is what - * makes it start shut; everything else — icon, label, links — still comes from the library. + * lets it start shut; everything else — icon, label, links — still comes from the library. It also returns before + * drawing anything when the style credits nobody, which would drop [extraCredits] with it. */ @Composable -private fun CollapsedAttributionButton(modifier: Modifier = Modifier) { +private fun CreditButton( + modifier: Modifier = Modifier, + extraCredits: List = emptyList(), + startExpanded: Boolean = false, +) { val mapState = checkNotNull(LocalMapState.current) val mapStyle = mapState.style // Derived, not remembered: a basemap arrives as a style URL, so its sources are still empty on the first // composition and a remembered list would never re-read. As in the library's own button. - val attributions by remember(mapStyle) { derivedStateOf { mapStyle.attributions() } } + val attributions by + remember(mapStyle, extraCredits) { derivedStateOf { (mapStyle.attributions() + extraCredits).distinct() } } if (attributions.isEmpty()) return - var expanded by remember(mapStyle) { mutableStateOf(false) } + var expanded by remember(mapStyle) { mutableStateOf(startExpanded) } // As the library's button: a gesture closes the credit, so it does not sit over a short map after a pan. Narrowed // to gestures, or the programmatic moves our own zoom controls make would fold it away mid-animation. diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ZoomControls.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ZoomControls.kt index e14a604791a..99ef43f5411 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ZoomControls.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ZoomControls.kt @@ -51,7 +51,7 @@ private const val ZOOM_BOTTOM_INSET = 56 * somewhere else. * * Lifted clear of the wordmark and attribution button along the bottom edge, which every map shows. See - * [MeshMapOrnaments] for why the main map carries the credit outright and the small maps behind the button. + * [meshMapOrnaments] for why the main map carries the credit outright and the small maps behind the button. */ @Composable internal fun BoxScope.MapZoom(mapState: MapState, basemap: Basemap) { diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/CustomLayer.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/CustomLayer.kt index 31e6b6e6d94..b555b752cd7 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/CustomLayer.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/CustomLayer.kt @@ -38,6 +38,8 @@ data class CustomLayer( val refreshToken: Int = 0, val icons: Set = emptySet(), val groundOverlays: List = emptyList(), + /** Credit the map shows while this layer is, for data the GeoJSON itself has no field to carry. */ + val attributionHtml: String? = null, ) /** diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/RenderableLayers.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/RenderableLayers.kt index f6123e7d4f4..642b1220505 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/RenderableLayers.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/layers/RenderableLayers.kt @@ -78,7 +78,15 @@ fun rememberRenderableLayers(manager: MapLayersManager, layers: List when { layer.layerType != LayerType.KML -> - layer.uri?.let { CustomLayer(id = layer.id, uri = it, refreshToken = layer.refreshToken) } + layer.uri?.let { + CustomLayer( + id = layer.id, + uri = it, + refreshToken = layer.refreshToken, + // An estimate is computed from Mapterhorn terrain. + attributionHtml = MAPTERHORN_ATTRIBUTION_HTML.takeIf { layer.layerType == LayerType.COVERAGE }, + ) + } else -> converted[layer.conversionKey()]?.let { conversion -> 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 07b08cf73c8..35c83d1896c 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 @@ -97,9 +97,10 @@ internal fun TerrainLayers(viewportBounds: BoundingBox?, zoom: Double, displayUn * both tiers rather than only the always-present global one is harmless). The contour * [org.maplibre.compose.sources.GeoJsonSource] below has no attribution field of its own to carry this on — * [org.maplibre.compose.sources.GeoJsonOptions] doesn't have one — but contours only ever render alongside hillshade - * (see [TerrainLayers]'s shared gate), so this covers both. + * (see [TerrainLayers]'s shared gate), so this covers both. A coverage estimate's [CustomLayer.attributionHtml] uses + * the same string, so the map shows it once when both draw. */ -private const val MAPTERHORN_ATTRIBUTION_HTML = +internal const val MAPTERHORN_ATTRIBUTION_HTML = "© Mapterhorn" /**