mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: maps (#2097)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
c05f434ff2
commit
87e50e03ea
76 changed files with 4188 additions and 1830 deletions
|
|
@ -62,9 +62,14 @@ data class Node(
|
|||
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
|
||||
}
|
||||
|
||||
val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET
|
||||
val hasPKC get() = (publicKey ?: user.publicKey).isNotEmpty()
|
||||
val mismatchKey get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
|
||||
val isUnknownUser
|
||||
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
|
||||
|
||||
val hasPKC
|
||||
get() = (publicKey ?: user.publicKey).isNotEmpty()
|
||||
|
||||
val mismatchKey
|
||||
get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
|
||||
|
||||
val hasEnvironmentMetrics: Boolean
|
||||
get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance()
|
||||
|
|
@ -72,20 +77,28 @@ data class Node(
|
|||
val hasPowerMetrics: Boolean
|
||||
get() = powerMetrics != PowerMetrics.getDefaultInstance()
|
||||
|
||||
val batteryLevel get() = deviceMetrics.batteryLevel
|
||||
val voltage get() = deviceMetrics.voltage
|
||||
val batteryStr get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
|
||||
val batteryLevel
|
||||
get() = deviceMetrics.batteryLevel
|
||||
|
||||
val latitude get() = position.latitudeI * 1e-7
|
||||
val longitude get() = position.longitudeI * 1e-7
|
||||
val voltage
|
||||
get() = deviceMetrics.voltage
|
||||
|
||||
private fun hasValidPosition(): Boolean {
|
||||
return latitude != 0.0 && longitude != 0.0 &&
|
||||
(latitude >= -90 && latitude <= 90.0) &&
|
||||
(longitude >= -180 && longitude <= 180)
|
||||
}
|
||||
val batteryStr
|
||||
get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
|
||||
|
||||
val validPosition: MeshProtos.Position? get() = position.takeIf { hasValidPosition() }
|
||||
val latitude
|
||||
get() = position.latitudeI * 1e-7
|
||||
|
||||
val longitude
|
||||
get() = position.longitudeI * 1e-7
|
||||
|
||||
private fun hasValidPosition(): Boolean = latitude != 0.0 &&
|
||||
longitude != 0.0 &&
|
||||
(latitude >= -90 && latitude <= 90.0) &&
|
||||
(longitude >= -180 && longitude <= 180)
|
||||
|
||||
val validPosition: MeshProtos.Position?
|
||||
get() = position.takeIf { hasValidPosition() }
|
||||
|
||||
// @return distance in meters to some other node (or null if unknown)
|
||||
fun distance(o: Node): Int? = when {
|
||||
|
|
@ -103,70 +116,58 @@ data class Node(
|
|||
else -> com.geeksville.mesh.util.bearing(latitude, longitude, o.latitude, o.longitude).toInt()
|
||||
}
|
||||
|
||||
fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
|
||||
DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude)
|
||||
DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude)
|
||||
DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude)
|
||||
DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude)
|
||||
else -> GPSFormat.toDEC(latitude, longitude)
|
||||
}
|
||||
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
|
||||
|
||||
private fun EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String {
|
||||
val temp = if (temperature != 0f) {
|
||||
if (isFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(temperature))
|
||||
val temp =
|
||||
if (temperature != 0f) {
|
||||
if (isFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(temperature))
|
||||
} else {
|
||||
"%.1f°C".format(temperature)
|
||||
}
|
||||
} else {
|
||||
"%.1f°C".format(temperature)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null
|
||||
val soilTemperatureStr = if (soilTemperature != 0f) {
|
||||
if (isFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(soilTemperature))
|
||||
val soilTemperatureStr =
|
||||
if (soilTemperature != 0f) {
|
||||
if (isFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(soilTemperature))
|
||||
} else {
|
||||
"%.1f°C".format(soilTemperature)
|
||||
}
|
||||
} else {
|
||||
"%.1f°C".format(soilTemperature)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val soilMoistureRange = 0..100
|
||||
val soilMoisture =
|
||||
if (soilMoisture in soilMoistureRange && soilTemperature != 0f) {
|
||||
"%d%%".format(soilMoisture)
|
||||
} else { null }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null
|
||||
val current = if (current != 0f) "%.1fmA".format(current) else null
|
||||
val iaq = if (iaq != 0) "IAQ: $iaq" else null
|
||||
|
||||
return listOfNotNull(
|
||||
temp,
|
||||
humidity,
|
||||
soilTemperatureStr,
|
||||
soilMoisture,
|
||||
voltage,
|
||||
current,
|
||||
iaq,
|
||||
).joinToString(" ")
|
||||
return listOfNotNull(temp, humidity, soilTemperatureStr, soilMoisture, voltage, current, iaq).joinToString(" ")
|
||||
}
|
||||
|
||||
private fun PaxcountProtos.Paxcount.getDisplayString() =
|
||||
"PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 }
|
||||
|
||||
fun getTelemetryString(isFahrenheit: Boolean = false): String {
|
||||
return listOfNotNull(
|
||||
paxcounter.getDisplayString(),
|
||||
environmentMetrics.getDisplayString(isFahrenheit),
|
||||
).joinToString(" ")
|
||||
}
|
||||
fun getTelemetryString(isFahrenheit: Boolean = false): String =
|
||||
listOfNotNull(paxcounter.getDisplayString(), environmentMetrics.getDisplayString(isFahrenheit))
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in listOf(
|
||||
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
|
||||
ConfigProtos.Config.DeviceConfig.Role.SENSOR,
|
||||
ConfigProtos.Config.DeviceConfig.Role.TRACKER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
|
||||
)
|
||||
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
|
||||
listOf(
|
||||
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
|
||||
ConfigProtos.Config.DeviceConfig.Role.SENSOR,
|
||||
ConfigProtos.Config.DeviceConfig.Role.TRACKER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -64,10 +64,10 @@ import com.geeksville.mesh.repository.radio.MeshActivity
|
|||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.geeksville.mesh.service.MeshServiceNotifications
|
||||
import com.geeksville.mesh.service.ServiceAction
|
||||
import com.geeksville.mesh.ui.map.MAP_STYLE_ID
|
||||
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
||||
import com.geeksville.mesh.util.getShortDate
|
||||
import com.geeksville.mesh.util.positionToMeter
|
||||
import com.geeksville.mesh.util.toggleBooleanPreference
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -82,7 +82,6 @@ import kotlinx.coroutines.flow.first
|
|||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
|
|
@ -102,27 +101,27 @@ import kotlin.math.roundToInt
|
|||
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
|
||||
// name and if the result is 3 or more characters, use the first three characters. If not, just take
|
||||
// the first 3 characters of the original name.
|
||||
fun getInitials(nameIn: String): String {
|
||||
val nchars = 4
|
||||
val minchars = 2
|
||||
val name = nameIn.trim().withoutEmojis()
|
||||
fun getInitials(fullName: String): String {
|
||||
val maxInitialLength = 4
|
||||
val minWordCountForInitials = 2
|
||||
val name = fullName.trim().withoutEmojis()
|
||||
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
|
||||
val initials =
|
||||
when (words.size) {
|
||||
in 0 until minchars -> {
|
||||
val nm =
|
||||
in 0 until minWordCountForInitials -> {
|
||||
val nameWithoutVowels =
|
||||
if (name.isNotEmpty()) {
|
||||
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (nm.length >= nchars) nm else name
|
||||
if (nameWithoutVowels.length >= maxInitialLength) nameWithoutVowels else name
|
||||
}
|
||||
|
||||
else -> words.map { it.first() }.joinToString("")
|
||||
}
|
||||
return initials.take(nchars)
|
||||
return initials.take(maxInitialLength)
|
||||
}
|
||||
|
||||
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
|
||||
|
|
@ -161,7 +160,6 @@ data class NodesUiState(
|
|||
val includeUnknown: Boolean = false,
|
||||
val onlyOnline: Boolean = false,
|
||||
val onlyDirect: Boolean = false,
|
||||
val gpsFormat: Int = 0,
|
||||
val distanceUnits: Int = 0,
|
||||
val tempInFahrenheit: Boolean = false,
|
||||
val showDetails: Boolean = false,
|
||||
|
|
@ -185,7 +183,7 @@ data class Contact(
|
|||
val nodeColors: Pair<Int, Int>? = null,
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@Suppress("LongParameterList", "LargeClass", "UnusedPrivateProperty")
|
||||
@HiltViewModel
|
||||
class UIViewModel
|
||||
@Inject
|
||||
|
|
@ -193,7 +191,7 @@ constructor(
|
|||
private val app: Application,
|
||||
private val nodeDB: NodeRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
radioInterfaceService: RadioInterfaceService,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
|
|
@ -301,9 +299,6 @@ constructor(
|
|||
val meshService: IMeshService?
|
||||
get() = radioConfigRepository.meshService
|
||||
|
||||
val selectedBluetooth
|
||||
get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x'
|
||||
|
||||
private val _localConfig = MutableStateFlow<LocalConfig>(LocalConfig.getDefaultInstance())
|
||||
val localConfig: StateFlow<LocalConfig> = _localConfig
|
||||
val config
|
||||
|
|
@ -338,11 +333,6 @@ constructor(
|
|||
private val onlyOnline = MutableStateFlow(preferences.getBoolean("only-online", false))
|
||||
private val onlyDirect = MutableStateFlow(preferences.getBoolean("only-direct", false))
|
||||
|
||||
private val onlyFavorites = MutableStateFlow(preferences.getBoolean("only-favorites", false))
|
||||
private val showWaypointsOnMap = MutableStateFlow(preferences.getBoolean("show-waypoints-on-map", true))
|
||||
private val showPrecisionCircleOnMap =
|
||||
MutableStateFlow(preferences.getBoolean("show-precision-circle-on-map", true))
|
||||
|
||||
private val _showIgnored = MutableStateFlow(preferences.getBoolean("show-ignored", false))
|
||||
val showIgnored: StateFlow<Boolean> = _showIgnored
|
||||
|
||||
|
|
@ -351,6 +341,7 @@ constructor(
|
|||
|
||||
private val _hasShownNotPairedWarning =
|
||||
MutableStateFlow(preferences.getBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, false))
|
||||
|
||||
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
|
||||
|
||||
fun suppressNoPairedWarning() {
|
||||
|
|
@ -358,40 +349,22 @@ constructor(
|
|||
preferences.edit { putBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, true) }
|
||||
}
|
||||
|
||||
private fun toggleBooleanPreference(
|
||||
state: MutableStateFlow<Boolean>,
|
||||
key: String,
|
||||
onChanged: (Boolean) -> Unit = {},
|
||||
) {
|
||||
val newValue = !state.value
|
||||
state.value = newValue
|
||||
preferences.edit { putBoolean(key, newValue) }
|
||||
onChanged(newValue)
|
||||
}
|
||||
fun toggleShowIgnored() = preferences.toggleBooleanPreference(_showIgnored, "show-ignored")
|
||||
|
||||
fun toggleShowIgnored() = toggleBooleanPreference(_showIgnored, "show-ignored")
|
||||
|
||||
fun toggleShowQuickChat() = toggleBooleanPreference(_showQuickChat, "show-quick-chat")
|
||||
fun toggleShowQuickChat() = preferences.toggleBooleanPreference(_showQuickChat, "show-quick-chat")
|
||||
|
||||
fun setSortOption(sort: NodeSortOption) {
|
||||
nodeSortOption.value = sort
|
||||
preferences.edit { putInt("node-sort-option", sort.ordinal) }
|
||||
}
|
||||
|
||||
fun toggleShowDetails() = toggleBooleanPreference(showDetails, "show-details")
|
||||
fun toggleShowDetails() = preferences.toggleBooleanPreference(showDetails, "show-details")
|
||||
|
||||
fun toggleIncludeUnknown() = toggleBooleanPreference(includeUnknown, "include-unknown")
|
||||
fun toggleIncludeUnknown() = preferences.toggleBooleanPreference(includeUnknown, "include-unknown")
|
||||
|
||||
fun toggleOnlyOnline() = toggleBooleanPreference(onlyOnline, "only-online")
|
||||
fun toggleOnlyOnline() = preferences.toggleBooleanPreference(onlyOnline, "only-online")
|
||||
|
||||
fun toggleOnlyDirect() = toggleBooleanPreference(onlyDirect, "only-direct")
|
||||
|
||||
fun toggleOnlyFavorites() = toggleBooleanPreference(onlyFavorites, "only-favorites")
|
||||
|
||||
fun toggleShowWaypointsOnMap() = toggleBooleanPreference(showWaypointsOnMap, "show-waypoints-on-map")
|
||||
|
||||
fun toggleShowPrecisionCircleOnMap() =
|
||||
toggleBooleanPreference(showPrecisionCircleOnMap, "show-precision-circle-on-map")
|
||||
fun toggleOnlyDirect() = preferences.toggleBooleanPreference(onlyDirect, "only-direct")
|
||||
|
||||
data class NodeFilterState(
|
||||
val filterText: String,
|
||||
|
|
@ -425,7 +398,6 @@ constructor(
|
|||
includeUnknown = filterFlow.includeUnknown,
|
||||
onlyOnline = filterFlow.onlyOnline,
|
||||
onlyDirect = filterFlow.onlyDirect,
|
||||
gpsFormat = profile.config.display.gpsFormat.number,
|
||||
distanceUnits = profile.config.display.units.number,
|
||||
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
|
||||
showDetails = showDetails,
|
||||
|
|
@ -474,22 +446,6 @@ constructor(
|
|||
initialValue = 0,
|
||||
)
|
||||
|
||||
data class MapFilterState(val onlyFavorites: Boolean, val showWaypoints: Boolean, val showPrecisionCircle: Boolean)
|
||||
|
||||
val mapFilterStateFlow: StateFlow<MapFilterState> =
|
||||
combine(onlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap) {
|
||||
favoritesOnly,
|
||||
showWaypoints,
|
||||
showPrecisionCircle,
|
||||
->
|
||||
MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = MapFilterState(false, true, true),
|
||||
)
|
||||
|
||||
// hardware info about our local device (can be null)
|
||||
val myNodeInfo: StateFlow<MyNodeEntity?>
|
||||
get() = nodeDB.myNodeInfo
|
||||
|
|
@ -497,22 +453,15 @@ constructor(
|
|||
val ourNodeInfo: StateFlow<Node?>
|
||||
get() = nodeDB.ourNodeInfo
|
||||
|
||||
val nodesWithPosition
|
||||
get() = nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }
|
||||
|
||||
var mapStyleId: Int
|
||||
get() = preferences.getInt(MAP_STYLE_ID, 0)
|
||||
set(value) = preferences.edit { putInt(MAP_STYLE_ID, value) }
|
||||
|
||||
fun getNode(userId: String?) = nodeDB.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
fun getUser(userId: String?) = nodeDB.getUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
val snackbarState = SnackbarHostState()
|
||||
private val snackBarHostState = SnackbarHostState()
|
||||
|
||||
fun showSnackbar(text: Int) = showSnackbar(app.getString(text))
|
||||
fun showSnackBar(text: Int) = showSnackBar(app.getString(text))
|
||||
|
||||
fun showSnackbar(text: String) = viewModelScope.launch { snackbarState.showSnackbar(text) }
|
||||
fun showSnackBar(text: String) = viewModelScope.launch { snackBarHostState.showSnackbar(text) }
|
||||
|
||||
init {
|
||||
radioConfigRepository.errorMessage
|
||||
|
|
@ -615,15 +564,6 @@ constructor(
|
|||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val waypoints =
|
||||
packetRepository.getWaypoints().mapLatest { list ->
|
||||
list
|
||||
.associateBy { packet -> packet.data.waypoint!!.id }
|
||||
.filterValues {
|
||||
it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000
|
||||
}
|
||||
}
|
||||
|
||||
fun generatePacketId(): Int? {
|
||||
return try {
|
||||
meshService?.packetId
|
||||
|
|
@ -749,8 +689,6 @@ constructor(
|
|||
val connectionState
|
||||
get() = radioConfigRepository.connectionState
|
||||
|
||||
fun isConnected() = isConnectedStateFlow.value
|
||||
|
||||
val isConnectedStateFlow =
|
||||
radioConfigRepository.connectionState
|
||||
.map { it.isConnected() }
|
||||
|
|
@ -763,7 +701,7 @@ constructor(
|
|||
fun requestChannelUrl(url: Uri) = runCatching { _requestChannelSet.value = url.toChannelSet() }
|
||||
.onFailure { ex ->
|
||||
errormsg("Channel url error: ${ex.message}")
|
||||
showSnackbar(R.string.channel_invalid)
|
||||
showSnackBar(R.string.channel_invalid)
|
||||
}
|
||||
|
||||
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
|
||||
|
|
@ -921,6 +859,7 @@ constructor(
|
|||
|
||||
writeToUri(uri) { writer ->
|
||||
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
writer.appendLine(
|
||||
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"",
|
||||
|
|
@ -939,7 +878,9 @@ constructor(
|
|||
// If the packet contains position data then use it to update, if valid
|
||||
packet.position?.let { position ->
|
||||
positionToPos.invoke(position)?.let {
|
||||
nodePositions[proto.from.takeIf { it != 0 } ?: myNodeNum] = position
|
||||
nodePositions[
|
||||
proto.from.takeIf { it != 0 } ?: myNodeNum,
|
||||
] = position
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -972,9 +913,9 @@ constructor(
|
|||
""
|
||||
} else {
|
||||
positionToMeter(
|
||||
rxPosition!!, // Use rxPosition but only if rxPos was
|
||||
Position(rxPosition!!), // Use rxPosition but only if rxPos was
|
||||
// valid
|
||||
senderPosition!!, // Use senderPosition but only if
|
||||
Position(senderPosition!!), // Use senderPosition but only if
|
||||
// senderPos was valid
|
||||
)
|
||||
.roundToInt()
|
||||
|
|
@ -998,7 +939,8 @@ constructor(
|
|||
}
|
||||
|
||||
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
|
||||
// elevation,rx snr,distance,hop limit,payload
|
||||
// elevation,rx
|
||||
// snr,distance,hop limit,payload
|
||||
@Suppress("MaxLineLength")
|
||||
writer.appendLine(
|
||||
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
|
||||
|
|
|
|||
|
|
@ -1,212 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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 com.geeksville.mesh.model.map
|
||||
|
||||
import org.osmdroid.tileprovider.tilesource.ITileSource
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
|
||||
|
||||
class CustomTileSource {
|
||||
|
||||
companion object {
|
||||
val OPENWEATHER_RADAR = OnlineTileSourceAuth(
|
||||
"Open Weather Map", 1, 22, 256, ".png", arrayOf(
|
||||
"https://tile.openweathermap.org/map/"
|
||||
), "Openweathermap",
|
||||
TileSourcePolicy(
|
||||
4,
|
||||
TileSourcePolicy.FLAG_NO_BULK
|
||||
or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
),
|
||||
"precipitation",
|
||||
""
|
||||
)
|
||||
//
|
||||
// val RAIN_VIEWER = object : OnlineTileSourceBase(
|
||||
// "RainViewer", 1, 15, 256, ".png", arrayOf(
|
||||
// "https://tilecache.rainviewer.com/v2/coverage/"
|
||||
// ), "RainViewer",
|
||||
// TileSourcePolicy(
|
||||
// 4,
|
||||
// TileSourcePolicy.FLAG_NO_BULK
|
||||
// or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
// or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
// or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
// )
|
||||
// ) {
|
||||
// override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
// return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
// .toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
// + "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
// + mImageFilenameEnding)
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
private val ESRI_IMAGERY = object : OnlineTileSourceBase(
|
||||
"ESRI World Overview", 1, 20, 256, ".jpg", arrayOf(
|
||||
"https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"
|
||||
), "Esri, Maxar, Earthstar Geographics, and the GIS User Community",
|
||||
TileSourcePolicy(
|
||||
4,
|
||||
TileSourcePolicy.FLAG_NO_BULK
|
||||
or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
+ "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
+ mImageFilenameEnding)
|
||||
}
|
||||
}
|
||||
|
||||
private val ESRI_WORLD_TOPO = object : OnlineTileSourceBase(
|
||||
"ESRI World TOPO",
|
||||
1,
|
||||
20,
|
||||
256,
|
||||
".jpg",
|
||||
arrayOf(
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"
|
||||
),
|
||||
"Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ",
|
||||
TileSourcePolicy(
|
||||
4,
|
||||
TileSourcePolicy.FLAG_NO_BULK
|
||||
or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
+ "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
+ mImageFilenameEnding)
|
||||
}
|
||||
}
|
||||
private val USGS_HYDRO_CACHE = object : OnlineTileSourceBase(
|
||||
"USGS Hydro Cache",
|
||||
0,
|
||||
18,
|
||||
256,
|
||||
"",
|
||||
arrayOf(
|
||||
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"
|
||||
),
|
||||
"USGS",
|
||||
TileSourcePolicy(
|
||||
2,
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
+ "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
+ mImageFilenameEnding)
|
||||
}
|
||||
}
|
||||
private val USGS_SHADED_RELIEF = object : OnlineTileSourceBase(
|
||||
"USGS Shaded Relief Only",
|
||||
0,
|
||||
18,
|
||||
256,
|
||||
"",
|
||||
arrayOf(
|
||||
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/"
|
||||
),
|
||||
"USGS",
|
||||
TileSourcePolicy(
|
||||
2,
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
+ "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
+ mImageFilenameEnding)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WMS TILE SERVER
|
||||
* More research is required to get this to function correctly with overlays
|
||||
*/
|
||||
val NOAA_RADAR_WMS = NOAAWmsTileSource(
|
||||
"Recent Weather Radar",
|
||||
arrayOf("https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/radar_meteo_imagery_nexrad_time/MapServer/WmsServer?"),
|
||||
"1",
|
||||
"1.1.0",
|
||||
"",
|
||||
"EPSG%3A3857",
|
||||
"",
|
||||
"image/png"
|
||||
)
|
||||
|
||||
/**
|
||||
* ===============================================================================================
|
||||
*/
|
||||
|
||||
private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK
|
||||
private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO
|
||||
private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo
|
||||
private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT
|
||||
private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP
|
||||
val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE
|
||||
|
||||
/**
|
||||
* Source for each available [ITileSource] and their display names.
|
||||
*/
|
||||
val mTileSources: Map<ITileSource, String> = mapOf(
|
||||
MAPNIK to "OpenStreetMap",
|
||||
USGS_TOPO to "USGS TOPO",
|
||||
OPEN_TOPO to "Open TOPO",
|
||||
ESRI_WORLD_TOPO to "ESRI World TOPO",
|
||||
USGS_SAT to "USGS Satellite",
|
||||
ESRI_IMAGERY to "ESRI World Overview",
|
||||
)
|
||||
|
||||
fun getTileSource(index: Int): ITileSource {
|
||||
return mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE
|
||||
}
|
||||
|
||||
fun getTileSource(aName: String): ITileSource {
|
||||
for (tileSource: ITileSource in mTileSources.keys) {
|
||||
if (tileSource.name().equals(aName)) {
|
||||
return tileSource
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("No such tile source: $aName")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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 com.geeksville.mesh.model.map
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.view.MotionEvent
|
||||
import com.geeksville.mesh.android.dpToPx
|
||||
import com.geeksville.mesh.android.spToPx
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polygon
|
||||
|
||||
class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) {
|
||||
|
||||
companion object {
|
||||
private const val LABEL_CORNER_RADIUS_DP = 4f
|
||||
private const val LABEL_Y_OFFSET_DP = 34f
|
||||
private const val FONT_SIZE_SP = 14f
|
||||
private const val EMOJI_FONT_SIZE_SP = 20f
|
||||
}
|
||||
|
||||
private val labelYOffsetPx by lazy {
|
||||
mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100
|
||||
}
|
||||
|
||||
private val labelCornerRadiusPx by lazy {
|
||||
mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12
|
||||
}
|
||||
|
||||
private var nodeColor: Int = Color.GRAY
|
||||
fun setNodeColors(colors: Pair<Int, Int>) {
|
||||
nodeColor = colors.second
|
||||
}
|
||||
|
||||
private var precisionBits: Int? = null
|
||||
fun setPrecisionBits(bits: Int) {
|
||||
precisionBits = bits
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun getPrecisionMeters(): Double? {
|
||||
return when (precisionBits) {
|
||||
10 -> 23345.484932
|
||||
11 -> 11672.7369
|
||||
12 -> 5836.36288
|
||||
13 -> 2918.175876
|
||||
14 -> 1459.0823719999053
|
||||
15 -> 729.53562
|
||||
16 -> 364.7622
|
||||
17 -> 182.375556
|
||||
18 -> 91.182212
|
||||
19 -> 45.58554
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private var onLongClickListener: (() -> Boolean)? = null
|
||||
|
||||
fun setOnLongClickListener(listener: () -> Boolean) {
|
||||
onLongClickListener = listener
|
||||
}
|
||||
|
||||
private val mLabel = label
|
||||
private val mEmoji = emoji
|
||||
private val textPaint = Paint().apply {
|
||||
textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f
|
||||
color = Color.DKGRAY
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
private val emojiPaint = Paint().apply {
|
||||
textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private val bgPaint = Paint().apply { color = Color.WHITE }
|
||||
|
||||
private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF {
|
||||
val fontMetrics = textPaint.fontMetrics
|
||||
val halfTextLength = textPaint.measureText(text) / 2 + 3
|
||||
return RectF(
|
||||
(x - halfTextLength),
|
||||
(y + fontMetrics.top),
|
||||
(x + halfTextLength),
|
||||
(y + fontMetrics.bottom)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean {
|
||||
val touched = hitTest(event, mapView)
|
||||
if (touched && this.id != null) {
|
||||
return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView)
|
||||
}
|
||||
return super.onLongPress(event, mapView)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) {
|
||||
super.draw(c, osmv, false)
|
||||
val p = mPositionPixels
|
||||
val bgRect = getTextBackgroundSize(mLabel, p.x.toFloat(), (p.y - labelYOffsetPx.toFloat()))
|
||||
bgRect.inset(-8F, -2F)
|
||||
|
||||
if (mLabel.isNotEmpty()) {
|
||||
c.drawRoundRect(bgRect, labelCornerRadiusPx.toFloat(), labelCornerRadiusPx.toFloat(), bgPaint)
|
||||
c.drawText(mLabel, (p.x - 0F), (p.y - labelYOffsetPx.toFloat()), textPaint)
|
||||
}
|
||||
mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) }
|
||||
|
||||
getPrecisionMeters()?.let { radius ->
|
||||
val polygon = Polygon(osmv).apply {
|
||||
points = Polygon.pointsAsCircle(
|
||||
position,
|
||||
radius
|
||||
)
|
||||
fillPaint.apply {
|
||||
color = nodeColor
|
||||
alpha = 48
|
||||
}
|
||||
outlinePaint.apply {
|
||||
color = nodeColor
|
||||
alpha = 64
|
||||
}
|
||||
}
|
||||
polygon.draw(c, osmv, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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 com.geeksville.mesh.model.map
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.util.Log
|
||||
import org.osmdroid.api.IMapView
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
import kotlin.math.atan
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sinh
|
||||
|
||||
open class NOAAWmsTileSource(
|
||||
aName: String,
|
||||
aBaseUrl: Array<String>,
|
||||
layername: String,
|
||||
version: String,
|
||||
time: String?,
|
||||
srs: String,
|
||||
style: String?,
|
||||
format: String,
|
||||
) : OnlineTileSourceBase(
|
||||
aName, 0, 5, 256, "png", aBaseUrl, "", TileSourcePolicy(
|
||||
2,
|
||||
TileSourcePolicy.FLAG_NO_BULK
|
||||
or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
|
||||
// array indexes for array to hold bounding boxes.
|
||||
private val MINX = 0
|
||||
private val MAXX = 1
|
||||
private val MINY = 2
|
||||
private val MAXY = 3
|
||||
|
||||
// Web Mercator n/w corner of the map.
|
||||
private val TILE_ORIGIN = doubleArrayOf(-20037508.34789244, 20037508.34789244)
|
||||
|
||||
//array indexes for that data
|
||||
private val ORIG_X = 0
|
||||
private val ORIG_Y = 1 // "
|
||||
|
||||
// Size of square world map in meters, using WebMerc projection.
|
||||
private val MAP_SIZE = 20037508.34789244 * 2
|
||||
private var layer = ""
|
||||
private var version = "1.1.0"
|
||||
private var srs = "EPSG%3A3857" //used by geo server
|
||||
private var format = ""
|
||||
private var time = ""
|
||||
private var style: String? = null
|
||||
private var forceHttps = false
|
||||
private var forceHttp = false
|
||||
|
||||
init {
|
||||
Log.i(IMapView.LOGTAG, "WMS support is BETA. Please report any issues")
|
||||
layer = layername
|
||||
this.version = version
|
||||
this.srs = srs
|
||||
this.style = style
|
||||
this.format = format
|
||||
if (time != null) this.time = time
|
||||
}
|
||||
|
||||
// fun createFrom(endpoint: WMSEndpoint, layer: WMSLayer): WMSTileSource? {
|
||||
// var srs: String? = "EPSG:900913"
|
||||
// if (layer.srs.isNotEmpty()) {
|
||||
// srs = layer.srs[0]
|
||||
// }
|
||||
// return if (layer.styles.isEmpty()) {
|
||||
// WMSTileSource(
|
||||
// layer.name, arrayOf(endpoint.baseurl), layer.name,
|
||||
// endpoint.wmsVersion, srs, null, layer.pixelSize
|
||||
// )
|
||||
// } else WMSTileSource(
|
||||
// layer.name, arrayOf(endpoint.baseurl), layer.name,
|
||||
// endpoint.wmsVersion, srs, layer.styles[0], layer.pixelSize
|
||||
// )
|
||||
// }
|
||||
|
||||
|
||||
private fun tile2lon(x: Int, z: Int): Double {
|
||||
return x / 2.0.pow(z.toDouble()) * 360.0 - 180
|
||||
}
|
||||
|
||||
private fun tile2lat(y: Int, z: Int): Double {
|
||||
val n = Math.PI - 2.0 * Math.PI * y / 2.0.pow(z.toDouble())
|
||||
return Math.toDegrees(atan(sinh(n)))
|
||||
}
|
||||
|
||||
// Return a web Mercator bounding box given tile x/y indexes and a zoom
|
||||
// level.
|
||||
private fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray {
|
||||
val tileSize = MAP_SIZE / 2.0.pow(zoom.toDouble())
|
||||
val minx = TILE_ORIGIN[ORIG_X] + x * tileSize
|
||||
val maxx = TILE_ORIGIN[ORIG_X] + (x + 1) * tileSize
|
||||
val miny = TILE_ORIGIN[ORIG_Y] - (y + 1) * tileSize
|
||||
val maxy = TILE_ORIGIN[ORIG_Y] - y * tileSize
|
||||
val bbox = DoubleArray(4)
|
||||
bbox[MINX] = minx
|
||||
bbox[MINY] = miny
|
||||
bbox[MAXX] = maxx
|
||||
bbox[MAXY] = maxy
|
||||
return bbox
|
||||
}
|
||||
|
||||
fun isForceHttps(): Boolean {
|
||||
return forceHttps
|
||||
}
|
||||
|
||||
fun setForceHttps(forceHttps: Boolean) {
|
||||
this.forceHttps = forceHttps
|
||||
}
|
||||
|
||||
fun isForceHttp(): Boolean {
|
||||
return forceHttp
|
||||
}
|
||||
|
||||
fun setForceHttp(forceHttp: Boolean) {
|
||||
this.forceHttp = forceHttp
|
||||
}
|
||||
|
||||
override fun getTileURLString(pMapTileIndex: Long): String? {
|
||||
var baseUrl = baseUrl
|
||||
if (forceHttps) baseUrl = baseUrl.replace("http://", "https://")
|
||||
if (forceHttp) baseUrl = baseUrl.replace("https://", "http://")
|
||||
val sb = StringBuilder(baseUrl)
|
||||
if (!baseUrl.endsWith("&"))
|
||||
sb.append("service=WMS")
|
||||
sb.append("&request=GetMap")
|
||||
sb.append("&version=").append(version)
|
||||
sb.append("&layers=").append(layer)
|
||||
if (style != null) sb.append("&styles=").append(style)
|
||||
sb.append("&format=").append(format)
|
||||
sb.append("&transparent=true")
|
||||
sb.append("&height=").append(Resources.getSystem().displayMetrics.heightPixels)
|
||||
sb.append("&width=").append(Resources.getSystem().displayMetrics.widthPixels)
|
||||
sb.append("&srs=").append(srs)
|
||||
sb.append("&size=").append(getSize())
|
||||
sb.append("&bbox=")
|
||||
val bbox = getBoundingBox(
|
||||
MapTileIndex.getX(pMapTileIndex),
|
||||
MapTileIndex.getY(pMapTileIndex),
|
||||
MapTileIndex.getZoom(pMapTileIndex)
|
||||
)
|
||||
sb.append(bbox[MINX]).append(",")
|
||||
sb.append(bbox[MINY]).append(",")
|
||||
sb.append(bbox[MAXX]).append(",")
|
||||
sb.append(bbox[MAXY])
|
||||
Log.i(IMapView.LOGTAG, sb.toString())
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun getSize(): String {
|
||||
val height = Resources.getSystem().displayMetrics.heightPixels
|
||||
val width = Resources.getSystem().displayMetrics.widthPixels
|
||||
return "$width,$height"
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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 com.geeksville.mesh.model.map
|
||||
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
|
||||
open class OnlineTileSourceAuth(
|
||||
aName: String,
|
||||
aZoomLevel: Int,
|
||||
aZoomMaxLevel: Int,
|
||||
aTileSizePixels: Int,
|
||||
aImageFileNameEnding: String,
|
||||
aBaseUrl: Array<String>,
|
||||
pCopyright: String,
|
||||
tileSourcePolicy: TileSourcePolicy,
|
||||
layerName: String?,
|
||||
apiKey: String
|
||||
) :
|
||||
OnlineTileSourceBase(
|
||||
aName,
|
||||
aZoomLevel,
|
||||
aZoomMaxLevel,
|
||||
aTileSizePixels,
|
||||
aImageFileNameEnding,
|
||||
aBaseUrl,
|
||||
pCopyright,
|
||||
tileSourcePolicy
|
||||
|
||||
) {
|
||||
private var layerName = ""
|
||||
private var apiKey = ""
|
||||
|
||||
init {
|
||||
if (layerName != null) {
|
||||
this.layerName = layerName
|
||||
}
|
||||
this.apiKey = apiKey
|
||||
|
||||
}
|
||||
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return "$baseUrl$layerName/" + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
.toString()) + mImageFilenameEnding + "?appId=$apiKey"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
package com.geeksville.mesh.model.map.clustering;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Point;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import org.osmdroid.api.IGeoPoint;
|
||||
import org.osmdroid.bonuspack.kml.KmlFeature;
|
||||
import org.osmdroid.util.BoundingBox;
|
||||
import org.osmdroid.util.GeoPoint;
|
||||
import org.osmdroid.views.MapView;
|
||||
import org.osmdroid.views.overlay.Overlay;
|
||||
import com.geeksville.mesh.model.map.MarkerWithLabel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.ListIterator;
|
||||
|
||||
/**
|
||||
* An overlay allowing to perform markers clustering.
|
||||
* Usage: put your markers inside with add(Marker), and add the MarkerClusterer to the map overlays.
|
||||
* Depending on the zoom level, markers will be displayed separately, or grouped as a single Marker. <br/>
|
||||
*
|
||||
* This abstract class provides the framework. Sub-classes have to implement the clustering algorithm,
|
||||
* and the rendering of a cluster.
|
||||
*
|
||||
* @author M.Kergall
|
||||
*
|
||||
*/
|
||||
public abstract class MarkerClusterer extends Overlay {
|
||||
|
||||
/** impossible value for zoom level, to force clustering */
|
||||
protected static final int FORCE_CLUSTERING = -1;
|
||||
|
||||
protected ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
|
||||
protected Point mPoint = new Point();
|
||||
protected ArrayList<StaticCluster> mClusters = new ArrayList<StaticCluster>();
|
||||
protected int mLastZoomLevel;
|
||||
protected Bitmap mClusterIcon;
|
||||
protected String mName, mDescription;
|
||||
|
||||
// abstract methods:
|
||||
|
||||
/** clustering algorithm */
|
||||
public abstract ArrayList<StaticCluster> clusterer(MapView mapView);
|
||||
/** Build the marker for a cluster. */
|
||||
public abstract MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView);
|
||||
/** build clusters markers to be used at next draw */
|
||||
public abstract void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView);
|
||||
|
||||
public MarkerClusterer() {
|
||||
super();
|
||||
mLastZoomLevel = FORCE_CLUSTERING;
|
||||
}
|
||||
|
||||
public void setName(String name){
|
||||
mName = name;
|
||||
}
|
||||
|
||||
public String getName(){
|
||||
return mName;
|
||||
}
|
||||
|
||||
public void setDescription(String description){
|
||||
mDescription = description;
|
||||
}
|
||||
|
||||
public String getDescription(){
|
||||
return mDescription;
|
||||
}
|
||||
|
||||
/** Set the cluster icon to be drawn when a cluster contains more than 1 marker.
|
||||
* If not set, default will be the default osmdroid marker icon (which is really inappropriate as a cluster icon). */
|
||||
public void setIcon(Bitmap icon){
|
||||
mClusterIcon = icon;
|
||||
}
|
||||
|
||||
/** Add the Marker.
|
||||
* Important: Markers added in a MarkerClusterer should not be added in the map overlays. */
|
||||
public void add(MarkerWithLabel marker){
|
||||
mItems.add(marker);
|
||||
}
|
||||
|
||||
/** Force a rebuild of clusters at next draw, even without a zooming action.
|
||||
* Should be done when you changed the content of a MarkerClusterer. */
|
||||
public void invalidate(){
|
||||
mLastZoomLevel = FORCE_CLUSTERING;
|
||||
}
|
||||
|
||||
/** @return the Marker at id (starting at 0) */
|
||||
public MarkerWithLabel getItem(int id){
|
||||
return mItems.get(id);
|
||||
}
|
||||
|
||||
/** @return the list of Markers. */
|
||||
public ArrayList<MarkerWithLabel> getItems(){
|
||||
return mItems;
|
||||
}
|
||||
|
||||
protected void hideInfoWindows(){
|
||||
for (MarkerWithLabel m : mItems){
|
||||
if (m.isInfoWindowShown())
|
||||
m.closeInfoWindow();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void draw(Canvas canvas, MapView mapView, boolean shadow) {
|
||||
if (shadow)
|
||||
return;
|
||||
//if zoom has changed and mapView is now stable, rebuild clusters:
|
||||
int zoomLevel = mapView.getZoomLevel();
|
||||
if (zoomLevel != mLastZoomLevel && !mapView.isAnimating()){
|
||||
hideInfoWindows();
|
||||
mClusters = clusterer(mapView);
|
||||
renderer(mClusters, canvas, mapView);
|
||||
mLastZoomLevel = zoomLevel;
|
||||
}
|
||||
|
||||
for (StaticCluster cluster:mClusters){
|
||||
MarkerWithLabel marker = cluster.getMarker();
|
||||
marker.draw(canvas, mapView, false);
|
||||
}
|
||||
}
|
||||
|
||||
public Iterable<StaticCluster> reversedClusters() {
|
||||
return new Iterable<StaticCluster>() {
|
||||
@Override
|
||||
public Iterator<StaticCluster> iterator() {
|
||||
final ListIterator<StaticCluster> i = mClusters.listIterator(mClusters.size());
|
||||
return new Iterator<StaticCluster>() {
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return i.hasPrevious();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StaticCluster next() {
|
||||
return i.previous();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove() {
|
||||
i.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onSingleTapConfirmed(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onLongPress(final MotionEvent event, final MapView mapView) {
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onLongPress(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onTouchEvent(final MotionEvent event, final MapView mapView) {
|
||||
for (StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onTouchEvent(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onDoubleTap(final MotionEvent event, final MapView mapView) {
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onDoubleTap(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public BoundingBox getBounds(){
|
||||
if (mItems.size() == 0)
|
||||
return null;
|
||||
double minLat = Double.MAX_VALUE;
|
||||
double minLon = Double.MAX_VALUE;
|
||||
double maxLat = -Double.MAX_VALUE;
|
||||
double maxLon = -Double.MAX_VALUE;
|
||||
for (final MarkerWithLabel item : mItems) {
|
||||
final double latitude = item.getPosition().getLatitude();
|
||||
final double longitude = item.getPosition().getLongitude();
|
||||
minLat = Math.min(minLat, latitude);
|
||||
minLon = Math.min(minLon, longitude);
|
||||
maxLat = Math.max(maxLat, latitude);
|
||||
maxLon = Math.max(maxLon, longitude);
|
||||
}
|
||||
return new BoundingBox(maxLat, maxLon, minLat, minLon);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
package com.geeksville.mesh.model.map.clustering;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import org.osmdroid.bonuspack.R;
|
||||
import org.osmdroid.util.BoundingBox;
|
||||
import org.osmdroid.util.GeoPoint;
|
||||
import org.osmdroid.views.MapView;
|
||||
import com.geeksville.mesh.model.map.MarkerWithLabel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
|
||||
/**
|
||||
* Radius-based Clustering algorithm:
|
||||
* create a cluster using the first point from the cloned list.
|
||||
* All points that are found within the neighborhood are added to this cluster.
|
||||
* Then all the neighbors and the main point are removed from the list of points.
|
||||
* It continues until the list is empty.
|
||||
*
|
||||
* Largely inspired from GridMarkerClusterer by M.Kergall
|
||||
*
|
||||
* @author sidorovroman92@gmail.com
|
||||
*/
|
||||
|
||||
public class RadiusMarkerClusterer extends MarkerClusterer {
|
||||
|
||||
protected int mMaxClusteringZoomLevel = 7;
|
||||
protected int mRadiusInPixels = 100;
|
||||
protected double mRadiusInMeters;
|
||||
protected Paint mTextPaint;
|
||||
private ArrayList<MarkerWithLabel> mClonedMarkers;
|
||||
protected boolean mAnimated;
|
||||
int mDensityDpi;
|
||||
|
||||
/** cluster icon anchor */
|
||||
public float mAnchorU = MarkerWithLabel.ANCHOR_CENTER, mAnchorV = MarkerWithLabel.ANCHOR_CENTER;
|
||||
/** anchor point to draw the number of markers inside the cluster icon */
|
||||
public float mTextAnchorU = MarkerWithLabel.ANCHOR_CENTER, mTextAnchorV = MarkerWithLabel.ANCHOR_CENTER;
|
||||
|
||||
public RadiusMarkerClusterer(Context ctx) {
|
||||
super();
|
||||
mTextPaint = new Paint();
|
||||
mTextPaint.setColor(Color.WHITE);
|
||||
mTextPaint.setTextSize(15 * ctx.getResources().getDisplayMetrics().density);
|
||||
mTextPaint.setFakeBoldText(true);
|
||||
mTextPaint.setTextAlign(Paint.Align.CENTER);
|
||||
mTextPaint.setAntiAlias(true);
|
||||
Drawable clusterIconD = ctx.getResources().getDrawable(R.drawable.marker_cluster);
|
||||
Bitmap clusterIcon = ((BitmapDrawable) clusterIconD).getBitmap();
|
||||
setIcon(clusterIcon);
|
||||
mAnimated = true;
|
||||
mDensityDpi = ctx.getResources().getDisplayMetrics().densityDpi;
|
||||
}
|
||||
|
||||
/** If you want to change the default text paint (color, size, font) */
|
||||
public Paint getTextPaint(){
|
||||
return mTextPaint;
|
||||
}
|
||||
|
||||
/** Set the radius of clustering in pixels. Default is 100px. */
|
||||
public void setRadius(int radius){
|
||||
mRadiusInPixels = radius;
|
||||
}
|
||||
|
||||
/** Set max zoom level with clustering. When zoom is higher or equal to this level, clustering is disabled.
|
||||
* You can put a high value to disable this feature. */
|
||||
public void setMaxClusteringZoomLevel(int zoom){
|
||||
mMaxClusteringZoomLevel = zoom;
|
||||
}
|
||||
|
||||
/** Radius-Based clustering algorithm */
|
||||
@Override public ArrayList<StaticCluster> clusterer(MapView mapView) {
|
||||
|
||||
ArrayList<StaticCluster> clusters = new ArrayList<StaticCluster>();
|
||||
convertRadiusToMeters(mapView);
|
||||
|
||||
mClonedMarkers = new ArrayList<MarkerWithLabel>(mItems); //shallow copy
|
||||
while (!mClonedMarkers.isEmpty()) {
|
||||
MarkerWithLabel m = mClonedMarkers.get(0);
|
||||
StaticCluster cluster = createCluster(m, mapView);
|
||||
clusters.add(cluster);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
private StaticCluster createCluster(MarkerWithLabel m, MapView mapView) {
|
||||
GeoPoint clusterPosition = m.getPosition();
|
||||
|
||||
StaticCluster cluster = new StaticCluster(clusterPosition);
|
||||
cluster.add(m);
|
||||
|
||||
mClonedMarkers.remove(m);
|
||||
|
||||
if (mapView.getZoomLevel() > mMaxClusteringZoomLevel) {
|
||||
//above max level => block clustering:
|
||||
return cluster;
|
||||
}
|
||||
|
||||
Iterator<MarkerWithLabel> it = mClonedMarkers.iterator();
|
||||
while (it.hasNext()) {
|
||||
MarkerWithLabel neighbour = it.next();
|
||||
double distance = clusterPosition.distanceToAsDouble(neighbour.getPosition());
|
||||
if (distance <= mRadiusInMeters) {
|
||||
cluster.add(neighbour);
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return cluster;
|
||||
}
|
||||
|
||||
@Override public MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView) {
|
||||
MarkerWithLabel m = new MarkerWithLabel(mapView, "", null);
|
||||
m.setPosition(cluster.getPosition());
|
||||
m.setInfoWindow(null);
|
||||
m.setAnchor(mAnchorU, mAnchorV);
|
||||
|
||||
Bitmap finalIcon = Bitmap.createBitmap(mClusterIcon.getScaledWidth(mDensityDpi),
|
||||
mClusterIcon.getScaledHeight(mDensityDpi), mClusterIcon.getConfig());
|
||||
Canvas iconCanvas = new Canvas(finalIcon);
|
||||
iconCanvas.drawBitmap(mClusterIcon, 0, 0, null);
|
||||
String text = "" + cluster.getSize();
|
||||
int textHeight = (int) (mTextPaint.descent() + mTextPaint.ascent());
|
||||
iconCanvas.drawText(text,
|
||||
mTextAnchorU * finalIcon.getWidth(),
|
||||
mTextAnchorV * finalIcon.getHeight() - textHeight / 2,
|
||||
mTextPaint);
|
||||
m.setIcon(new BitmapDrawable(mapView.getContext().getResources(), finalIcon));
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
@Override public void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView) {
|
||||
for (StaticCluster cluster : clusters) {
|
||||
if (cluster.getSize() == 1) {
|
||||
//cluster has only 1 marker => use it as it is:
|
||||
cluster.setMarker(cluster.getItem(0));
|
||||
} else {
|
||||
//only draw 1 Marker at Cluster center, displaying number of Markers contained
|
||||
MarkerWithLabel m = buildClusterMarker(cluster, mapView);
|
||||
cluster.setMarker(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void convertRadiusToMeters(MapView mapView) {
|
||||
|
||||
Rect mScreenRect = mapView.getIntrinsicScreenRect(null);
|
||||
|
||||
int screenWidth = mScreenRect.right - mScreenRect.left;
|
||||
int screenHeight = mScreenRect.bottom - mScreenRect.top;
|
||||
|
||||
BoundingBox bb = mapView.getBoundingBox();
|
||||
|
||||
double diagonalInMeters = bb.getDiagonalLengthInMeters();
|
||||
double diagonalInPixels = Math.sqrt(screenWidth * screenWidth + screenHeight * screenHeight);
|
||||
double metersInPixel = diagonalInMeters / diagonalInPixels;
|
||||
|
||||
mRadiusInMeters = mRadiusInPixels * metersInPixel;
|
||||
}
|
||||
|
||||
public void setAnimation(boolean animate){
|
||||
mAnimated = animate;
|
||||
}
|
||||
|
||||
public void zoomOnCluster(MapView mapView, StaticCluster cluster){
|
||||
BoundingBox bb = cluster.getBoundingBox();
|
||||
if (bb.getLatNorth()!=bb.getLatSouth() || bb.getLonEast()!=bb.getLonWest()) {
|
||||
bb = bb.increaseByScale(2.3f);
|
||||
mapView.zoomToBoundingBox(bb, true);
|
||||
} else //all points exactly at the same place:
|
||||
mapView.setExpectedCenter(bb.getCenterWithDateLine());
|
||||
}
|
||||
|
||||
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) {
|
||||
if (mAnimated && cluster.getSize() > 1)
|
||||
zoomOnCluster(mapView, cluster);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package com.geeksville.mesh.model.map.clustering;
|
||||
|
||||
import org.osmdroid.util.BoundingBox;
|
||||
import org.osmdroid.util.GeoPoint;
|
||||
import com.geeksville.mesh.model.map.MarkerWithLabel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Cluster of Markers.
|
||||
* @author M.Kergall
|
||||
*/
|
||||
public class StaticCluster {
|
||||
protected final ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
|
||||
protected GeoPoint mCenter;
|
||||
protected MarkerWithLabel mMarker;
|
||||
|
||||
public StaticCluster(GeoPoint center) {
|
||||
mCenter = center;
|
||||
}
|
||||
|
||||
public void setPosition(GeoPoint center){
|
||||
mCenter = center;
|
||||
}
|
||||
|
||||
public GeoPoint getPosition() {
|
||||
return mCenter;
|
||||
}
|
||||
|
||||
public int getSize() {
|
||||
return mItems.size();
|
||||
}
|
||||
|
||||
public MarkerWithLabel getItem(int index) {
|
||||
return mItems.get(index);
|
||||
}
|
||||
|
||||
public boolean add(MarkerWithLabel t) {
|
||||
return mItems.add(t);
|
||||
}
|
||||
|
||||
/** set the Marker to be displayed for this cluster */
|
||||
public void setMarker(MarkerWithLabel marker){
|
||||
mMarker = marker;
|
||||
}
|
||||
|
||||
/** @return the Marker to be displayed for this cluster */
|
||||
public MarkerWithLabel getMarker(){
|
||||
return mMarker;
|
||||
}
|
||||
|
||||
public BoundingBox getBoundingBox(){
|
||||
if (getSize()==0)
|
||||
return null;
|
||||
GeoPoint p = getItem(0).getPosition();
|
||||
BoundingBox bb = new BoundingBox(p.getLatitude(), p.getLongitude(), p.getLatitude(), p.getLongitude());
|
||||
for (int i=1; i<getSize(); i++) {
|
||||
p = getItem(i).getPosition();
|
||||
double minLat = Math.min(bb.getLatSouth(), p.getLatitude());
|
||||
double minLon = Math.min(bb.getLonWest(), p.getLongitude());
|
||||
double maxLat = Math.max(bb.getLatNorth(), p.getLatitude());
|
||||
double maxLon = Math.max(bb.getLonEast(), p.getLongitude());
|
||||
bb.set(maxLat, maxLon, minLat, minLon);
|
||||
}
|
||||
return bb;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue