Compare commits

..
2 Commits
Author SHA1 Message Date
PoliEcho 7ad24463b7 better Public transport vehicle map 2026-09-06 13:15:28 +02:00
PoliEcho a7f2be8a3e add vehicles 2026-09-03 17:19:17 +02:00
3 changed files with 140 additions and 5 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-09-02T15:28:00.985067432Z">
<DropdownSelection timestamp="2026-09-03T13:26:15.706271055Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/lukas/.config/.android/avd/Medium_Phone_OSS.avd" />
@@ -4,12 +4,34 @@ import android.app.Activity
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.transit.realtime.GtfsRealtime
import kotlinx.serialization.Serializable
import okhttp3.OkHttpClient
import okhttp3.Request
import ovh.plrapps.mapcompose.api.addClusterer
import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.hasMarker
import ovh.plrapps.mapcompose.api.moveMarker
import ovh.plrapps.mapcompose.api.removeMarker
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import java.net.URL
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.mutableStateMapOf
@Serializable
data class GTFSRoute(
@@ -74,6 +96,33 @@ val gtfshttpClient = OkHttpClient.Builder()
private val GTFSMainHandler = Handler(Looper.getMainLooper())
private var GTFSTrackingThread: Thread? = null
private var GTFSIsTracking = false
private val activeVehicleIds = mutableSetOf<String>()
private data class VehicleUiState(val bearing: Float, val routeShortName: String?)
private val vehicleUiStates = mutableStateMapOf<String, VehicleUiState>()
private const val GTFS_CLUSTERER_ID = "gtfs_vehicles"
private var gtfsClustererInitialized = false
private fun ensureGtfsClusterer() {
if (gtfsClustererInitialized) return
mapState.addClusterer(GTFS_CLUSTERER_ID, 30.dp) { ids ->
{
Surface(
shape = MaterialTheme.shapes.small,
color = Color(0xFF31FF00)
) {
Text(
text = "${ids.size}",
modifier = Modifier.padding(4.dp),
fontSize = 11.sp,
color = Color.Black
)
}
}
}
gtfsClustererInitialized = true
}
private fun fetchGTFSPositions(gtfsUrl: URL, api_key: String, callee: Activity) {
try {
@@ -88,15 +137,85 @@ private fun fetchGTFSPositions(gtfsUrl: URL, api_key: String, callee: Activity)
throw Exception("Error: ${response.code} ${response.message}")
}
val bytes = response.body?.bytes() ?: throw Exception("Empty response")
val feed = GtfsRealtime.FeedMessage.parseFrom(bytes)
val feed = response.body?.byteStream()?.use { stream ->
GtfsRealtime.FeedMessage.parseFrom(stream)
} ?: throw Exception("Empty response")
data class VehicleUpdate(
val id: String,
val x: Double,
val y: Double,
val bearing: Float,
val routeShortName: String?
)
val seenIds = mutableSetOf<String>()
val updates = mutableListOf<VehicleUpdate>()
feed.entityList.forEach { entity ->
if (entity.hasVehicle()) {
// use vehicle
val id = entity.vehicle.vehicle.id
seenIds.add(id)
val routeId = entity.vehicle.trip.routeId
updates.add(
VehicleUpdate(
id = id,
x = longitudeToXNormalized(entity.vehicle.position.longitude.toDouble()),
y = latitudeToYNormalized(entity.vehicle.position.latitude.toDouble()),
bearing = entity.vehicle.position.bearing,
routeShortName = gtfsRoutes.find { it.route_id == routeId }?.route_short_name
)
)
}
}
GTFSMainHandler.post {
ensureGtfsClusterer()
updates.forEach { u ->
vehicleUiStates[u.id] = VehicleUiState(u.bearing, u.routeShortName)
if (mapState.hasMarker(u.id)) {
mapState.moveMarker(u.id, x = u.x, y = u.y)
} else {
mapState.addMarker(
u.id,
x = u.x,
y = u.y,
Offset(-0.5f, -0.5f),
renderingStrategy = RenderingStrategy.Clustering(GTFS_CLUSTERER_ID)
) {
val state = vehicleUiStates[u.id]
Box(
modifier = Modifier.size(28.dp),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.mhd_icon),
contentDescription = null,
modifier = Modifier.size(16.dp).rotate(state?.bearing ?: u.bearing),
tint = Color(0xFF31FF00)
)
Text(
"${state?.routeShortName ?: u.routeShortName}",
color = Color.Red,
fontSize = 10.sp,
modifier = Modifier.align(Alignment.Center)
)
}
}
activeVehicleIds.add(u.id)
}
}
val stale = activeVehicleIds - seenIds
stale.forEach { staleId ->
mapState.removeMarker(staleId)
vehicleUiStates.remove(staleId)
}
activeVehicleIds.removeAll(stale)
activeVehicleIds.addAll(seenIds)
}
}
} catch (e: Exception) {
GTFSMainHandler.post {
@@ -213,4 +332,7 @@ fun stopGTFSTracking() {
}
GTFSTrackingThread?.interrupt()
GTFSTrackingThread = null
activeVehicleIds.clear()
vehicleUiStates.clear()
gtfsClustererInitialized = false
}
+13
View File
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="256dp"
android:height="256dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M2.98,253.02H253.02V128L128,2.98 2.98,128v125.02"
android:strokeLineJoin="round"
android:strokeWidth="5.95207"
android:fillColor="#000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
</vector>