diff options
Diffstat (limited to 'android-app/app/src')
5 files changed, 114 insertions, 1 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index 5950844..17fa52f 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -50,6 +50,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private var anchorWatchHandler: AnchorWatchHandler? = null private val loadedStyleFlow = MutableStateFlow<Style?>(null) private var weatherLoaded = false + private var mapLibreMapRef: org.maplibre.android.maps.MapLibreMap? = null private lateinit var bottomSheetBehavior: BottomSheetBehavior<View> private lateinit var fragmentContainer: FrameLayout @@ -232,6 +233,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapView = findViewById(R.id.mapView) mapView?.onCreate(null) mapView?.getMapAsync { maplibreMap -> + mapLibreMapRef = maplibreMap mapHandler = MapHandler(maplibreMap) val style = Style.Builder() .fromUri("https://tiles.openfreemap.org/styles/liberty") @@ -240,7 +242,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { it.setMaxZoom(18f) }, 256)) .withLayer(RasterLayer("openseamap-layer", "openseamap-source")) - + maplibreMap.setStyle(style) { style -> loadedStyleFlow.value = style val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor) @@ -248,6 +250,15 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { val windArrowBitmap = rasterizeDrawable(R.drawable.ic_wind_arrow) mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap) mapHandler?.setupWindLayer(style, windArrowBitmap) + mapHandler?.setupWindGridLayer(style) + } + + maplibreMap.addOnCameraIdleListener { + val bounds = maplibreMap.projection.visibleRegion.latLngBounds + viewModel.loadWindGrid( + bounds.latSouth, bounds.latNorth, + bounds.lonWest, bounds.lonEast + ) } } } @@ -270,6 +281,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } lifecycleScope.launch { + viewModel.windGrid.collect { arrows -> + mapHandler?.updateWindGridLayer(arrows) + } + } + lifecycleScope.launch { LocationService.anchorWatchState.collect { state -> safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive") } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt index 9713bcd..bce3534 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/WeatherApiService.kt @@ -15,4 +15,14 @@ interface WeatherApiService { @Query("forecast_days") forecastDays: Int = 7, @Query("wind_speed_unit") windSpeedUnit: String = "kn" ): WeatherResponse + + /** Batch endpoint: comma-separated lat/lon → JSON array of WeatherResponse. */ + @GET("v1/forecast") + suspend fun getWeatherForecastBatch( + @Query("latitude") latitudes: String, + @Query("longitude") longitudes: String, + @Query("hourly") hourly: String = "windspeed_10m,winddirection_10m", + @Query("forecast_days") forecastDays: Int = 1, + @Query("wind_speed_unit") windSpeedUnit: String = "kn" + ): List<WeatherResponse> } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt index ee586a5..3459d7b 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt @@ -47,6 +47,26 @@ class WeatherRepository( ) } + /** + * Fetch a current wind arrow for each point in [points] using Open-Meteo's batch endpoint. + * Points are split into chunks of 10 (API limit per call). + */ + suspend fun fetchWindGrid(points: List<Pair<Double, Double>>): Result<List<WindArrow>> = + runCatching { + points.chunked(10).flatMap { chunk -> + val lats = chunk.joinToString(",") { "%.4f".format(it.first) } + val lons = chunk.joinToString(",") { "%.4f".format(it.second) } + weatherApi.getWeatherForecastBatch(lats, lons).map { r -> + WindArrow( + lat = r.latitude, + lon = r.longitude, + speedKt = r.hourly.windspeed10m.firstOrNull() ?: 0.0, + directionDeg = r.hourly.winddirection10m.firstOrNull() ?: 0.0 + ) + } + } + } + companion object { /** Factory using the shared ApiClient singletons. */ fun create(): WeatherRepository { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt index 0efff52..4c2c555 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt @@ -37,6 +37,9 @@ class MainViewModel( private val _windArrow = MutableStateFlow<WindArrow?>(null) val windArrow: StateFlow<WindArrow?> = _windArrow + private val _windGrid = MutableStateFlow<List<WindArrow>>(emptyList()) + val windGrid: StateFlow<List<WindArrow>> = _windGrid.asStateFlow() + private val _forecast = MutableStateFlow<List<ForecastItem>>(emptyList()) val forecast: StateFlow<List<ForecastItem>> = _forecast @@ -121,6 +124,31 @@ class MainViewModel( } /** + * Fetch wind arrows for a 4×5 grid covering the visible map bounds. + * Fires-and-forgets; silently ignores network failures (grid is decorative). + */ + fun loadWindGrid(latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) { + viewModelScope.launch { + val points = buildGrid(latMin, latMax, lonMin, lonMax, cols = 4, rows = 5) + repository.fetchWindGrid(points).getOrNull()?.let { _windGrid.value = it } + } + } + + private fun buildGrid( + latMin: Double, latMax: Double, + lonMin: Double, lonMax: Double, + cols: Int, rows: Int + ): List<Pair<Double, Double>> { + val latStep = (latMax - latMin) / (rows - 1).coerceAtLeast(1) + val lonStep = (lonMax - lonMin) / (cols - 1).coerceAtLeast(1) + return (0 until rows).flatMap { row -> + (0 until cols).map { col -> + Pair(latMin + row * latStep, lonMin + col * lonStep) + } + } + } + + /** * Process a single NMEA sentence from the hardware AIS receiver. * Call this from MainActivity when bytes arrive from the TCP socket. */ diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt index cdf0c25..bb73c53 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt @@ -49,11 +49,15 @@ class MapHandler(private val maplibreMap: MapLibreMap) { private val WIND_LAYER_ID = "wind-arrows" private val WIND_ARROW_ICON_ID = "wind-arrow" + private val WIND_GRID_SOURCE_ID = "wind-grid-source" + private val WIND_GRID_LAYER_ID = "wind-grid-arrows" + private var anchorPointSource: GeoJsonSource? = null private var anchorCircleSource: GeoJsonSource? = null private var tidalCurrentSource: GeoJsonSource? = null private var trackSource: GeoJsonSource? = null private var windSource: GeoJsonSource? = null + private var windGridSource: GeoJsonSource? = null /** * Initializes map layers for anchor watch and tidal currents. @@ -167,6 +171,41 @@ class MapHandler(private val maplibreMap: MapLibreMap) { } /** + * Registers the wind-grid layer. Uses the same bitmap as the single arrow — call after setupWindLayer. + */ + fun setupWindGridLayer(style: Style) { + windGridSource = GeoJsonSource(WIND_GRID_SOURCE_ID) + style.addSource(windGridSource!!) + style.addLayer(SymbolLayer(WIND_GRID_LAYER_ID, WIND_GRID_SOURCE_ID).apply { + setProperties( + PropertyFactory.iconImage(WIND_ARROW_ICON_ID), + PropertyFactory.iconRotate(Expression.get("direction")), + PropertyFactory.iconRotationAlignment("map"), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconSize( + Expression.interpolate( + Expression.linear(), + Expression.get("speed_kt"), + Expression.stop(0, 0.5f), + Expression.stop(30, 1.2f) + ) + ) + ) + }) + } + + /** Replaces all wind-grid arrows with the fetched list. */ + fun updateWindGridLayer(arrows: List<WindArrow>) { + val features = arrows.map { arrow -> + Feature.fromGeometry(Point.fromLngLat(arrow.lon, arrow.lat)).apply { + addNumberProperty("direction", arrow.directionDeg) + addNumberProperty("speed_kt", arrow.speedKt) + } + } + windGridSource?.setGeoJson(FeatureCollection.fromFeatures(features)) + } + + /** * Places or moves the single wind arrow at the GPS position. */ fun updateWindLayer(arrow: WindArrow) { |
