From 01204a2c225f87579a890936dfd48409766e8db8 Mon Sep 17 00:00:00 2001 From: Ashin Walpola Date: Tue, 15 Sep 2026 17:23:01 +0200 Subject: [PATCH] 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. --- TODO.md | 23 ++ .../com/hawhamburg/micr0bu/MainActivity.kt | 39 +- .../micr0bu/ui/navigation/AppNavigation.kt | 3 + .../ui/screens/MqttTopicViewerScreen.kt | 68 ++-- .../micr0bu/ui/screens/V2xLiveMapView.kt | 366 +++++++++++++----- .../micr0bu/ui/screens/V2xMapScreen.kt | 203 ++++++++++ .../main/res/drawable/ic_map_spat_amber.xml | 38 ++ .../main/res/drawable/ic_map_spat_dark.xml | 38 ++ .../main/res/drawable/ic_map_spat_green.xml | 38 ++ app/src/main/res/drawable/ic_map_spat_red.xml | 38 ++ app/src/main/res/values-de/strings.xml | 9 +- app/src/main/res/values/strings.xml | 9 +- 12 files changed, 726 insertions(+), 146 deletions(-) create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xMapScreen.kt create mode 100644 app/src/main/res/drawable/ic_map_spat_amber.xml create mode 100644 app/src/main/res/drawable/ic_map_spat_dark.xml create mode 100644 app/src/main/res/drawable/ic_map_spat_green.xml create mode 100644 app/src/main/res/drawable/ic_map_spat_red.xml diff --git a/TODO.md b/TODO.md index 566940a..87fcc55 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,29 @@ Engineering to-do list. The reviewer-facing open items live in ## 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) `geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt index 1727eb0..77f491b 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -26,6 +26,7 @@ import androidx.core.view.WindowCompat import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument 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.SensorScreen 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.TripHistoryScreen import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen @@ -104,6 +106,13 @@ class MainActivity : AppCompatActivity() { } 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( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> @@ -132,14 +141,16 @@ class MainActivity : AppCompatActivity() { Scaffold( topBar = { - StatusTopBar( - state = state, - mqttConnectionState = mqttConnectionState, - isEsp32 = obuHardware == ObuHardware.ESP32_C5, - usbSerialState = usbSerialState, - ) + if (!isFullBleed) { + StatusTopBar( + state = state, + mqttConnectionState = mqttConnectionState, + isEsp32 = obuHardware == ObuHardware.ESP32_C5, + usbSerialState = usbSerialState, + ) + } }, - bottomBar = { BottomNavBar(navController) }, + bottomBar = { if (!isFullBleed) BottomNavBar(navController) }, ) { innerPadding -> NavHost( navController = navController, @@ -253,7 +264,19 @@ class MainActivity : AppCompatActivity() { } 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) { SettingsScreen( diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt index c44f205..36bc42c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt @@ -34,6 +34,8 @@ sealed class Screen(val route: String, val labelRes: Int) { data object Connection : Screen("connection", R.string.nav_connection) data object Map : Screen("map", R.string.map_title) 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 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.Sensors.route Screen.Record -> route == Screen.Log.route + Screen.MqttViewer -> route == Screen.V2xMap.route else -> false } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt index cf7e036..97586c2 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -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.Link 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.VerticalAlignBottom import androidx.compose.material.icons.filled.Warning @@ -87,11 +88,15 @@ import java.util.Date import java.util.Locale /** - * View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST), the raw MQTT topic list - * (TOPICS, CiT One only - there is no broker on the ESP32-C5 path), or the V2X live map - * (MAP, Section 13). + * 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). + * + * 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) @@ -116,6 +121,7 @@ private val WarningRedBg = Color(0xFF3A0A0A) @Composable fun MqttTopicViewerScreen( viewModel: MqttViewModel = hiltViewModel(), + onOpenMap: () -> Unit = {}, ) { val connectionState by viewModel.connectionState.collectAsState() val topicMessages by viewModel.topicMessages.collectAsState() @@ -193,6 +199,17 @@ fun MqttTopicViewerScreen( 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) Spacer(Modifier.width(2.dp)) IconButton( @@ -335,49 +352,38 @@ private fun TopicListPane( 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 // 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 - // to", not instead of, the topic list). It is hidden on the ESP32-C5 path, where there is - // no broker and `topics` is permanently empty. - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - ViewModeButton( - label = stringResource(R.string.mqtt_view_list), - selected = viewMode == TopicViewMode.LIST, - ) { viewMode = TopicViewMode.LIST } + // to", not instead of, the topic list). + // + // The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is + // permanently empty, and a toggle offering a single choice is just noise. + if (!isEsp32) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ViewModeButton( + label = stringResource(R.string.mqtt_view_list), + selected = viewMode == TopicViewMode.LIST, + ) { viewMode = TopicViewMode.LIST } - if (!isEsp32) { ViewModeButton( label = stringResource(R.string.mqtt_view_topics), selected = 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 // 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. val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode - if (shownMode == TopicViewMode.MAP) { - V2xLiveMapView( - own = ownCamPosition, - remotes = remoteCamPositions, - alerts = useCaseAlerts, - denms = denmEvents, - modifier = Modifier.fillMaxSize(), - ) - } else if (shownMode == TopicViewMode.LIST) { + if (shownMode == TopicViewMode.LIST) { ReceivedCamPane( own = ownCamPosition, remotes = remoteCamPositions, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt index 305d34f..ca4156d 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xLiveMapView.kt @@ -1,12 +1,13 @@ package com.hawhamburg.micr0bu.ui.screens import android.content.Context +import android.graphics.drawable.Drawable +import android.view.MotionEvent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons 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.domain.cam.Cam 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.UseCaseAlert import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView import org.osmdroid.views.overlay.Marker /** - * V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus - * every currently-tracked remote road user's last-known CAM position, in addition to (not - * replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as - * [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). + * V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the + * ego bike's own position, every currently-tracked remote road user's last-known CAM position, + * every live hazard (DENM) and every signalised intersection heard over SPATEM. * - * Remote markers are colored by that station's most severe active alert level, if any, so a - * glance at the map shows not just "who's nearby" but "who's a warning right now" — the same - * severity coloring already used by [UseCaseAlertPanel]. + * Marker vocabulary, one shape per message type so the map reads without a legend: + * - CAM — teardrop pin, tinted by that station's most severe active alert level + * - 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 fun V2xLiveMapView( @@ -56,6 +69,9 @@ fun V2xLiveMapView( remotes: Map, alerts: List, denms: List = emptyList(), + spats: List = emptyList(), + followOwn: Boolean = true, + onUserPanned: () -> Unit = {}, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -71,8 +87,17 @@ fun V2xLiveMapView( .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() } + val mapViewRef = remember { mutableStateOf(null) } 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) { val observer = LifecycleEventObserver { _, event -> @@ -89,106 +114,236 @@ fun V2xLiveMapView( } } - Column(modifier = modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.v2x_map_remote_count, remotes.size), - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(4.dp)) - - AndroidView( - factory = { ctx -> - initOsmForV2xMap(ctx) - MapView(ctx).apply { - setTileSource(TileSourceFactory.MAPNIK) - setMultiTouchControls(true) - controller.setZoom(17.0) - controller.setCenter(ownGeoPoint) - mapViewRef.value = this + AndroidView( + factory = { ctx -> + initOsmForV2xMap(ctx) + MapView(ctx).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + // Raster tiles are authored for ~160 dpi; without this they are upscaled by the + // display density and labels come out soft on a modern phone. + isTilesScaledToDpi = true + // The floating +/- buttons sit exactly where the rider's thumb lands and + // duplicate pinch-zoom. Pinch and double-tap still work. + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + setMinZoomLevel(4.0) + setMaxZoomLevel(20.0) + controller.setZoom(17.0) + controller.setCenter(ownGeoPoint) + // Any touch means the rider is driving the map; follow-own hands over to them + // 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 } - }, - update = { mv -> - mv.overlays.clear() + mapViewRef.value = this + } + }, + update = { mv -> + val now = System.currentTimeMillis() - // Own position: a centred "you are here" dot, not a pin. Own position is a fact - // about the viewer rather than one of the tracked objects, and when both used - // osmdroid's identical default pin the two were indistinguishable at a glance. - mv.overlays.add( - Marker(mv).apply { - position = ownGeoPoint - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own) - title = context.getString(R.string.v2x_map_own_label) - } - ) + // Intersections we can actually place: SPATEM carries signal state but no geometry + // (that is MAPEM's job), so the only position available is the sending RSU's own CAM. + val locatedSpats = spats.mapNotNull { spat -> + remotes[spat.stationId]?.let { rsu -> spat to rsu } + } + // An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it: + // two markers on one point, the lower one unreachable. + val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet() - remotes.forEach { (stationId, cam) -> - val level = alertByStation[stationId] - val label = when (level) { - AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId) - AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId) - AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId) - null -> context.getString(R.string.v2x_map_remote_plain, stationId) - } - // Teardrop pin anchored at its tip, tinted by severity. Now that these are - // custom drawables, per-instance tinting is possible - severity no longer - // 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. - val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station) - ?.mutate() - ?.apply { setTint(level.toMarkerColor()) } - mv.overlays.add( - Marker(mv).apply { - position = GeoPoint(cam.latitude, cam.longitude) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - icon = pin - title = label - } + val live = mutableSetOf() + + // Own position: a centred "you are here" dot, not a pin. Own position is a fact about + // the viewer rather than one of the tracked objects, and when both used osmdroid's + // identical default pin the two were indistinguishable at a glance. + markers.marker(mv, KEY_OWN, live).apply { + position = ownGeoPoint + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + icon = icons.own + title = context.getString(R.string.v2x_map_own_label) + } + + remotes.forEach { (stationId, cam) -> + if (stationId in spatStationIds) return@forEach + val level = alertByStation[stationId] + val label = when (level) { + AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId) + AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId) + AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId) + null -> context.getString(R.string.v2x_map_remote_plain, stationId) + } + // 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 - // hidden behind a CAM pin defeats the point of showing it. - denms.forEach { denm -> - mv.overlays.add( - Marker(mv).apply { - position = GeoPoint(denm.latitude, denm.longitude) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning) - 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) - } - ) - } + // Drop markers for stations, hazards and intersections that have expired, then rebuild + // the overlay list in draw order from the cached Markers. Reordering moves references; + // nothing here allocates a Marker or decodes a Drawable. + markers.keys.retainAll { key -> + (key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() } + } + mv.overlays.clear() + markers.entries + .sortedBy { (key, _) -> key.drawOrder() } + .forEach { (_, marker) -> mv.overlays.add(marker) } - mv.controller.animateTo(ownGeoPoint) - mv.invalidate() - }, - // osmdroid's onDetach() permanently tears the MapView down: afterwards its - // MapViewRepository holds a null MapView, so constructing a Marker against it throws - // NullPointerException from deep inside InfoWindow's constructor. - // - // This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect - // 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 - // 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 = { it.onDetach() }, - modifier = Modifier.fillMaxSize(), - ) + // setCenter, not animateTo: an animation restarted on every CAM never finishes, which + // 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. + if (followOwn) { + if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint) + else mv.controller.animateTo(ownGeoPoint) + } + wasFollowing.value = followOwn + + mv.invalidate() + }, + // osmdroid's onDetach() permanently tears the MapView down: afterwards its + // MapViewRepository holds a null MapView, so constructing a Marker against it throws + // NullPointerException from deep inside InfoWindow's constructor. + // + // This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect + // 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 + // 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.marker( + mv: MapView, + key: String, + live: MutableSet, +): 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 = + (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.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 { load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) 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() } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xMapScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xMapScreen.kt new file mode 100644 index 0000000..7537778 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/V2xMapScreen.kt @@ -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) diff --git a/app/src/main/res/drawable/ic_map_spat_amber.xml b/app/src/main/res/drawable/ic_map_spat_amber.xml new file mode 100644 index 0000000..d89ba21 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_spat_amber.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_map_spat_dark.xml b/app/src/main/res/drawable/ic_map_spat_dark.xml new file mode 100644 index 0000000..657f627 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_spat_dark.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_map_spat_green.xml b/app/src/main/res/drawable/ic_map_spat_green.xml new file mode 100644 index 0000000..81ca1a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_spat_green.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_map_spat_red.xml b/app/src/main/res/drawable/ic_map_spat_red.xml new file mode 100644 index 0000000..a33b3a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_spat_red.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 687fcbb..4833bca 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -142,12 +142,18 @@ Noch kein GPS-Signal - gehen Sie ins Freie Standortkarte Aktueller Standort - %1$d erfasste externe Verkehrsteilnehmer Eigen (Ego) Extern #%1$d Extern #%1$d · Info Extern #%1$d · Aufmerksamkeit Extern #%1$d · Warnung + V2X-Live-Karte + Zurück + Auf eigene Position zentrieren + Verkehrsteilnehmer (CAM) + Gefahren (DENM) + Signale (SPATEM) + %1$d Signal(e) nicht dargestellt - Senderposition unbekannt Einstellungen @@ -197,7 +203,6 @@ Noch keine Nachrichten Liste Topics - Karte Mit der OBU verbinden und auf V2X-Verkehr warten Noch keine Nachrichten zu diesem Thema diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2efa2d6..5d5cd9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,12 +143,18 @@ No GPS fix yet - move to an open area Location Map Current Location - %1$d tracked remote road user(s) Own (ego) Remote #%1$d Remote #%1$d · Info Remote #%1$d · Awareness Remote #%1$d · Warning + V2X Live Map + Back + Centre on own position + Road users (CAM) + Hazards (DENM) + Signals (SPATEM) + %1$d signal(s) not shown - sender position unknown Settings @@ -198,7 +204,6 @@ No messages yet List Topics - Map Connect to the OBU and wait for V2X traffic No messages on this topic yet