Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pair<Double, Double>> =
if (west <= east) listOf(west to east) else listOf(west to MAX_LONGITUDE, -MAX_LONGITUDE to east)

private const val MAX_LONGITUDE = 180.0
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -155,7 +156,7 @@ class MapLibreMapViewProvider(
screen = screen,
waypoints = waypoints,
location = location,
customLayers = customLayers(),
customLayers = layers,
navigateToNodeDetails = navigateToNodeDetails,
)

Expand All @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String> = 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. */
Expand All @@ -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,
Expand Down Expand Up @@ -267,7 +271,7 @@ fun MeshMap(
}
}
},
overlay = { include(MeshMapOrnaments) },
overlay = { include(ornaments) },
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>): MaplibreOverlay = MaplibreOverlay {
val mapState = checkNotNull(LocalMapState.current)
val viewportInsets = LocalViewportInsets.current

Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

// 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)
Expand All @@ -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))
}
}

Expand All @@ -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<String> = 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ data class CustomLayer(
val refreshToken: Int = 0,
val icons: Set<String> = emptySet(),
val groundOverlays: List<LayerGroundOverlay> = emptyList(),
/** Credit the map shows while this layer is, for data the GeoJSON itself has no field to carry. */
val attributionHtml: String? = null,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,15 @@ fun rememberRenderableLayers(manager: MapLayersManager, layers: List<MapLayerIte
val renderable = layers.mapNotNull { layer ->
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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
"&copy; <a href=\"${MapterhornEndpoints.ATTRIBUTION_URL}\">Mapterhorn</a>"

/**
Expand Down