Give the V2X live map its own screen, and a traffic light per SPATEM
The map was a third view mode inside the V2X Monitor's topic pane, below the use case alert panel and the DENM/CAM TX cards. On a phone that left it about a third of the display tall, which is not enough to see where anything is relative to anything else - the one thing a map is for. It is now its own destination, V2xMapScreen on route v2x_map, reached from a map button in that screen's header. The button sits in the header rather than the view-mode row so it is also reachable from the message detail pane and does not move as the available modes change with the selected hardware. The status bar and bottom nav are hidden on this route; the screen carries its own floating back button, and system back still works. Both hardware paths get the same screen: everything drawn comes from CamUseCaseRepository, which already merges the CiT One's MQTT feed and the ESP32-C5's serial feed into one set of flows. With the map gone from the toggle row, the row offers a single choice on the ESP32-C5 path - there is no broker there and `topics` is always empty - so it is hidden entirely in that mode. SPATEM markers. Hazards already drew as a warning triangle; signalised intersections did not draw at all. They now draw as a traffic light with one lamp lit. Two things are worth knowing, because neither is forced by the data: - SPATEM carries signal state but no geometry, which is MAPEM's job and MAPEM is not decoded. The only position available is the sending RSU's own CAM, so the light is drawn there, and that RSU is drawn once - as the light, not as a CAM pin with a light on top of it. An intersection whose sender has not been heard over CAM cannot be placed; the map says how many rather than dropping them silently. - Which lamp lights follows the rule DashboardScreen's SignalCard already uses, the signal group changing soonest speaking for the intersection, so the same intersection reads the same way in both places instead of inventing a second convention. Four drawables rather than one tinted at runtime: setTint recolours every path in a vector, so a single shared asset would turn the whole light one flat colour and stop it reading as a traffic light. Marker reuse. Every incoming message recomposes the map, and the update block cleared the overlay list and rebuilt every Marker, decoding and mutating a fresh Drawable per marker - at up to 10 Hz per station. It also called animateTo(own) on every update, restarting the pan animation before it could finish. Drawables are now loaded once per alert level and phase and shared (osmdroid sets the icon's bounds on each draw, so one instance across markers is safe), Markers are cached by key, and the overlay list is only reordered, which moves references without allocating. Following uses setCenter, keeping animateTo for the one move worth seeing: the rider asking for follow back. Follow-own now hands over to the rider on the first touch and returns via the location button, which lights up while following. Before this the map could not be panned at all while traffic was flowing, since the next CAM dragged the viewport back. Also on the map view: tiles scaled to DPI, the floating +/- buttons off (they sit where the thumb lands and duplicate pinch), a zoom range, and more tile threads so a pan that exposes a screenful of new tiles is not served two at a time. Compiles and the unit tests pass. None of it has been seen with live traffic; TODO.md lists the on-device checks under "Waiting on hardware", including which of the HAW RSUs send CAM alongside SPATEM.
This commit is contained in:
@@ -5,6 +5,29 @@ Engineering to-do list. The reviewer-facing open items live in
|
|||||||
|
|
||||||
## Waiting on hardware
|
## Waiting on hardware
|
||||||
|
|
||||||
|
### On-device check of the full-screen V2X live map (added 2026-09-15)
|
||||||
|
|
||||||
|
The live map moved out of the V2X Monitor's view-mode row into its own full-screen destination
|
||||||
|
(`V2xMapScreen`, route `v2x_map`), reached from the map button in that screen's header. Markers are
|
||||||
|
now cached and reused across updates instead of being rebuilt on every incoming message, and
|
||||||
|
SPATEM intersections are drawn as traffic lights at the position of the RSU's own CAM. All of that
|
||||||
|
compiles and the unit tests pass, but none of it has been seen with live traffic.
|
||||||
|
|
||||||
|
Needs: the phone with the app, plus a CAM/DENM/SPATEM source - either the CiT One, or the OBU
|
||||||
|
ESP32-C5 with a second board or a real RSU transmitting.
|
||||||
|
|
||||||
|
- [ ] Both hardware modes: tap the map button, confirm the map fills the screen (no status bar, no
|
||||||
|
bottom nav) and the back button returns to the V2X Monitor.
|
||||||
|
- [ ] Panning stays smooth while CAMs are arriving - this is what the marker reuse is for. Compare
|
||||||
|
against the old behaviour if it still judders.
|
||||||
|
- [ ] Touching the map stops it recentring; the location FAB resumes follow and lights up.
|
||||||
|
- [ ] A DENM shows the warning triangle, and a SPATEM intersection shows a traffic light with the
|
||||||
|
lamp matching the Dashboard's SignalCard for the same intersection.
|
||||||
|
- [ ] Near a real RSU: confirm the RSU is drawn once, as a traffic light, not as a CAM pin with a
|
||||||
|
light on top of it. If the RSU sends SPATEM but no CAM, the "signals not shown" note should
|
||||||
|
appear instead - worth knowing which of the two the HAW RSUs actually do.
|
||||||
|
|
||||||
|
|
||||||
### Over-the-air check of the GN lifetime fix (added 2026-09-11)
|
### Over-the-air check of the GN lifetime fix (added 2026-09-11)
|
||||||
|
|
||||||
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
|
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.core.view.WindowCompat
|
|||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||||
@@ -40,6 +41,7 @@ import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen
|
|||||||
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
|
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.SensorScreen
|
import com.hawhamburg.micr0bu.ui.screens.SensorScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
|
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
|
||||||
|
import com.hawhamburg.micr0bu.ui.screens.V2xMapScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.MapScreen
|
import com.hawhamburg.micr0bu.ui.screens.MapScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
|
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
|
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
|
||||||
@@ -104,6 +106,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
// The V2X live map is a full-bleed destination: the app's own chrome would eat a
|
||||||
|
// third of the display on the one screen whose entire job is showing where things
|
||||||
|
// are relative to each other. It carries its own floating back button, and system
|
||||||
|
// back still works, so nothing becomes unreachable.
|
||||||
|
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val isFullBleed = currentBackStackEntry?.destination?.route == Screen.V2xMap.route
|
||||||
|
|
||||||
val locationLauncher = rememberLauncherForActivityResult(
|
val locationLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
) { permissions ->
|
) { permissions ->
|
||||||
@@ -132,14 +141,16 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
StatusTopBar(
|
if (!isFullBleed) {
|
||||||
state = state,
|
StatusTopBar(
|
||||||
mqttConnectionState = mqttConnectionState,
|
state = state,
|
||||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
mqttConnectionState = mqttConnectionState,
|
||||||
usbSerialState = usbSerialState,
|
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||||
)
|
usbSerialState = usbSerialState,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
bottomBar = { BottomNavBar(navController) },
|
bottomBar = { if (!isFullBleed) BottomNavBar(navController) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -253,7 +264,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable(Screen.MqttViewer.route) {
|
composable(Screen.MqttViewer.route) {
|
||||||
MqttTopicViewerScreen(viewModel = mqttViewModel)
|
MqttTopicViewerScreen(
|
||||||
|
viewModel = mqttViewModel,
|
||||||
|
onOpenMap = { navController.navigate(Screen.V2xMap.route) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.V2xMap.route) {
|
||||||
|
// Activity-scoped instance, like Connection below: a hiltViewModel()
|
||||||
|
// here would be scoped to this NavBackStackEntry and torn down on the
|
||||||
|
// way back out, taking the shared transport with it.
|
||||||
|
V2xMapScreen(
|
||||||
|
viewModel = mqttViewModel,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Settings.route) {
|
composable(Screen.Settings.route) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ sealed class Screen(val route: String, val labelRes: Int) {
|
|||||||
data object Connection : Screen("connection", R.string.nav_connection)
|
data object Connection : Screen("connection", R.string.nav_connection)
|
||||||
data object Map : Screen("map", R.string.map_title)
|
data object Map : Screen("map", R.string.map_title)
|
||||||
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
|
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
|
||||||
|
/** Full-screen V2X live map, opened from the V2X Monitor's map button. */
|
||||||
|
data object V2xMap : Screen("v2x_map", R.string.v2x_map_title)
|
||||||
|
|
||||||
// Phase A — Trip Recording
|
// Phase A — Trip Recording
|
||||||
data object TripHistory : Screen("trip_history", R.string.nav_trips)
|
data object TripHistory : Screen("trip_history", R.string.nav_trips)
|
||||||
@@ -83,6 +85,7 @@ private fun Screen.ownsRoute(route: String?): Boolean {
|
|||||||
route == Screen.Map.route ||
|
route == Screen.Map.route ||
|
||||||
route == Screen.Sensors.route
|
route == Screen.Sensors.route
|
||||||
Screen.Record -> route == Screen.Log.route
|
Screen.Record -> route == Screen.Log.route
|
||||||
|
Screen.MqttViewer -> route == Screen.V2xMap.route
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
|||||||
import androidx.compose.material.icons.filled.Circle
|
import androidx.compose.material.icons.filled.Circle
|
||||||
import androidx.compose.material.icons.filled.Link
|
import androidx.compose.material.icons.filled.Link
|
||||||
import androidx.compose.material.icons.filled.LinkOff
|
import androidx.compose.material.icons.filled.LinkOff
|
||||||
|
import androidx.compose.material.icons.filled.Map
|
||||||
import androidx.compose.material.icons.filled.NotificationsActive
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
import androidx.compose.material.icons.filled.VerticalAlignBottom
|
import androidx.compose.material.icons.filled.VerticalAlignBottom
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
@@ -87,11 +88,15 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST), the raw MQTT topic list
|
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST) or the raw MQTT topic list
|
||||||
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path), or the V2X live map
|
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path).
|
||||||
* (MAP, Section 13).
|
*
|
||||||
|
* The live map used to be a third mode here. It is now its own full-screen destination
|
||||||
|
* ([V2xMapScreen]), reached from the map button in this screen's header: sharing the screen with
|
||||||
|
* the alert panel and the TX cards left the map about a third of a phone display tall, which is
|
||||||
|
* not enough to see where anything is relative to anything else.
|
||||||
*/
|
*/
|
||||||
private enum class TopicViewMode { LIST, TOPICS, MAP }
|
private enum class TopicViewMode { LIST, TOPICS }
|
||||||
|
|
||||||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||||
|
|
||||||
@@ -116,6 +121,7 @@ private val WarningRedBg = Color(0xFF3A0A0A)
|
|||||||
@Composable
|
@Composable
|
||||||
fun MqttTopicViewerScreen(
|
fun MqttTopicViewerScreen(
|
||||||
viewModel: MqttViewModel = hiltViewModel(),
|
viewModel: MqttViewModel = hiltViewModel(),
|
||||||
|
onOpenMap: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val connectionState by viewModel.connectionState.collectAsState()
|
val connectionState by viewModel.connectionState.collectAsState()
|
||||||
val topicMessages by viewModel.topicMessages.collectAsState()
|
val topicMessages by viewModel.topicMessages.collectAsState()
|
||||||
@@ -193,6 +199,17 @@ fun MqttTopicViewerScreen(
|
|||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full-screen live map. In the header rather than in the view-mode row below, so it
|
||||||
|
// is reachable from the message detail pane too and does not move around as the
|
||||||
|
// available view modes change with the selected hardware.
|
||||||
|
IconButton(onClick = onOpenMap) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Map,
|
||||||
|
contentDescription = stringResource(R.string.v2x_map_title),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ConnectionChip(effectiveState)
|
ConnectionChip(effectiveState)
|
||||||
Spacer(Modifier.width(2.dp))
|
Spacer(Modifier.width(2.dp))
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -335,49 +352,38 @@ private fun TopicListPane(
|
|||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── List / Topics / Map toggle ────────────────────────────────────────────────────
|
// ── List / Topics toggle ──────────────────────────────────────────────
|
||||||
// Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
|
// Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
|
||||||
// the road users and hazards, not the transport that carried them. The raw MQTT topic
|
// the road users and hazards, not the transport that carried them. The raw MQTT topic
|
||||||
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
|
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
|
||||||
// to", not instead of, the topic list). It is hidden on the ESP32-C5 path, where there is
|
// to", not instead of, the topic list).
|
||||||
// no broker and `topics` is permanently empty.
|
//
|
||||||
Row(
|
// The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
// permanently empty, and a toggle offering a single choice is just noise.
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
if (!isEsp32) {
|
||||||
) {
|
Row(
|
||||||
ViewModeButton(
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
label = stringResource(R.string.mqtt_view_list),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
selected = viewMode == TopicViewMode.LIST,
|
) {
|
||||||
) { viewMode = TopicViewMode.LIST }
|
ViewModeButton(
|
||||||
|
label = stringResource(R.string.mqtt_view_list),
|
||||||
|
selected = viewMode == TopicViewMode.LIST,
|
||||||
|
) { viewMode = TopicViewMode.LIST }
|
||||||
|
|
||||||
if (!isEsp32) {
|
|
||||||
ViewModeButton(
|
ViewModeButton(
|
||||||
label = stringResource(R.string.mqtt_view_topics),
|
label = stringResource(R.string.mqtt_view_topics),
|
||||||
selected = viewMode == TopicViewMode.TOPICS,
|
selected = viewMode == TopicViewMode.TOPICS,
|
||||||
) { viewMode = TopicViewMode.TOPICS }
|
) { viewMode = TopicViewMode.TOPICS }
|
||||||
}
|
}
|
||||||
|
|
||||||
ViewModeButton(
|
|
||||||
label = stringResource(R.string.mqtt_view_map),
|
|
||||||
selected = viewMode == TopicViewMode.MAP,
|
|
||||||
) { viewMode = TopicViewMode.MAP }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Decoded traffic / raw topics / live map ───────────────────────────
|
// ── Decoded traffic / raw topics ──────────────────────────────────────
|
||||||
// TOPICS can still be the saved selection from a CiT One session after switching hardware
|
// TOPICS can still be the saved selection from a CiT One session after switching hardware
|
||||||
// to the ESP32-C5, where that button no longer exists - fall back to the decoded list
|
// to the ESP32-C5, where that button no longer exists - fall back to the decoded list
|
||||||
// rather than stranding the user on a pane they can't navigate away from.
|
// rather than stranding the user on a pane they can't navigate away from.
|
||||||
val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
|
val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
|
||||||
|
|
||||||
if (shownMode == TopicViewMode.MAP) {
|
if (shownMode == TopicViewMode.LIST) {
|
||||||
V2xLiveMapView(
|
|
||||||
own = ownCamPosition,
|
|
||||||
remotes = remoteCamPositions,
|
|
||||||
alerts = useCaseAlerts,
|
|
||||||
denms = denmEvents,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
} else if (shownMode == TopicViewMode.LIST) {
|
|
||||||
ReceivedCamPane(
|
ReceivedCamPane(
|
||||||
own = ownCamPosition,
|
own = ownCamPosition,
|
||||||
remotes = remoteCamPositions,
|
remotes = remoteCamPositions,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package com.hawhamburg.micr0bu.ui.screens
|
package com.hawhamburg.micr0bu.ui.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.view.MotionEvent
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.GpsOff
|
import androidx.compose.material.icons.filled.GpsOff
|
||||||
@@ -30,25 +31,37 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import com.hawhamburg.micr0bu.R
|
import com.hawhamburg.micr0bu.R
|
||||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||||
|
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.views.CustomZoomButtonsController
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.Marker
|
import org.osmdroid.views.overlay.Marker
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus
|
* V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the
|
||||||
* every currently-tracked remote road user's last-known CAM position, in addition to (not
|
* ego bike's own position, every currently-tracked remote road user's last-known CAM position,
|
||||||
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as
|
* every live hazard (DENM) and every signalised intersection heard over SPATEM.
|
||||||
* [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here
|
|
||||||
* always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]
|
|
||||||
* currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc).
|
|
||||||
*
|
*
|
||||||
* Remote markers are colored by that station's most severe active alert level, if any, so a
|
* Marker vocabulary, one shape per message type so the map reads without a legend:
|
||||||
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same
|
* - CAM — teardrop pin, tinted by that station's most severe active alert level
|
||||||
* severity coloring already used by [UseCaseAlertPanel].
|
* - DENM — hazard warning triangle
|
||||||
|
* - SPATEM — traffic light, with the lamp for the intersection's leading phase lit
|
||||||
|
*
|
||||||
|
* Unlike [MapScreen] this has no phone-GNSS-only fallback: [own] always reflects whichever ego
|
||||||
|
* source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] currently trusts (obu_gnss /
|
||||||
|
* phone GNSS / CAM-topic-own — see that class's KDoc).
|
||||||
|
*
|
||||||
|
* **Markers are reused across updates, not rebuilt.** CAMs arrive at up to 10 Hz per station, and
|
||||||
|
* every arrival recomposes this view; the previous version cleared the overlay list and rebuilt
|
||||||
|
* every Marker — decoding and mutating a fresh Drawable per marker per update — which is what
|
||||||
|
* made panning stutter under live traffic. Drawables are now loaded once per level/phase and
|
||||||
|
* shared (osmdroid sets the icon's bounds on each draw, so sharing one instance across markers is
|
||||||
|
* safe), and Marker objects are cached by key. The overlay list is still reordered each update,
|
||||||
|
* which costs nothing: it moves existing references, it does not allocate.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun V2xLiveMapView(
|
fun V2xLiveMapView(
|
||||||
@@ -56,6 +69,9 @@ fun V2xLiveMapView(
|
|||||||
remotes: Map<Long, Cam>,
|
remotes: Map<Long, Cam>,
|
||||||
alerts: List<UseCaseAlert>,
|
alerts: List<UseCaseAlert>,
|
||||||
denms: List<DenmEvent> = emptyList(),
|
denms: List<DenmEvent> = emptyList(),
|
||||||
|
spats: List<SpatIntersection> = emptyList(),
|
||||||
|
followOwn: Boolean = true,
|
||||||
|
onUserPanned: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -71,8 +87,17 @@ fun V2xLiveMapView(
|
|||||||
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
|
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loaded once and shared by every marker that needs them. mutate() on the remote pin is still
|
||||||
|
// essential: without it all four tinted copies would share one ConstantState and the last
|
||||||
|
// tint applied would recolour every pin on the map.
|
||||||
|
val icons = remember(context) { MapIcons(context) }
|
||||||
|
val markers = remember { mutableMapOf<String, Marker>() }
|
||||||
|
|
||||||
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
// Tracks whether the last update already recentred for this follow session, so re-enabling
|
||||||
|
// follow animates once instead of fighting the rider's own panning on every frame.
|
||||||
|
val wasFollowing = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
@@ -89,106 +114,236 @@ fun V2xLiveMapView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
AndroidView(
|
||||||
Text(
|
factory = { ctx ->
|
||||||
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
|
initOsmForV2xMap(ctx)
|
||||||
style = MaterialTheme.typography.labelMedium,
|
MapView(ctx).apply {
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
setMultiTouchControls(true)
|
||||||
)
|
// Raster tiles are authored for ~160 dpi; without this they are upscaled by the
|
||||||
Spacer(Modifier.height(4.dp))
|
// display density and labels come out soft on a modern phone.
|
||||||
|
isTilesScaledToDpi = true
|
||||||
AndroidView(
|
// The floating +/- buttons sit exactly where the rider's thumb lands and
|
||||||
factory = { ctx ->
|
// duplicate pinch-zoom. Pinch and double-tap still work.
|
||||||
initOsmForV2xMap(ctx)
|
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||||
MapView(ctx).apply {
|
setMinZoomLevel(4.0)
|
||||||
setTileSource(TileSourceFactory.MAPNIK)
|
setMaxZoomLevel(20.0)
|
||||||
setMultiTouchControls(true)
|
controller.setZoom(17.0)
|
||||||
controller.setZoom(17.0)
|
controller.setCenter(ownGeoPoint)
|
||||||
controller.setCenter(ownGeoPoint)
|
// Any touch means the rider is driving the map; follow-own hands over to them
|
||||||
mapViewRef.value = this
|
// until they ask for it back. false: the MapView's own gesture handling still
|
||||||
|
// runs, this only observes.
|
||||||
|
setOnTouchListener { _, event ->
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_DOWN) onUserPanned()
|
||||||
|
false
|
||||||
}
|
}
|
||||||
},
|
mapViewRef.value = this
|
||||||
update = { mv ->
|
}
|
||||||
mv.overlays.clear()
|
},
|
||||||
|
update = { mv ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
// Own position: a centred "you are here" dot, not a pin. Own position is a fact
|
// Intersections we can actually place: SPATEM carries signal state but no geometry
|
||||||
// about the viewer rather than one of the tracked objects, and when both used
|
// (that is MAPEM's job), so the only position available is the sending RSU's own CAM.
|
||||||
// osmdroid's identical default pin the two were indistinguishable at a glance.
|
val locatedSpats = spats.mapNotNull { spat ->
|
||||||
mv.overlays.add(
|
remotes[spat.stationId]?.let { rsu -> spat to rsu }
|
||||||
Marker(mv).apply {
|
}
|
||||||
position = ownGeoPoint
|
// An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it:
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
// two markers on one point, the lower one unreachable.
|
||||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet()
|
||||||
title = context.getString(R.string.v2x_map_own_label)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
remotes.forEach { (stationId, cam) ->
|
val live = mutableSetOf<String>()
|
||||||
val level = alertByStation[stationId]
|
|
||||||
val label = when (level) {
|
// Own position: a centred "you are here" dot, not a pin. Own position is a fact about
|
||||||
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
// the viewer rather than one of the tracked objects, and when both used osmdroid's
|
||||||
AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId)
|
// identical default pin the two were indistinguishable at a glance.
|
||||||
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
markers.marker(mv, KEY_OWN, live).apply {
|
||||||
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
position = ownGeoPoint
|
||||||
}
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
// Teardrop pin anchored at its tip, tinted by severity. Now that these are
|
icon = icons.own
|
||||||
// custom drawables, per-instance tinting is possible - severity no longer
|
title = context.getString(R.string.v2x_map_own_label)
|
||||||
// depends on tapping the marker to read its label. mutate() is essential:
|
}
|
||||||
// without it every marker shares one ConstantState and the last tint applied
|
|
||||||
// would recolour all of them.
|
remotes.forEach { (stationId, cam) ->
|
||||||
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
if (stationId in spatStationIds) return@forEach
|
||||||
?.mutate()
|
val level = alertByStation[stationId]
|
||||||
?.apply { setTint(level.toMarkerColor()) }
|
val label = when (level) {
|
||||||
mv.overlays.add(
|
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
||||||
Marker(mv).apply {
|
AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId)
|
||||||
position = GeoPoint(cam.latitude, cam.longitude)
|
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
||||||
icon = pin
|
}
|
||||||
title = label
|
// Teardrop pin anchored at its tip, tinted by severity, so severity no longer
|
||||||
}
|
// depends on tapping the marker to read its label.
|
||||||
|
markers.marker(mv, "$KEY_CAM$stationId", live).apply {
|
||||||
|
position = GeoPoint(cam.latitude, cam.longitude)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
|
icon = icons.remotePin(level)
|
||||||
|
title = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hazards and signals are added after the vehicle pins, so they draw on top: a hazard
|
||||||
|
// hidden behind a CAM pin defeats the point of showing it.
|
||||||
|
denms.forEach { denm ->
|
||||||
|
markers.marker(mv, "$KEY_DENM${denm.dedupKey}", live).apply {
|
||||||
|
position = GeoPoint(denm.latitude, denm.longitude)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
|
icon = icons.denm
|
||||||
|
title = denm.causeCode?.let {
|
||||||
|
context.getString(
|
||||||
|
R.string.v2x_map_denm_labeled,
|
||||||
|
it,
|
||||||
|
denm.subCauseCode ?: 0,
|
||||||
|
denm.stationId,
|
||||||
|
)
|
||||||
|
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locatedSpats.forEach { (spat, rsu) ->
|
||||||
|
val phase = spat.leadingPhase(now)
|
||||||
|
markers.marker(mv, "$KEY_SPAT${spat.key}", live).apply {
|
||||||
|
position = GeoPoint(rsu.latitude, rsu.longitude)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
|
icon = icons.signal(phase)
|
||||||
|
title = context.getString(
|
||||||
|
R.string.v2x_spat_rx_title, spat.state.key, spat.stationId,
|
||||||
)
|
)
|
||||||
|
snippet = spat.state.movements.joinToString(" · ") { movement ->
|
||||||
|
val seconds = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||||
|
context.getString(R.string.v2x_spat_group, movement.signalGroup) +
|
||||||
|
(seconds?.let { " " + context.getString(R.string.v2x_spat_countdown, it) } ?: "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard
|
// Drop markers for stations, hazards and intersections that have expired, then rebuild
|
||||||
// hidden behind a CAM pin defeats the point of showing it.
|
// the overlay list in draw order from the cached Markers. Reordering moves references;
|
||||||
denms.forEach { denm ->
|
// nothing here allocates a Marker or decodes a Drawable.
|
||||||
mv.overlays.add(
|
markers.keys.retainAll { key ->
|
||||||
Marker(mv).apply {
|
(key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() }
|
||||||
position = GeoPoint(denm.latitude, denm.longitude)
|
}
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
mv.overlays.clear()
|
||||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
markers.entries
|
||||||
title = denm.causeCode?.let {
|
.sortedBy { (key, _) -> key.drawOrder() }
|
||||||
context.getString(
|
.forEach { (_, marker) -> mv.overlays.add(marker) }
|
||||||
R.string.v2x_map_denm_labeled,
|
|
||||||
it,
|
|
||||||
denm.subCauseCode ?: 0,
|
|
||||||
denm.stationId,
|
|
||||||
)
|
|
||||||
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
mv.controller.animateTo(ownGeoPoint)
|
// setCenter, not animateTo: an animation restarted on every CAM never finishes, which
|
||||||
mv.invalidate()
|
// is exactly the judder this used to show under live traffic. The one animated move is
|
||||||
},
|
// the rider re-enabling follow, where the travel is worth seeing.
|
||||||
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
if (followOwn) {
|
||||||
// MapViewRepository holds a null MapView, so constructing a Marker against it throws
|
if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint)
|
||||||
// NullPointerException from deep inside InfoWindow's constructor.
|
else mv.controller.animateTo(ownGeoPoint)
|
||||||
//
|
}
|
||||||
// This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect
|
wasFollowing.value = followOwn
|
||||||
// is keyed on the lifecycle owner and disposes independently of this AndroidView, so
|
|
||||||
// the update block above could still run against an already-detached MapView and
|
mv.invalidate()
|
||||||
// rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving,
|
},
|
||||||
// because every incoming message recomposes this view and there are far more updates
|
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
||||||
// to land in that window than there used to be.
|
// MapViewRepository holds a null MapView, so constructing a Marker against it throws
|
||||||
//
|
// NullPointerException from deep inside InfoWindow's constructor.
|
||||||
// onRelease is the callback that actually means "this View is gone": Compose
|
//
|
||||||
// guarantees no further update after it.
|
// This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect
|
||||||
onRelease = { it.onDetach() },
|
// is keyed on the lifecycle owner and disposes independently of this AndroidView, so
|
||||||
modifier = Modifier.fillMaxSize(),
|
// the update block above could still run against an already-detached MapView and
|
||||||
)
|
// rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving,
|
||||||
|
// because every incoming message recomposes this view and there are far more updates
|
||||||
|
// to land in that window than there used to be.
|
||||||
|
//
|
||||||
|
// onRelease is the callback that actually means "this View is gone": Compose
|
||||||
|
// guarantees no further update after it.
|
||||||
|
onRelease = {
|
||||||
|
markers.clear()
|
||||||
|
it.onDetach()
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marker cache ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private const val KEY_OWN = "own"
|
||||||
|
private const val KEY_CAM = "cam:"
|
||||||
|
private const val KEY_DENM = "denm:"
|
||||||
|
private const val KEY_SPAT = "spat:"
|
||||||
|
|
||||||
|
/** Draw order: own dot at the bottom, then vehicles, with hazards and signals on top. */
|
||||||
|
private fun String.drawOrder(): Int = when {
|
||||||
|
this == KEY_OWN -> 0
|
||||||
|
startsWith(KEY_CAM) -> 1
|
||||||
|
startsWith(KEY_DENM) -> 2
|
||||||
|
else -> 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cached [Marker] for [key], created against [mv] on first use, recording the key in [live]
|
||||||
|
* so the caller can drop whatever it did not ask for this update.
|
||||||
|
*/
|
||||||
|
private fun MutableMap<String, Marker>.marker(
|
||||||
|
mv: MapView,
|
||||||
|
key: String,
|
||||||
|
live: MutableSet<String>,
|
||||||
|
): Marker {
|
||||||
|
live += key
|
||||||
|
return getOrPut(key) { Marker(mv) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker artwork, loaded once per composition rather than per update.
|
||||||
|
*
|
||||||
|
* The remote pin is drawn white and tinted per severity here; [mutate] is what keeps the four
|
||||||
|
* tinted copies independent, since without it they would share one ConstantState and the last
|
||||||
|
* tint applied would recolour all of them.
|
||||||
|
*/
|
||||||
|
private class MapIcons(context: Context) {
|
||||||
|
val own: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||||
|
val denm: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||||
|
|
||||||
|
private val pins: Map<AlertLevel?, Drawable?> =
|
||||||
|
(listOf(null) + AlertLevel.entries).associateWith { level ->
|
||||||
|
ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
||||||
|
?.mutate()
|
||||||
|
?.apply { setTint(level.toMarkerColor()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val signals: Map<SignalLamp, Drawable?> = SignalLamp.entries.associateWith { lamp ->
|
||||||
|
ContextCompat.getDrawable(context, lamp.drawableRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remotePin(level: AlertLevel?): Drawable? = pins[level]
|
||||||
|
fun signal(lamp: SignalLamp): Drawable? = signals[lamp]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Signal phase → lamp ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Which lamp of the traffic-light marker is lit. */
|
||||||
|
private enum class SignalLamp(val drawableRes: Int) {
|
||||||
|
RED(R.drawable.ic_map_spat_red),
|
||||||
|
AMBER(R.drawable.ic_map_spat_amber),
|
||||||
|
GREEN(R.drawable.ic_map_spat_green),
|
||||||
|
DARK(R.drawable.ic_map_spat_dark),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lamp to light for this intersection.
|
||||||
|
*
|
||||||
|
* Without MAPEM there is no lane geometry, so there is no way to know which of an intersection's
|
||||||
|
* signal groups applies to the rider's own approach. This follows the rule the Dashboard's
|
||||||
|
* SignalCard already uses — the group changing soonest speaks for the intersection — so the same
|
||||||
|
* intersection reads the same way in both places rather than inventing a second convention.
|
||||||
|
*/
|
||||||
|
private fun SpatIntersection.leadingPhase(nowMs: Long): SignalLamp {
|
||||||
|
val leading = state.movements.minByOrNull { movement ->
|
||||||
|
movement.current?.secondsUntil(nowMs)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
|
||||||
|
}
|
||||||
|
val phase = leading?.current?.phase
|
||||||
|
return when {
|
||||||
|
phase == null -> SignalLamp.DARK
|
||||||
|
phase.isGo -> SignalLamp.GREEN
|
||||||
|
phase.isStop -> SignalLamp.RED
|
||||||
|
phase.isTransition -> SignalLamp.AMBER
|
||||||
|
else -> SignalLamp.DARK // UNAVAILABLE / DARK / caution
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,5 +383,10 @@ private fun initOsmForV2xMap(context: Context) {
|
|||||||
Configuration.getInstance().apply {
|
Configuration.getInstance().apply {
|
||||||
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
|
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
|
||||||
userAgentValue = context.packageName
|
userAgentValue = context.packageName
|
||||||
|
// Panning off the edge of the cache is what makes a raster map feel slow: the default
|
||||||
|
// 600 MB cap is plenty, but the default 2 download threads are not when a pan exposes a
|
||||||
|
// screenful of new tiles at once.
|
||||||
|
tileDownloadThreads = 6.toShort()
|
||||||
|
tileFileSystemThreads = 6.toShort()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.hawhamburg.micr0bu.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.MyLocation
|
||||||
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Traffic
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hawhamburg.micr0bu.R
|
||||||
|
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen V2X live map — the map and nothing else, reached from the map button on the V2X
|
||||||
|
* Monitor screen.
|
||||||
|
*
|
||||||
|
* Identical on both hardware paths. Everything drawn here comes from
|
||||||
|
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which already merges the CiT One's MQTT
|
||||||
|
* feed and the ESP32-C5's serial feed into one set of flows, so this screen never has to know
|
||||||
|
* which OBU is connected.
|
||||||
|
*
|
||||||
|
* The chrome is deliberately minimal and floats over the map rather than boxing it in: a back
|
||||||
|
* button, a live count per message type, and a recentre button. The counts double as the map's
|
||||||
|
* legend — each one carries the same icon family as the marker it counts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun V2xMapScreen(
|
||||||
|
viewModel: MqttViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||||
|
// Road users from the detection engine PLUS roadside units, which it deliberately does not
|
||||||
|
// track - an RSU is what carries the traffic lights below.
|
||||||
|
val stations by viewModel.stationsInRange.collectAsState()
|
||||||
|
val alerts by viewModel.useCaseAlerts.collectAsState()
|
||||||
|
val denms by viewModel.denmEvents.collectAsState()
|
||||||
|
val spats by viewModel.spatIntersections.collectAsState()
|
||||||
|
|
||||||
|
// Follow is on until the rider touches the map, and comes back when they ask for it. Without
|
||||||
|
// the hand-over, every incoming CAM would drag the viewport back to the ego position and the
|
||||||
|
// map could not be panned at all while traffic is flowing.
|
||||||
|
var followOwn by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
// SPATEM carries no geometry of its own, so an intersection can only be placed if its RSU has
|
||||||
|
// also been heard over CAM. Saying so is better than silently dropping it: "the map shows two
|
||||||
|
// of the three lights I can see in the list" is otherwise an unexplained discrepancy.
|
||||||
|
val unlocatedSpats = spats.count { it.stationId !in stations.keys }
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
V2xLiveMapView(
|
||||||
|
own = ownCamPosition,
|
||||||
|
remotes = stations,
|
||||||
|
alerts = alerts,
|
||||||
|
denms = denms,
|
||||||
|
spats = spats,
|
||||||
|
followOwn = followOwn,
|
||||||
|
onUserPanned = { followOwn = false },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Floating header: back + live counts, which double as the legend ──
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
MapChrome {
|
||||||
|
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.v2x_map_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
MapChrome {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
MapCount(
|
||||||
|
icon = Icons.Default.Place,
|
||||||
|
tint = CamPinBlue,
|
||||||
|
count = stations.size,
|
||||||
|
label = stringResource(R.string.v2x_map_legend_cam),
|
||||||
|
)
|
||||||
|
MapCount(
|
||||||
|
icon = Icons.Default.Warning,
|
||||||
|
tint = HazardAmber,
|
||||||
|
count = denms.size,
|
||||||
|
label = stringResource(R.string.v2x_map_legend_denm),
|
||||||
|
)
|
||||||
|
MapCount(
|
||||||
|
icon = Icons.Default.Traffic,
|
||||||
|
tint = SignalGreenDot,
|
||||||
|
count = spats.size,
|
||||||
|
label = stringResource(R.string.v2x_map_legend_spat),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unlocatedSpats > 0) {
|
||||||
|
MapChrome(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.v2x_map_spat_unlocated, unlocatedSpats),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recentre: lit while following, so the button also reports which mode the map is in.
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { followOwn = true },
|
||||||
|
containerColor = if (followOwn) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.MyLocation,
|
||||||
|
contentDescription = stringResource(R.string.v2x_map_follow),
|
||||||
|
tint = if (followOwn) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A translucent pill for anything floating over the map, so chrome stays readable over tiles. */
|
||||||
|
@Composable
|
||||||
|
private fun MapChrome(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
shadowElevation = 2.dp,
|
||||||
|
modifier = modifier,
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapCount(icon: ImageVector, tint: Color, count: Int, label: String) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(16.dp))
|
||||||
|
Text(
|
||||||
|
text = count.toString(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend tints, matching the marker artwork rather than the theme: these name the drawables on
|
||||||
|
// the map, so they must not shift with light/dark mode the way theme colours do.
|
||||||
|
private val CamPinBlue = Color(0xFF78909C)
|
||||||
|
private val HazardAmber = Color(0xFFFFC107)
|
||||||
|
private val SignalGreenDot = Color(0xFF4CAF50)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!--
|
||||||
|
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||||
|
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||||
|
|
||||||
|
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||||
|
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||||
|
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||||
|
|
||||||
|
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||||
|
than covering it.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="30dp"
|
||||||
|
android:height="30dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||||
|
|
||||||
|
<!-- Housing. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF263238"
|
||||||
|
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||||
|
|
||||||
|
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF5A2220"
|
||||||
|
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFC107"
|
||||||
|
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF1E4D2B"
|
||||||
|
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!--
|
||||||
|
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||||
|
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||||
|
|
||||||
|
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||||
|
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||||
|
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||||
|
|
||||||
|
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||||
|
than covering it.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="30dp"
|
||||||
|
android:height="30dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||||
|
|
||||||
|
<!-- Housing. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF263238"
|
||||||
|
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||||
|
|
||||||
|
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF5A2220"
|
||||||
|
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF4A2E1C"
|
||||||
|
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF1E4D2B"
|
||||||
|
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!--
|
||||||
|
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||||
|
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||||
|
|
||||||
|
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||||
|
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||||
|
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||||
|
|
||||||
|
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||||
|
than covering it.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="30dp"
|
||||||
|
android:height="30dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||||
|
|
||||||
|
<!-- Housing. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF263238"
|
||||||
|
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||||
|
|
||||||
|
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF5A2220"
|
||||||
|
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF4A2E1C"
|
||||||
|
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF4CAF50"
|
||||||
|
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!--
|
||||||
|
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||||
|
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||||
|
|
||||||
|
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||||
|
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||||
|
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||||
|
|
||||||
|
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||||
|
than covering it.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="30dp"
|
||||||
|
android:height="30dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||||
|
|
||||||
|
<!-- Housing. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF263238"
|
||||||
|
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||||
|
|
||||||
|
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFF5252"
|
||||||
|
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF4A2E1C"
|
||||||
|
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF1E4D2B"
|
||||||
|
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||||
|
</vector>
|
||||||
@@ -142,12 +142,18 @@
|
|||||||
<string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
|
<string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
|
||||||
<string name="map_title">Standortkarte</string>
|
<string name="map_title">Standortkarte</string>
|
||||||
<string name="map_location_label">Aktueller Standort</string>
|
<string name="map_location_label">Aktueller Standort</string>
|
||||||
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
|
|
||||||
<string name="v2x_map_own_label">Eigen (Ego)</string>
|
<string name="v2x_map_own_label">Eigen (Ego)</string>
|
||||||
<string name="v2x_map_remote_plain">Extern #%1$d</string>
|
<string name="v2x_map_remote_plain">Extern #%1$d</string>
|
||||||
<string name="v2x_map_remote_info">Extern #%1$d · Info</string>
|
<string name="v2x_map_remote_info">Extern #%1$d · Info</string>
|
||||||
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
|
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
|
||||||
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
|
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
|
||||||
|
<string name="v2x_map_title">V2X-Live-Karte</string>
|
||||||
|
<string name="v2x_map_back">Zurück</string>
|
||||||
|
<string name="v2x_map_follow">Auf eigene Position zentrieren</string>
|
||||||
|
<string name="v2x_map_legend_cam">Verkehrsteilnehmer (CAM)</string>
|
||||||
|
<string name="v2x_map_legend_denm">Gefahren (DENM)</string>
|
||||||
|
<string name="v2x_map_legend_spat">Signale (SPATEM)</string>
|
||||||
|
<string name="v2x_map_spat_unlocated">%1$d Signal(e) nicht dargestellt - Senderposition unbekannt</string>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<string name="settings_title">Einstellungen</string>
|
<string name="settings_title">Einstellungen</string>
|
||||||
@@ -197,7 +203,6 @@
|
|||||||
<string name="mqtt_no_topics">Noch keine Nachrichten</string>
|
<string name="mqtt_no_topics">Noch keine Nachrichten</string>
|
||||||
<string name="mqtt_view_list">Liste</string>
|
<string name="mqtt_view_list">Liste</string>
|
||||||
<string name="mqtt_view_topics">Topics</string>
|
<string name="mqtt_view_topics">Topics</string>
|
||||||
<string name="mqtt_view_map">Karte</string>
|
|
||||||
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
|
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
|
||||||
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
|
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
|
||||||
|
|
||||||
|
|||||||
@@ -143,12 +143,18 @@
|
|||||||
<string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
|
<string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
|
||||||
<string name="map_title">Location Map</string>
|
<string name="map_title">Location Map</string>
|
||||||
<string name="map_location_label">Current Location</string>
|
<string name="map_location_label">Current Location</string>
|
||||||
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
|
|
||||||
<string name="v2x_map_own_label">Own (ego)</string>
|
<string name="v2x_map_own_label">Own (ego)</string>
|
||||||
<string name="v2x_map_remote_plain">Remote #%1$d</string>
|
<string name="v2x_map_remote_plain">Remote #%1$d</string>
|
||||||
<string name="v2x_map_remote_info">Remote #%1$d · Info</string>
|
<string name="v2x_map_remote_info">Remote #%1$d · Info</string>
|
||||||
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
|
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
|
||||||
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
|
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
|
||||||
|
<string name="v2x_map_title">V2X Live Map</string>
|
||||||
|
<string name="v2x_map_back">Back</string>
|
||||||
|
<string name="v2x_map_follow">Centre on own position</string>
|
||||||
|
<string name="v2x_map_legend_cam">Road users (CAM)</string>
|
||||||
|
<string name="v2x_map_legend_denm">Hazards (DENM)</string>
|
||||||
|
<string name="v2x_map_legend_spat">Signals (SPATEM)</string>
|
||||||
|
<string name="v2x_map_spat_unlocated">%1$d signal(s) not shown - sender position unknown</string>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
@@ -198,7 +204,6 @@
|
|||||||
<string name="mqtt_no_topics">No messages yet</string>
|
<string name="mqtt_no_topics">No messages yet</string>
|
||||||
<string name="mqtt_view_list">List</string>
|
<string name="mqtt_view_list">List</string>
|
||||||
<string name="mqtt_view_topics">Topics</string>
|
<string name="mqtt_view_topics">Topics</string>
|
||||||
<string name="mqtt_view_map">Map</string>
|
|
||||||
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
|
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
|
||||||
<string name="mqtt_no_messages">No messages on this topic yet</string>
|
<string name="mqtt_no_messages">No messages on this topic yet</string>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user