From f70921608e9ebb893c38fcf2cf42b9acd1342813 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:39:44 -0500 Subject: [PATCH 1/4] fix(map): credit Mapterhorn on the MapLibre map under a coverage layer A coverage estimate is a GeoJSON source, which carries no attribution, so the MapLibre map credited Mapterhorn only while hillshade drew. A coverage layer now carries the credit and the main map adds it to the attribution button, deduplicated against the hillshade credit. --- .../map/maplibre/MapLibreMapViewProvider.kt | 4 +++- .../feature/map/maplibre/MeshMap.kt | 8 +++++-- .../feature/map/maplibre/NodeTrackMap.kt | 2 +- .../feature/map/maplibre/SecondaryMaps.kt | 2 +- .../map/maplibre/component/MapOrnaments.kt | 22 +++++++++++++++++-- .../map/maplibre/component/ZoomControls.kt | 2 +- .../map/maplibre/layers/CustomLayer.kt | 2 ++ .../map/maplibre/layers/RenderableLayers.kt | 10 ++++++++- .../map/maplibre/layers/TerrainLayers.kt | 5 +++-- 9 files changed, 46 insertions(+), 11 deletions(-) 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..7c29fd3f1dc 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 @@ -47,6 +47,7 @@ import org.maplibre.compose.map.LocalMapState import org.maplibre.compose.overlay.AttributionDefaults import org.maplibre.compose.overlay.AttributionLinks import org.maplibre.compose.overlay.DisappearingScaleBar +import org.maplibre.compose.overlay.ExpandingAttributionButton import org.maplibre.compose.overlay.LocalViewportInsets import org.maplibre.compose.overlay.MaplibreLogo import org.maplibre.compose.overlay.attributions @@ -69,8 +70,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,10 +93,24 @@ internal val MeshMapOrnaments: MaplibreOverlay = MaplibreOverlay { zoom = { mapState.cameraPosition.zoom }, modifier = Modifier.align(Alignment.TopStart), ) + if (extraCredits.isNotEmpty()) { + // `AttributionOnly` as the library composes it, with the extra credits added to what the button shows. + MaplibreLogo(Modifier.align(Alignment.BottomStart)) + ExpandingAttributionButton( + modifier = Modifier.align(Alignment.BottomEnd), + contentAlignment = Alignment.BottomEnd, + toggleButton = AttributionDefaults.button, + expandedContent = { credits, style -> AttributionDefaults.content((credits + extraCredits).distinct(), style) }, + expandedStyle = AttributionDefaults.expandedStyle(), + collapsedStyle = AttributionDefaults.collapsedStyle(), + expand = AttributionDefaults.expand, + collapse = AttributionDefaults.collapse, + ) + } } // The logo and the attribution button, in the places `MapOverlay.Default` puts them. - include(MaplibreOverlay.AttributionOnly) + if (extraCredits.isEmpty()) include(MaplibreOverlay.AttributionOnly) } /** 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" /** From 1b5252c18d6e01ffb667ecdf91fd4da79a5e7656 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:40:03 -0500 Subject: [PATCH 2/4] fix(coverage): match offline regions across the antimeridian A region whose west is east of its east crosses the 180th meridian, and the overlap check rejected ground on both sides of it, so the estimate went to the network for terrain the region holds. --- .../feature/coverage/RegionTerrain.kt | 9 +++- .../feature/coverage/RegionTerrainTest.kt | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/RegionTerrainTest.kt 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..54da072dc99 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 { ) } +/** Whether the two overlap. A box whose west is east of its east crosses the antimeridian. */ internal fun GeoBounds.intersects(other: GeoBounds): Boolean = - west < other.east && other.west < east && south < other.north && other.south < north + 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))) + } +} From a7d31074567518b47a0b6de3efde14f4ca2e72da Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:43:01 -0500 Subject: [PATCH 3/4] style: spotless --- .../kotlin/org/meshtastic/feature/coverage/RegionTerrain.kt | 6 +++--- .../feature/map/maplibre/component/MapOrnaments.kt | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) 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 54da072dc99..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 @@ -37,9 +37,9 @@ internal fun TileIndex.bounds(): GeoBounds { } /** 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 } } +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) 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 7c29fd3f1dc..4f8397ff0a5 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 @@ -100,7 +100,9 @@ internal fun meshMapOrnaments(extraCredits: List): MaplibreOverlay = Map modifier = Modifier.align(Alignment.BottomEnd), contentAlignment = Alignment.BottomEnd, toggleButton = AttributionDefaults.button, - expandedContent = { credits, style -> AttributionDefaults.content((credits + extraCredits).distinct(), style) }, + expandedContent = { credits, style -> + AttributionDefaults.content((credits + extraCredits).distinct(), style) + }, expandedStyle = AttributionDefaults.expandedStyle(), collapsedStyle = AttributionDefaults.collapsedStyle(), expand = AttributionDefaults.expand, From 18ffa4a4ac9f0822b97618605aca05f3630c6e65 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:57:39 -0500 Subject: [PATCH 4/4] fix(map): show the coverage credit when the basemap credits nobody The library button draws nothing for a style with no attribution, as a custom raster basemap may have, which dropped the Mapterhorn credit with it. The main map now uses the same held-state button as the small maps, fed the extra credit. --- .../map/maplibre/component/MapOrnaments.kt | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) 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 4f8397ff0a5..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 @@ -47,7 +47,6 @@ import org.maplibre.compose.map.LocalMapState import org.maplibre.compose.overlay.AttributionDefaults import org.maplibre.compose.overlay.AttributionLinks import org.maplibre.compose.overlay.DisappearingScaleBar -import org.maplibre.compose.overlay.ExpandingAttributionButton import org.maplibre.compose.overlay.LocalViewportInsets import org.maplibre.compose.overlay.MaplibreLogo import org.maplibre.compose.overlay.attributions @@ -94,20 +93,10 @@ internal fun meshMapOrnaments(extraCredits: List): MaplibreOverlay = Map modifier = Modifier.align(Alignment.TopStart), ) if (extraCredits.isNotEmpty()) { - // `AttributionOnly` as the library composes it, with the extra credits added to what the button shows. + // 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)) - ExpandingAttributionButton( - modifier = Modifier.align(Alignment.BottomEnd), - contentAlignment = Alignment.BottomEnd, - toggleButton = AttributionDefaults.button, - expandedContent = { credits, style -> - AttributionDefaults.content((credits + extraCredits).distinct(), style) - }, - expandedStyle = AttributionDefaults.expandedStyle(), - collapsedStyle = AttributionDefaults.collapsedStyle(), - expand = AttributionDefaults.expand, - collapse = AttributionDefaults.collapse, - ) + CreditButton(Modifier.align(Alignment.BottomEnd), extraCredits = extraCredits, startExpanded = true) } } @@ -119,8 +108,8 @@ internal fun meshMapOrnaments(extraCredits: List): MaplibreOverlay = Map * `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) @@ -139,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)) } } @@ -150,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.