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β
| Feature | Google Maps | Mapbox |
|---|---|---|
| Marker rendering | MarkerComposable (Compose tree) | PointAnnotation with pre-rendered Bitmap |
| Dark theme | JSON style resource (R.raw.google_map_dark_style) | LightPresetValue.NIGHT / .DAY |
| Map styles | MapType enum (NORMAL, SATELLITE, HYBRID) | URI-based (Style.STANDARD, .SATELLITE_STREETS, etc.) |
| Location puck | Built-in isMyLocationEnabled | Manual createDefault2DPuck() with bearing |
| My-location click | onMyLocationClick callback | Manual puck hit-test via pixelForCoordinate distance check |
| Compass | Built-in (disabled) | Custom composable with Navigation icon |
| Fit-all | CameraUpdateFactory.newLatLngBounds() | cameraForCoordinates() with EdgeInsets |
| Off-screen projection | projection.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.