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
1 change: 1 addition & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@
package org.meshtastic.app.map.component

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.maplibre.spatialk.geojson.Position
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.feature.coverage.RegionTerrain
import org.meshtastic.feature.coverage.rememberCoverageEstimate
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.component.SitePlannerHost
import org.meshtastic.feature.map.component.toSitePlannerParams
import org.meshtastic.feature.map.maplibre.SitePlannerSession
import org.meshtastic.feature.map.maplibre.terrain.OfflineTerrainRepository

/**
* Runs the Site Planner for the F-Droid map. The estimate becomes a GeoJSON map layer and the map moves to the
Expand All @@ -48,9 +52,15 @@ fun SitePlannerSlot(session: SitePlannerSession) {
// A deep link names the node to plan for; a toolbar launch plans for whatever we are connected to.
val subject = session.nodeNum?.let { num -> nodes.firstOrNull { it.num == num } } ?: ourNode

val terrain = OfflineTerrainRepository.default
LaunchedEffect(terrain) { terrain.refresh() }
val terrainRegion by terrain.region.collectAsStateWithLifecycle()
val offlineTerrain =
remember(terrainRegion) { listOfNotNull(terrainRegion?.let { RegionTerrain(terrain.tileStore, it.bounds) }) }

SitePlannerHost(
initialParams = subject.toSitePlannerParams(channelSet),
estimate = rememberCoverageEstimate(),
estimate = rememberCoverageEstimate(offlineTerrain),
onDismiss = session.onDismiss,
onImport = { name, geoJson, latitude, longitude ->
mapViewModel.addGeoJsonLayer(name, geoJson)
Expand Down
22 changes: 19 additions & 3 deletions androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.coverage.RegionTerrain
import org.meshtastic.feature.coverage.rememberCoverageEstimate
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.MapBounds
Expand Down Expand Up @@ -225,6 +226,7 @@ import org.meshtastic.feature.map.layers.MapLayerItem
import org.meshtastic.feature.map.layers.opacityOf
import org.meshtastic.feature.map.layers.toPickedMapFile
import org.meshtastic.feature.map.mergeStationaryRuns
import org.meshtastic.feature.map.terrain.GeoBounds
import org.meshtastic.feature.map.terrain.MapterhornEndpoints
import org.meshtastic.feature.map.tiles.mapAttributionText
import org.meshtastic.feature.map.tracerouteNodeSelection
Expand Down Expand Up @@ -1027,12 +1029,14 @@ fun MapView(
// alone: an on-disk-only attribution is invisible to anyone who never opens the offline-region manager
// sheet, which is exactly the gap this mirrors from the sibling iOS app's own terrain layer. The offline
// terrain layer's own credit (Mapterhorn) is folded in the same way, whenever hillshade or contours are
// actually rendering.
// actually rendering, or a coverage estimate built from that terrain is shown.
val attributionCoveringRegion =
cameraPositionState.projection?.visibleRegion?.latLngBounds?.let { mapViewModel.offlineRegionCovering(it) }
val offlineRegionActive = offlineOverlayEnabled && attributionCoveringRegion != null
val terrainActive =
val terrainDrawn =
attributionCoveringRegion?.hasTerrain == true && (terrainHillshadeEnabled || terrainContoursEnabled)
val coverageShown = mapLayers.any { it.layerType == LayerType.COVERAGE && it.isVisible }
val terrainActive = terrainDrawn || coverageShown
val attributionText =
remember(currentRasterBasemap, enabledOverlayIds, offlineRegionActive, terrainActive) {
val baseAttribution =
Expand Down Expand Up @@ -1234,9 +1238,21 @@ fun MapView(
// Route launches retain the selected node; manual map launches continue following our connected node.
val onUseNodeLocation: (() -> Pair<Double, Double>)? =
launch.nodeLocation(ourNodeInfo)?.let { location -> { location } }
val offlineRegions by mapViewModel.offlineRegions.collectAsStateWithLifecycle()
val offlineTerrain =
remember(offlineRegions) {
offlineRegions
.filter { it.hasTerrain }
.map { region ->
RegionTerrain(
store = mapViewModel.terrainStoreForRegion(region.id),
bounds = GeoBounds(region.southLat, region.westLon, region.northLat, region.eastLon),
)
}
}
SitePlannerHost(
initialParams = launch.initialParams,
estimate = rememberCoverageEstimate(),
estimate = rememberCoverageEstimate(offlineTerrain),
onDismiss = { sitePlannerLaunch = null },
onImport = { name, geoJson, latitude, longitude ->
mapViewModel.addGeoJsonLayer(name, geoJson)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,7 @@
<string name="site_planner_section_simulation">Simulation Options</string>
<string name="site_planner_section_transmitter">Site / Transmitter</string>
<string name="site_planner_subtitle">Estimate RF coverage for a transmitter</string>
<string name="site_planner_terrain_credit">Terrain data © Mapterhorn</string>
<string name="site_planner_tx_power_watts">TX power (W)</string>
<string name="site_planner_use_current_location">Use current location</string>
<string name="site_planner_use_map_center">Use map center</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,21 @@
package org.meshtastic.desktop.map

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.maplibre.spatialk.geojson.Position
import org.meshtastic.feature.coverage.RegionTerrain
import org.meshtastic.feature.coverage.rememberCoverageEstimate
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.component.SitePlannerHost
import org.meshtastic.feature.map.component.toSitePlannerParams
import org.meshtastic.feature.map.layers.MapLayersManager
import org.meshtastic.feature.map.maplibre.SitePlannerSession
import org.meshtastic.feature.map.maplibre.terrain.OfflineTerrainRepository

/** Runs the Site Planner for the desktop map; the estimate becomes a GeoJSON map layer and the map moves to it. */
@Composable
Expand All @@ -42,9 +46,15 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) {
// A deep link names the node to plan for; a toolbar launch plans for whatever we are connected to.
val subject = session.nodeNum?.let { num -> nodes.firstOrNull { it.num == num } } ?: ourNode

val terrain = OfflineTerrainRepository.default
LaunchedEffect(terrain) { terrain.refresh() }
val terrainRegion by terrain.region.collectAsStateWithLifecycle()
val offlineTerrain =
remember(terrainRegion) { listOfNotNull(terrainRegion?.let { RegionTerrain(terrain.tileStore, it.bounds) }) }

SitePlannerHost(
initialParams = subject.toSitePlannerParams(channelSet),
estimate = rememberCoverageEstimate(),
estimate = rememberCoverageEstimate(offlineTerrain),
onDismiss = session.onDismiss,
onImport = { name, geoJson, latitude, longitude ->
layersManager.addGeoJsonLayer(name, geoJson)
Expand Down
10 changes: 7 additions & 3 deletions feature/coverage/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,21 @@ kotlin {
implementation(libs.kp1812)
implementation(libs.kotlinx.coroutines.core)
implementation(projects.feature.map)
implementation(projects.feature.mapTerrain)
// api: the estimate's public signatures take a TerrainTileStore.
api(projects.feature.mapTerrain)
implementation(projects.core.common)
implementation(projects.core.di)
implementation(libs.okio)
implementation(libs.ktor.client.core)
}

jvmMain.dependencies { implementation(libs.ktor.client.java) }
androidMain.dependencies { implementation(libs.ktor.client.okhttp) }

commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
commonTest.dependencies {
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
implementation(libs.okio.fakefilesystem)
}
jvmTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* 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

internal actual fun processorCount(): Int = Runtime.getRuntime().availableProcessors()
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers

/**
* Where a coverage estimate's CPU work runs: [Dispatchers.Default] with one worker held back. The mesh service shares
* Default, and a sweep queues hundreds of radials, so an unlimited one would leave incoming radio data waiting on it.
*/
@Suppress("InjectDispatcher") // the sweep and tile decode are built outside DI
internal val coverageComputeDispatcher: CoroutineDispatcher by lazy {
Dispatchers.Default.limitedParallelism(maxOf(1, processorCount() - 1))
}

/** CPU cores available to the process; common code has no API for it. */
internal expect fun processorCount(): Int
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,21 @@ internal fun CoverageGrid.bands(ranges: List<ClosedFloatingPointRange<Double>>):
// Greedily merge horizontal runs of in-band cells into rectangles: far fewer polygons than
// one per cell, and the map renders a handful of rings rather than thousands.
val taken = BooleanArray(width * height)
for (y in 0 until height - 1) {
for (y in 0 until height) {
var x = 0
while (x < width - 1) {
while (x < width) {
if (taken[y * width + x] || !inBand(x, y, range)) {
x++
continue
}
var runEnd = x
while (runEnd + 1 < width - 1 && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, range)) {
while (runEnd + 1 < width && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, range)) {
runEnd++
}
// Extend downwards while the whole run stays in band.
var runBottom = y
while (
runBottom + 1 < height - 1 &&
runBottom + 1 < height &&
(x..runEnd).all { inBand(it, runBottom + 1, range) } &&
(x..runEnd).none { taken[(runBottom + 1) * width + it] }
) {
Expand All @@ -63,11 +63,11 @@ internal fun CoverageGrid.bands(ranges: List<ClosedFloatingPointRange<Double>>):
}
rings.add(
listOf(
lonAt(x.toDouble()) to latAt(y.toDouble()),
lonAt(runEnd + 1.0) to latAt(y.toDouble()),
lonAt(runEnd + 1.0) to latAt(runBottom + 1.0),
lonAt(x.toDouble()) to latAt(runBottom + 1.0),
lonAt(x.toDouble()) to latAt(y.toDouble()),
lonAt(x - HALF_CELL) to latAt(y - HALF_CELL),
lonAt(runEnd + HALF_CELL) to latAt(y - HALF_CELL),
lonAt(runEnd + HALF_CELL) to latAt(runBottom + HALF_CELL),
lonAt(x - HALF_CELL) to latAt(runBottom + HALF_CELL),
lonAt(x - HALF_CELL) to latAt(y - HALF_CELL),
),
)
x = runEnd + 1
Expand Down Expand Up @@ -142,6 +142,7 @@ $features
"""
}

private const val HALF_CELL = 0.5
private const val DEFAULT_BANDS = 6
private const val EMPTY_FEATURE_COLLECTION =
"""{"type":"FeatureCollection","properties":{"generator":"meshtastic-kp1812"},"features":[]}"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class CoverageGrid(
val dbm: DoubleArray,
) {
init {
// lonAt and latAt divide by width - 1 and height - 1.
require(width >= 2 && height >= 2) { "grid must be at least 2x2, got $width x $height" }
require(dbm.size == width * height) { "dbm must be width*height, got ${dbm.size} for $width x $height" }
}

Expand Down
Loading
Loading