Skip to main content

Mapbox Implementation

This page covers the Mapbox provider implementation, including the bridge composable, bitmap marker rendering, position history playback, and off-screen projection fallback. For the shared architecture, provider abstraction, and marker system, see the Maps overview.


MapboxMapContent​

MapboxMapContent acts as a stateful bridge that translates MapAction flow events into MapboxMapView parameters:

// ui/maps/MapboxMapContent.kt
var focusTarget by remember { mutableStateOf<Point?>(null) }
var focusZoom by remember { mutableStateOf<Double?>(null) }
var fitAllTrigger by remember { mutableIntStateOf(0) }

LaunchedEffect(Unit) {
mapActions.collect { action ->
when (action) {
is MapAction.FocusOnPoint -> {
focusTarget = Point.fromLngLat(action.longitude, action.latitude)
focusZoom = action.zoom
}
is MapAction.FitDevices -> fitAllTrigger++
}
}
}

MapboxMapView​

The core Mapbox composable uses MapboxMap (aliased as MapboxMapComposable) with PointAnnotation for markers:

// ui/maps/MapboxMapView.kt
MapboxMapComposable(
mapViewportState = mapViewportState,
style = {
if (isStandardStyle) {
MapboxStandardStyle(
standardStyleState = rememberStandardStyleState {
configurationsState.lightPreset = if (isDarkTheme) {
LightPresetValue.NIGHT
} else {
LightPresetValue.DAY
}
}
)
} else {
MapStyle(style = styleUri)
}
}
) {
devices.forEach { deviceWithPosition ->
PointAnnotation(point = Point.fromLngLat(lng, lat)) {
iconImage = IconImage(markerBitmap)
iconAnchor = IconAnchor.CENTER
interactionsState.onClicked { onDeviceClick(deviceWithPosition); true }
}
}
}

Key Differences from Google Maps​

FeatureGoogle MapsMapbox
Marker renderingMarkerComposable (Compose tree)PointAnnotation with pre-rendered Bitmap
Dark themeJSON style resource (R.raw.google_map_dark_style)LightPresetValue.NIGHT / .DAY
Map stylesMapType enum (NORMAL, SATELLITE, HYBRID)URI-based (Style.STANDARD, .SATELLITE_STREETS, etc.)
Location puckBuilt-in isMyLocationEnabledManual createDefault2DPuck() with bearing
My-location clickonMyLocationClick callbackManual puck hit-test via pixelForCoordinate distance check
CompassBuilt-in (disabled)Custom composable with Navigation icon
Fit-allCameraUpdateFactory.newLatLngBounds()cameraForCoordinates() with EdgeInsets
Off-screen projectionprojection.toScreenLocation()pixelForCoordinate() + great circle bearing fallback

See also the Google Maps implementation for the other side of this comparison.

Map Style Mapping​

private fun mapTypeToMapboxStyle(mapType: MapType): String = when (mapType) {
MapType.SATELLITE, MapType.HYBRID -> "satellite"
else -> "standard"
}

Puck Click Detection​

Since Mapbox doesn't have a built-in my-location click handler, the app manually detects clicks near the location puck:

onMapClickListener = { clickPoint ->
val puck = puckPosition
val map = mapboxMapRef
if (puck != null && map != null) {
val clickScreen = map.pixelForCoordinate(clickPoint)
val puckScreen = map.pixelForCoordinate(puck)
val dx = clickScreen.x - puckScreen.x
val dy = clickScreen.y - puckScreen.y
val distance = kotlin.math.sqrt(dx * dx + dy * dy)
if (distance < MapConstants.PUCK_CLICK_THRESHOLD) { // 60px
onMyLocationClick()
} else {
onMapClick()
}
}
true
}

Off-Screen Projection Fallback​

Mapbox's pixelForCoordinate returns (-1, -1) for off-screen points. The implementation falls back to great circle bearing calculation for correct directional clamping:

// When pixelForCoordinate returns off-screen (-1, -1):
val gcBearing = Math.atan2(
Math.sin(dLng) * Math.cos(lat2),
Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng)
)
val screenAngle = gcBearing - Math.toRadians(mapBearing)
val far = maxOf(mapWidth, mapHeight) * 2f
Pair(
centerX + (far * Math.sin(screenAngle)).toFloat(),
centerY - (far * Math.cos(screenAngle)).toFloat()
)

See also the design decision rationale for why this approach was chosen.


MarkerBitmapFactory​

Since Mapbox uses PointAnnotation with bitmap icons (not Compose trees), MarkerBitmapFactory.kt pre-renders markers to Bitmap using Compose's CanvasDrawScope:

@Composable
fun rememberMarkerBitmap(
isOnline: Boolean,
isMoving: Boolean,
course: Double,
deviceIcon: String?,
deviceCategory: String?,
deviceName: String? = null,
): Bitmap

The bitmap is memoized with remember() keyed on status, movement, course (quantized to 5Β° increments), icon, and name. When a device is selected, the bitmap includes a label pill above the marker.

For the shared DeviceMarker composable used by both providers, see Marker System.


Position History​

The HistoryScreen uses Mapbox exclusively (not the dual-provider system) to display historical device positions with playback controls. See the design decision for the rationale.

Architecture​

HistoryViewModel​

Follows the same MVI pattern as MapViewModel:

data class HistoryUiState(
val positions: List<Position> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null,
val dateFrom: Instant = Instant.now().minus(7, ChronoUnit.DAYS),
val dateTo: Instant = Instant.now(),
val currentIndex: Int = 0,
val isPlaying: Boolean = false,
val playbackSpeed: Float = 1.0f,
)

Playback System​

Playback is driven by a LaunchedEffect coroutine that advances the index at intervals based on speed:

LaunchedEffect(uiState.isPlaying, uiState.playbackSpeed) {
if (uiState.isPlaying && uiState.positions.isNotEmpty()) {
while (uiState.isPlaying && uiState.currentIndex < uiState.positions.size - 1) {
delay((1000 / uiState.playbackSpeed).toLong())
viewModel.handle(HistoryIntent.AdvancePlayback)
}
}
}

Available playback speeds: 0.5x, 1x, 2x, 5x, 10x

Skip forward/backward jumps by 10 positions.

Route Rendering​

if (routePoints.size >= 2) {
PolylineAnnotation(points = routePoints) {
lineColor = colors.primary
lineWidth = 4.0
}
}

The camera auto-centers on the current position marker as the index changes.