diff --git a/README.md b/README.md index 5b2535c..6fcfda5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Add to your `build.gradle.kts`: ```kotlin dependencies { - implementation("de.afarber:openmapview:0.12.0") + implementation("de.afarber:openmapview:0.13.0") } ``` diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 9d0fb7b..2504a45 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -269,7 +269,7 @@ HttpClient(Android) { All requests include a user-agent header as required by OSM tile usage policy: ```kotlin -header("User-Agent", "OpenMapView/0.12.0 (https://github.com/afarber/OpenMapView)") +header("User-Agent", "OpenMapView/0.13.0 (https://github.com/afarber/OpenMapView)") ``` ### Coroutine-Based Downloads diff --git a/docs/REPLACING_GOOGLE_MAPS.md b/docs/REPLACING_GOOGLE_MAPS.md index 46c1a6d..9ee046d 100644 --- a/docs/REPLACING_GOOGLE_MAPS.md +++ b/docs/REPLACING_GOOGLE_MAPS.md @@ -107,7 +107,7 @@ dependencies { ```kotlin // Add to build.gradle.kts dependencies { - implementation("de.afarber:openmapview:0.12.0") + implementation("de.afarber:openmapview:0.13.0") } ``` diff --git a/examples/Example01Pan/README.md b/examples/Example01Pan/README.md index f124f1e..6c1f1ce 100644 --- a/examples/Example01Pan/README.md +++ b/examples/Example01Pan/README.md @@ -64,7 +64,7 @@ fun MapViewScreen() { factory = { ctx -> OpenMapView(ctx).apply { lifecycleOwner.lifecycle.addObserver(this) - setCenter(LatLng(51.4661, 7.2491)) // Bochum, Germany + setCenter(LatLng(51.4661, 7.2491)) setZoom(14.0f) mapView = this } @@ -81,14 +81,6 @@ fun MapViewScreen() { } ``` -### OSM-Inspired Colors (Colors.kt) - -```kotlin -val OsmParkGreen = Color(0xFFAAD3A2) // Parks and forests -val OsmHighwayPink = Color(0xFFE892A2) // Highways and roads -val OsmWaterBlue = Color(0xFFAAD3DF) // Water areas -``` - ### Key Concepts - **LatLng**: Represents geographic coordinates (latitude, longitude) @@ -117,3 +109,4 @@ val OsmWaterBlue = Color(0xFFAAD3DF) // Water areas - Location 2 (Green marker): East of center - Location 3 (Magenta marker): South-West of center - Initial (Cyan marker): Center position + diff --git a/examples/Example02Zoom/README.md b/examples/Example02Zoom/README.md index 7f8bfb9..01a2bb1 100644 --- a/examples/Example02Zoom/README.md +++ b/examples/Example02Zoom/README.md @@ -62,7 +62,7 @@ fun MapViewScreen() { factory = { ctx -> OpenMapView(ctx).apply { lifecycleOwner.lifecycle.addObserver(this) - setCenter(LatLng(51.4661, 7.2491)) // Bochum, Germany + setCenter(LatLng(51.4661, 7.2491)) setZoom(14.0f) mapView = this } @@ -76,14 +76,6 @@ fun MapViewScreen() { } ``` -### OSM-Inspired Colors (Colors.kt) - -```kotlin -val OsmParkGreen = Color(0xFFAAD3A2) // Parks and forests -val OsmHighwayPink = Color(0xFFE892A2) // Highways and roads -val OsmWaterBlue = Color(0xFFAAD3DF) // Water areas -``` - ### Key Concepts - **setZoom()**: Sets zoom level (2.0 = world view, 19.0 = street level) @@ -122,3 +114,4 @@ OpenMapView uses: ## Map Location **Default Center:** Bochum, Germany (51.4661N, 7.2491E) at zoom 14.0 + diff --git a/examples/Example03Markers/README.md b/examples/Example03Markers/README.md index 015c53a..6944488 100644 --- a/examples/Example03Markers/README.md +++ b/examples/Example03Markers/README.md @@ -46,7 +46,7 @@ example03markers/ ├── MarkerToolbar.kt # Horizontal toolbar with prev/next navigation buttons ├── StatusToolbar.kt # Status overlay showing selection index and camera state ├── MarkerData.kt # Marker data class and Bochum POI locations -└── Constants.kt # Colors, dimensions, and durations +└── Colors.kt # OSM-inspired colors and shared dimensions ``` ## Code Highlights @@ -68,8 +68,8 @@ fun MapViewScreen() { OpenMapView(ctx).apply { lifecycleOwner.lifecycle.addObserver(this) setCenter(initialLocation) - setZoom(13.0f) - getUiSettings().infoWindowAutoDismiss = 10.seconds + setZoom(15.0f) + getUiSettings().infoWindowAutoDismiss = 5.seconds setOnMarkerClickListener { marker -> selectedMarker = marker @@ -98,7 +98,7 @@ addMarker( Marker( position = LatLng(51.4783, 7.2231), title = "Bochum Hauptbahnhof", - snippet = "Main railway station", + snippet = "Main Railway Station", icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED), ) ) @@ -116,14 +116,6 @@ addMarker( ) ``` -### OSM-Inspired Colors (Constants.kt) - -```kotlin -val OsmParkGreen = Color(0xFFAAD3A2) // Navigation buttons (prev/next) -val OsmHighwayPink = Color(0xFFE892A2) // Info window toggle FAB -val OsmWaterBlue = Color(0xFFAAD3DF) // Reserved for future use -``` - ### Key Concepts - **Marker**: Data class with position, title, snippet, icon, anchor, and tag @@ -132,7 +124,6 @@ val OsmWaterBlue = Color(0xFFAAD3DF) // Reserved for future use - **setOnMarkerClickListener()**: Handle marker click events - **setOnInfoWindowClickListener()**: Handle info window click events - **setOnInfoWindowCloseListener()**: Handle info window close events (manual or auto-dismiss) -- **infoWindowAutoDismiss**: Auto-dismiss info windows after a duration ## What to Test @@ -145,19 +136,6 @@ val OsmWaterBlue = Color(0xFFAAD3DF) // Reserved for future use 7. **Wait 10 seconds** - info window auto-dismisses, status text turns black 8. **Pan/zoom the map** - markers stay at correct geographic positions -## Marker Locations - -This example displays 6 markers at notable Bochum landmarks: - -| Location | Coordinates | Description | -| ----------------- | ------------------- | -------------------- | -| Hauptbahnhof | 51.4783°N, 7.2231°E | Main railway station | -| Ruhr University | 51.4452°N, 7.2622°E | Ruhr-Universitat | -| Rathaus | 51.4816°N, 7.2166°E | City Hall | -| Bermuda3eck | 51.4807°N, 7.2222°E | Entertainment dist. | -| Bergbau-Museum | 51.4892°N, 7.2174°E | Mining Museum | -| Starlight Express | 51.4649°N, 7.2043°E | Musical theater | - ## Custom Marker Icons To use custom marker icons instead of the default teardrop: @@ -211,4 +189,5 @@ Click detection uses: **Default Center:** Calculated from marker positions (~51.47°N, 7.22°E) at zoom 13.0 -All 6 markers are positioned around Bochum at real landmark locations. +All 8 markers are positioned around Bochum at real landmark locations. + diff --git a/examples/Example03Markers/screenshot.gif b/examples/Example03Markers/screenshot.gif index 2471919..5829c46 100644 Binary files a/examples/Example03Markers/screenshot.gif and b/examples/Example03Markers/screenshot.gif differ diff --git a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/Constants.kt b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/Colors.kt similarity index 80% rename from examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/Constants.kt rename to examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/Colors.kt index f396b5c..a685342 100644 --- a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/Constants.kt +++ b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/Colors.kt @@ -9,8 +9,6 @@ package de.afarber.openmapview.example03markers import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds /** * Constants for the Example03Markers app: colors, dimensions, and durations. @@ -27,6 +25,3 @@ val OsmWaterBlue = Color(0xFFAAD3DF) /** Shared corner radius for all toolbar components (matches Material3 FAB). */ val ToolbarCornerRadius = 16.dp - -/** Duration after which info windows are automatically dismissed. */ -val InfoWindowAutoDismissDuration: Duration = 5.seconds diff --git a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt index 757a953..2b0b693 100644 --- a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt +++ b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt @@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.LocationSearching import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -39,6 +39,7 @@ import de.afarber.openmapview.LatLng import de.afarber.openmapview.Marker import de.afarber.openmapview.OnCameraMoveStartedListener import de.afarber.openmapview.OpenMapView +import kotlin.time.Duration.Companion.seconds /** * Main activity demonstrating OpenMapView marker navigation. @@ -83,7 +84,7 @@ fun MapViewScreen() { poiMarkers.map { it.position.latitude }.average(), poiMarkers.map { it.position.longitude }.average(), ) - val initialZoom = 14.0f + val initialZoom = 15.0f // State variables - mapView is nullable because AndroidView.factory runs after first composition var mapView: OpenMapView? by remember { mutableStateOf(null) } @@ -119,7 +120,7 @@ fun MapViewScreen() { setCenter(initialLocation) setZoom(initialZoom) - getUiSettings().infoWindowAutoDismiss = InfoWindowAutoDismissDuration + getUiSettings().infoWindowAutoDismiss = 5.seconds createMarkers(this) @@ -159,6 +160,7 @@ fun MapViewScreen() { // Status overlay at top StatusToolbar( + totalCount = poiMarkers.size, selectedIndex = selectedIndex, selectedMarkerTitle = selectedMarker?.title, cameraState = cameraState, @@ -212,7 +214,7 @@ fun MapViewScreen() { .padding(16.dp), ) { Icon( - imageVector = Icons.Default.LocationOn, + imageVector = Icons.Default.LocationSearching, contentDescription = "Toggle Info Window", ) } diff --git a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerData.kt b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerData.kt index 6d137e1..f5948f0 100644 --- a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerData.kt +++ b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerData.kt @@ -30,13 +30,13 @@ val poiMarkers = listOf( MarkerData( position = LatLng(51.4783, 7.2231), title = "Bochum Hauptbahnhof", - snippet = "Main railway station", + snippet = "Main Railway Station", hue = BitmapDescriptorFactory.HUE_RED, ), MarkerData( position = LatLng(51.4452, 7.2622), title = "Ruhr University", - snippet = "Ruhr-Universität Bochum", + snippet = "Ruhr-University Bochum", hue = BitmapDescriptorFactory.HUE_BLUE, ), MarkerData( @@ -46,9 +46,9 @@ val poiMarkers = listOf( hue = BitmapDescriptorFactory.HUE_GREEN, ), MarkerData( - position = LatLng(51.4807, 7.2222), + position = LatLng(51.4761, 7.2161), title = "Bermuda3eck", - snippet = "Entertainment district", + snippet = "Entertainment District", hue = BitmapDescriptorFactory.HUE_ORANGE, ), MarkerData( @@ -58,9 +58,21 @@ val poiMarkers = listOf( hue = BitmapDescriptorFactory.HUE_MAGENTA, ), MarkerData( - position = LatLng(51.4649, 7.2043), + position = LatLng(51.4927, 7.2342), title = "Starlight Express", - snippet = "Musical theater", + snippet = "Musical Theater", hue = BitmapDescriptorFactory.HUE_CYAN, ), + MarkerData( + position = LatLng(51.4722, 7.2177), + title = "Schauspielhaus Bochum", + snippet = "Drama Theater", + hue = BitmapDescriptorFactory.HUE_YELLOW, + ), + MarkerData( + position = LatLng(51.4854, 7.2278), + title = "Zeiss Planetarium", + snippet = "Domed Astronomy Center", + hue = BitmapDescriptorFactory.HUE_VIOLET, + ), ) diff --git a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/StatusToolbar.kt b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/StatusToolbar.kt index 9babc18..b83829c 100644 --- a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/StatusToolbar.kt +++ b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/StatusToolbar.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp * Shows the currently selected marker index, name, and camera state. * The marker index text turns red when an info window is shown. * + * @param totalCount Total number of markers. * @param selectedIndex The index of the currently selected marker. * @param selectedMarkerTitle Title of the currently selected marker, or null if none selected. * @param cameraState Current camera state description (e.g., "Idle", "Moving (gesture)"). @@ -33,6 +34,7 @@ import androidx.compose.ui.unit.dp */ @Composable fun StatusToolbar( + totalCount: Int, selectedIndex: Int, selectedMarkerTitle: String?, cameraState: String, @@ -47,7 +49,7 @@ fun StatusToolbar( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "Marker #${selectedIndex + 1}", + text = "Marker #${selectedIndex + 1} of $totalCount", color = if (isInfoWindowShown) Color.Red else Color.Black, ) Text( diff --git a/examples/Example04Polylines/README.md b/examples/Example04Polylines/README.md index 3d1672b..112b647 100644 --- a/examples/Example04Polylines/README.md +++ b/examples/Example04Polylines/README.md @@ -2,16 +2,19 @@ [Back to README](../../README.md) -This example demonstrates drawing vector shapes (polylines and polygons) on OpenMapView, including styled lines, filled areas, and polygons with holes. +This example demonstrates drawing vector shapes (polylines and polygons) on OpenMapView with navigation controls, click handling, and overlay highlighting. ## Features Demonstrated - Polylines with custom stroke colors and widths +- Geodesic polylines (great-circle paths for long distances) - Filled polygons with stroke and fill colors - Polygons with holes (donut shapes) -- Multiple overlapping shapes on a single map -- Shapes that properly pan and zoom with the map -- Semi-transparent polygon fills +- Navigation between overlays with prev/next buttons +- Click handling on polylines and polygons +- Highlight toggle for selected overlay (thicker stroke with dashed pattern) +- Camera animation when selecting overlays +- Real-time status display showing selection and camera state ## Screenshot @@ -38,120 +41,146 @@ adb shell am start -n de.afarber.openmapview.example04polylines/.MainActivity ## Code Highlights -### Adding a Polyline +### Overlay Data Model + +The example uses an interface for overlay data with two implementations: ```kotlin -OpenMapView(context).apply { - setCenter(LatLng(51.4661, 7.2491)) // Bochum, Germany - setZoom(14.0) - - // Add a blue route line - addPolyline( - Polyline( - points = listOf( - LatLng(51.4700, 7.2400), - LatLng(51.4680, 7.2450), - LatLng(51.4650, 7.2500), - LatLng(51.4620, 7.2550), - ), - strokeColor = Color.BLUE, - strokeWidth = 8f, - ) - ) +interface OverlayData { + val title: String + val snippet: String + val points: List } + +data class PolylineData( + override val title: String, + override val snippet: String, + override val points: List, + val color: Color, + val width: Float, + val geodesic: Boolean = false, // Draw as great-circle path +) : OverlayData + +data class PolygonData( + override val title: String, + override val snippet: String, + override val points: List, + val holes: List> = emptyList(), + val strokeColor: Color, + val fillColor: Color, +) : OverlayData ``` -### Adding a Polygon +### Adding Overlays from Data ```kotlin -// Add a green area (park) -addPolygon( - Polygon( - points = listOf( - LatLng(51.4640, 7.2380), - LatLng(51.4660, 7.2380), - LatLng(51.4660, 7.2420), - LatLng(51.4640, 7.2420), - ), - strokeColor = Color.rgb(0, 128, 0), - strokeWidth = 4f, - fillColor = Color.argb(100, 0, 255, 0), // Semi-transparent green - ) -) +poiOverlays.forEach { data -> + when (data) { + is PolylineData -> { + mapView.addPolyline( + Polyline( + points = data.points, + strokeColor = data.color, + strokeWidth = data.width, + geodesic = data.geodesic, + clickable = true, + tag = data.title, + ) + ) + } + is PolygonData -> { + mapView.addPolygon( + Polygon( + points = data.points, + holes = data.holes, + strokeColor = data.strokeColor, + fillColor = data.fillColor, + clickable = true, + tag = data.title, + ) + ) + } + } +} ``` -### Adding a Polygon with a Hole +### Click Handling ```kotlin -// Add a donut-shaped polygon -addPolygon( - Polygon( - points = listOf( - LatLng(51.4700, 7.2580), - LatLng(51.4720, 7.2580), - LatLng(51.4720, 7.2620), - LatLng(51.4700, 7.2620), - ), - holes = listOf( - listOf( - LatLng(51.4706, 7.2590), - LatLng(51.4714, 7.2590), - LatLng(51.4714, 7.2610), - LatLng(51.4706, 7.2610), - ), - ), - strokeColor = Color.CYAN, - strokeWidth = 4f, - fillColor = Color.argb(100, 0, 255, 255), // Semi-transparent cyan - ) -) +setOnPolylineClickListener { polyline -> + val title = polyline.tag as? String ?: return@setOnPolylineClickListener + selectedIndex = findOverlayIndexByTitle(title) + animateCamera(CameraUpdateFactory.newLatLng(getOverlayCenter(overlay)), 500) +} + +setOnPolygonClickListener { polygon -> + val title = polygon.tag as? String ?: return@setOnPolygonClickListener + selectedIndex = findOverlayIndexByTitle(title) + animateCamera(CameraUpdateFactory.newLatLng(getOverlayCenter(overlay)), 500) +} ``` -### Key Concepts +### Highlight Feature -- **Polyline**: Connected line segments defined by a list of LatLng points -- **Polygon**: Closed shape with fill color, automatically closed between last and first point -- **Holes**: Polygons can have interior cutouts (donut shapes) -- **addPolyline()**: Add a polyline to the map -- **addPolygon()**: Add a polygon to the map -- **removePolyline()/removePolygon()**: Remove specific shapes -- **clearPolylines()/clearPolygons()**: Remove all shapes of that type +Toggle highlight by replacing overlays with thicker stroke: + +```kotlin +fun updateHighlight(map: OpenMapView, highlighted: Boolean, highlightIndex: Int) { + // Remove existing overlays + createdPolylines.forEach { map.removePolyline(it) } + createdPolygons.forEach { map.removePolygon(it) } + + // Recreate with highlight (2x stroke width when highlighted) + val strokeMultiplier = if (highlighted && index == highlightIndex) 2.0f else 1.0f + // ... create overlays with multiplied stroke width +} +``` ## What to Test -1. **Launch the app** - you should see two polylines and two polygons -2. **Pan the map** - shapes stay at correct geographic positions -3. **Zoom in/out** - shapes scale and remain properly positioned -4. **Observe layering** - polygons render below polylines, polylines below markers -5. **Notice transparency** - polygon fills are semi-transparent +1. **Launch the app** - you should see 4 polylines (including 1 geodesic) and 3 polygons +2. **Navigate overlays** - use prev/next buttons at the bottom +3. **Click overlays** - tap any polyline or polygon to select it +4. **Toggle highlight** - tap FAB to highlight selected overlay (thicker stroke) +5. **Observe status** - left panel shows overlay info and camera state +6. **Pan and zoom** - shapes stay at correct geographic positions -## Shapes in this Example +## UI Components -This example displays 4 shapes around Bochum, Germany: +- **StatusToolbar** - Displays overlay index, title, type, camera state, highlight status +- **OverlayToolbar** - Prev/next navigation buttons +- **FAB** - Toggle highlight (changes color when active) -| Shape Type | Color | Description | -| ---------- | ----- | -------------------------------- | -| Polyline | Blue | Route path (8px wide) | -| Polyline | Red | Alternative route (6px wide) | -| Polygon | Green | Rectangular area (park/zone) | -| Polygon | Cyan | Rectangular area with inner hole | +## Key Concepts + +- **Polyline**: Connected line segments defined by a list of LatLng points +- **Polygon**: Closed shape with fill color, automatically closed between last and first point +- **Holes**: Polygons can have interior cutouts (donut shapes) +- **Click handling**: Use `setOnPolylineClickListener` and `setOnPolygonClickListener` +- **Tag property**: Store metadata (like title) for identification in click handlers ## Styling Options ### Polyline Properties - **points**: List of LatLng coordinates (minimum 2 points) -- **strokeColor**: Line color (Int from Color class) +- **strokeColor**: Line color (Compose Color) - **strokeWidth**: Line width in pixels (Float) +- **geodesic**: Draw as great-circle path instead of straight Mercator line (Boolean) +- **startCap/endCap**: Shape of line endpoints (StrokeCap: Butt, Round, Square) +- **spans**: List of StyleSpan for multi-colored segments +- **clickable**: Enable click handling (Boolean) - **tag**: Optional user data (Any?) ### Polygon Properties - **points**: List of LatLng coordinates (minimum 3 points) -- **strokeColor**: Outline color (Int from Color class) +- **strokeColor**: Outline color (Compose Color) - **strokeWidth**: Outline width in pixels (Float) - **fillColor**: Interior fill color with alpha channel support - **holes**: List of hole definitions, each a List (minimum 3 points per hole) +- **geodesic**: Draw edges as great-circle paths (Boolean) +- **clickable**: Enable click handling (Boolean) - **tag**: Optional user data (Any?) ## Technical Details @@ -166,70 +195,9 @@ Shapes are drawn in this order (bottom to top): 4. Markers (icons) 5. Attribution overlay -Shapes of the same type are drawn in the order they were added. - -### Coordinate System - -- Uses Web Mercator projection (EPSG:3857) -- Geographic coordinates (LatLng) automatically converted to screen pixels -- Shapes properly transform during pan and zoom operations - -### Performance - -- Paths are created on-the-fly during rendering -- Paint objects configured per shape for accurate styling -- Anti-aliasing enabled for smooth lines -- Round caps and joins for professional appearance - -## Advanced Usage - -### Create Complex Routes - -```kotlin -val route = Polyline( - points = listOf( - LatLng(51.4700, 7.2400), - LatLng(51.4680, 7.2450), - // ... add more waypoints - ), - strokeColor = Color.BLUE, - strokeWidth = 10f, - tag = "main_route" // Custom metadata -) -addPolyline(route) -``` - -### Create Multi-Hole Polygons - -```kotlin -val complexPolygon = Polygon( - points = outerBoundary, - holes = listOf( - hole1Points, - hole2Points, - hole3Points, - ), - strokeColor = Color.BLACK, - strokeWidth = 2f, - fillColor = Color.argb(80, 255, 255, 0), -) -addPolygon(complexPolygon) -``` - -### Remove Shapes - -```kotlin -val polyline = addPolyline(...) -// Later: -removePolyline(polyline) - -// Or remove all: -clearPolylines() -clearPolygons() -``` - -## Map Location +### Immutable Overlays -**Default Center:** Bochum, Germany (51.4661°N, 7.2491°E) at zoom 14.0 +Polyline and Polygon are immutable data classes. To modify an overlay (like changing stroke width for highlight), you must: -All shapes are positioned around Bochum within approximately 1km radius. +1. Remove the original overlay +2. Add a new overlay with modified properties diff --git a/examples/Example04Polylines/screenshot.gif b/examples/Example04Polylines/screenshot.gif index ee295d8..e153768 100644 Binary files a/examples/Example04Polylines/screenshot.gif and b/examples/Example04Polylines/screenshot.gif differ diff --git a/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/Colors.kt b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/Colors.kt new file mode 100644 index 0000000..2ba531a --- /dev/null +++ b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/Colors.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview.example04polylines + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Constants for the Example04Polylines app: colors and dimensions. + */ + +/** Green color used by OpenStreetMap for parks and forests. */ +val OsmParkGreen = Color(0xFFAAD3A2) + +/** Pink color used by OpenStreetMap for highways and major roads. */ +val OsmHighwayPink = Color(0xFFE892A2) + +/** Blue color used by OpenStreetMap for water areas (lakes, rivers). */ +val OsmWaterBlue = Color(0xFFAAD3DF) + +/** Shared corner radius for all toolbar components (matches Material3 FAB). */ +val ToolbarCornerRadius = 16.dp diff --git a/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/MainActivity.kt b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/MainActivity.kt index ad14671..da9f05a 100644 --- a/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/MainActivity.kt +++ b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/MainActivity.kt @@ -8,22 +8,48 @@ package de.afarber.openmapview.example04polylines import android.os.Bundle -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Highlight +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.platform.LocalContext +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LocalLifecycleOwner +import de.afarber.openmapview.CameraUpdateFactory import de.afarber.openmapview.LatLng +import de.afarber.openmapview.OnCameraMoveStartedListener import de.afarber.openmapview.OpenMapView import de.afarber.openmapview.Polygon import de.afarber.openmapview.Polyline +/** + * Main activity demonstrating OpenMapView polyline and polygon navigation. + * + * This example showcases: + * - Displaying polylines and polygons at real Bochum locations + * - Navigating between overlays with prev/next buttons + * - Highlighting selected overlay with thicker stroke via FAB + * - Camera animation when centering on overlays + * - Real-time selection index and highlight state tracking + * - Camera state monitoring + */ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -40,106 +66,267 @@ class MainActivity : ComponentActivity() { } } +/** + * Calculates the center point of an overlay. + */ +private fun getOverlayCenter(overlay: OverlayData): LatLng = LatLng( + overlay.points.map { it.latitude }.average(), + overlay.points.map { it.longitude }.average(), +) + +/** + * Calculates an appropriate zoom level to fit the overlay on screen. + */ +private fun getOverlayZoom(overlay: OverlayData): Float { + val latitudes = overlay.points.map { it.latitude } + val longitudes = overlay.points.map { it.longitude } + val latSpan = latitudes.max() - latitudes.min() + val lonSpan = longitudes.max() - longitudes.min() + val maxSpan = maxOf(latSpan, lonSpan) + + // Calculate zoom level based on geographic span + return when { + maxSpan > 5.0 -> 6f // Very large (e.g., Bochum to Berlin) + maxSpan > 1.0 -> 8f + maxSpan > 0.1 -> 12f + maxSpan > 0.01 -> 14f + else -> 15f + } +} + +/** + * Main composable screen containing the map and overlay navigation controls. + * + * Displays an OpenMapView with polylines and polygons at notable Bochum locations, + * a status toolbar showing selection state, and an overlay toolbar for navigation. + */ @Composable fun MapViewScreen() { - val context = LocalContext.current - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - - AndroidView( - factory = { ctx -> - OpenMapView(ctx).apply { - // Register lifecycle observer for proper cleanup - lifecycleOwner.lifecycle.addObserver(this) - - // Center on Bochum, Germany - setCenter(LatLng(51.4661, 7.2491)) - setZoom(14.0f) - - // Add a blue polyline (route) - addPolyline( - Polyline( - points = listOf( - LatLng(51.4700, 7.2400), - LatLng(51.4680, 7.2450), - LatLng(51.4650, 7.2500), - LatLng(51.4620, 7.2550), - ), - strokeColor = Color.Blue, - strokeWidth = 8f, - clickable = true, - tag = "Blue Route", - ), - ) + val lifecycleOwner = LocalLifecycleOwner.current - // Add a red polyline (path) - addPolyline( - Polyline( - points = listOf( - LatLng(51.4620, 7.2430), - LatLng(51.4640, 7.2460), - LatLng(51.4660, 7.2490), - LatLng(51.4680, 7.2520), - LatLng(51.4700, 7.2550), - ), - strokeColor = Color.Red, - strokeWidth = 6f, - clickable = true, - tag = "Red Path", - ), - ) + // Initial location centered on the first overlay + val initialLocation = getOverlayCenter(poiOverlays.first()) + val initialZoom = 14.0f - // Add a green polygon (park or area) - addPolygon( - Polygon( - points = listOf( - LatLng(51.4640, 7.2380), - LatLng(51.4660, 7.2380), - LatLng(51.4660, 7.2420), - LatLng(51.4640, 7.2420), - ), - strokeColor = Color(red = 0, green = 128, blue = 0), - strokeWidth = 4f, - fillColor = Color(red = 0, green = 255, blue = 0, alpha = 100), - clickable = true, - tag = "Green Park", - ), - ) + // State variables + var mapView: OpenMapView? by remember { mutableStateOf(null) } + var selectedIndex by remember { mutableIntStateOf(0) } + var cameraState by remember { mutableStateOf("Idle") } + var isHighlighted by remember { mutableStateOf(false) } + + // Track created overlays for reference + var createdPolylines by remember { mutableStateOf>(emptyList()) } + var createdPolygons by remember { mutableStateOf>(emptyList()) } + + // Derived state - current overlay data + val selectedOverlay: OverlayData? = poiOverlays.getOrNull(selectedIndex) + + /** + * Creates overlays on the map from the predefined data. + */ + fun createOverlays(map: OpenMapView, highlighted: Boolean = false, highlightIndex: Int = -1) { + val polylines = mutableListOf() + val polygons = mutableListOf() - // Add a cyan polygon with a hole (donut shape) - addPolygon( - Polygon( - points = listOf( - LatLng(51.4700, 7.2580), - LatLng(51.4720, 7.2580), - LatLng(51.4720, 7.2620), - LatLng(51.4700, 7.2620), - ), - holes = listOf( - listOf( - LatLng(51.4706, 7.2590), - LatLng(51.4714, 7.2590), - LatLng(51.4714, 7.2610), - LatLng(51.4706, 7.2610), - ), - ), - strokeColor = Color.Cyan, - strokeWidth = 4f, - fillColor = Color(red = 0, green = 255, blue = 255, alpha = 100), + poiOverlays.forEachIndexed { index, data -> + val isThisHighlighted = highlighted && index == highlightIndex + val strokeMultiplier = if (isThisHighlighted) 2.0f else 1.0f + + when (data) { + is PolylineData -> { + val polyline = Polyline( + points = data.points, + strokeColor = data.color, + strokeWidth = data.width * strokeMultiplier, + strokePattern = if (isThisHighlighted) PathEffect.dashPathEffect(floatArrayOf(40f, 20f)) else null, + geodesic = data.geodesic, clickable = true, - tag = "Cyan Donut", - ), - ) + tag = data.title, + ) + map.addPolyline(polyline) + polylines.add(polyline) + } + is PolygonData -> { + val polygon = Polygon( + points = data.points, + holes = data.holes, + strokeColor = data.strokeColor, + strokeWidth = 4f * strokeMultiplier, + fillColor = data.fillColor, + clickable = true, + tag = data.title, + ) + map.addPolygon(polygon) + polygons.add(polygon) + } + } + } + + createdPolylines = polylines + createdPolygons = polygons + } + + /** + * Recreates all overlays with updated highlight state. + */ + fun updateHighlight(map: OpenMapView, highlighted: Boolean, highlightIndex: Int) { + // Remove all existing overlays + createdPolylines.forEach { map.removePolyline(it) } + createdPolygons.forEach { map.removePolygon(it) } + + // Recreate with new highlight state + createOverlays(map, highlighted, highlightIndex) + } + + /** + * Gets the overlay type as a string. + */ + fun getOverlayType(overlay: OverlayData): String = when (overlay) { + is PolylineData -> "Polyline" + else -> "Polygon" + } + + /** + * Finds the index of an overlay by its title. + */ + fun findOverlayIndexByTitle(title: String): Int = poiOverlays.indexOfFirst { it.title == title }.coerceAtLeast(0) + + Box(modifier = Modifier.fillMaxSize()) { + // Map view + AndroidView( + factory = { ctx -> + OpenMapView(ctx).apply { + lifecycleOwner.lifecycle.addObserver(this) + + setCenter(initialLocation) + setZoom(initialZoom) + + createOverlays(this) + + setOnCameraMoveStartedListener { reason -> + cameraState = when (reason) { + OnCameraMoveStartedListener.REASON_GESTURE -> "Moving (gesture)" + OnCameraMoveStartedListener.REASON_API_ANIMATION -> "Moving (animation)" + OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION -> "Moving (programmatic)" + else -> "Moving" + } + } - // Set click listeners - setOnPolylineClickListener { polyline -> - Toast.makeText(context, "Clicked: ${polyline.tag}", Toast.LENGTH_SHORT).show() + setOnCameraIdleListener { + cameraState = "Idle" + } + + setOnPolylineClickListener { polyline -> + val tag = polyline.tag as? String ?: return@setOnPolylineClickListener + val newIndex = findOverlayIndexByTitle(tag) + if (newIndex != selectedIndex) { + // Turn off highlight when selecting different overlay + if (isHighlighted) { + isHighlighted = false + mapView?.let { updateHighlight(it, false, -1) } + } + } + selectedIndex = newIndex + val overlay = poiOverlays[newIndex] + animateCamera( + CameraUpdateFactory.newLatLngZoom(getOverlayCenter(overlay), getOverlayZoom(overlay)), + 500, + ) + } + + setOnPolygonClickListener { polygon -> + val tag = polygon.tag as? String ?: return@setOnPolygonClickListener + val newIndex = findOverlayIndexByTitle(tag) + if (newIndex != selectedIndex) { + // Turn off highlight when selecting different overlay + if (isHighlighted) { + isHighlighted = false + mapView?.let { updateHighlight(it, false, -1) } + } + } + selectedIndex = newIndex + val overlay = poiOverlays[newIndex] + animateCamera( + CameraUpdateFactory.newLatLngZoom(getOverlayCenter(overlay), getOverlayZoom(overlay)), + 500, + ) + } + + mapView = this } + }, + modifier = Modifier.fillMaxSize(), + ) - setOnPolygonClickListener { polygon -> - Toast.makeText(context, "Clicked: ${polygon.tag}", Toast.LENGTH_SHORT).show() + // Status overlay at left + StatusToolbar( + totalCount = poiOverlays.size, + selectedIndex = selectedIndex, + overlayTitle = selectedOverlay?.title ?: "None", + overlayType = selectedOverlay?.let { getOverlayType(it) } ?: "", + cameraState = cameraState, + isHighlighted = isHighlighted, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(16.dp), + ) + + // Overlay toolbar at bottom + OverlayToolbar( + onPrevClick = { + val newIndex = (selectedIndex - 1 + poiOverlays.size) % poiOverlays.size + if (isHighlighted && newIndex != selectedIndex) { + isHighlighted = false + mapView?.let { updateHighlight(it, false, -1) } } - } - }, - modifier = Modifier.fillMaxSize(), - ) + selectedIndex = newIndex + val overlay = poiOverlays[newIndex] + mapView?.animateCamera( + CameraUpdateFactory.newLatLngZoom(getOverlayCenter(overlay), getOverlayZoom(overlay)), + 500, + ) + }, + onNextClick = { + val newIndex = (selectedIndex + 1) % poiOverlays.size + if (isHighlighted && newIndex != selectedIndex) { + isHighlighted = false + mapView?.let { updateHighlight(it, false, -1) } + } + selectedIndex = newIndex + val overlay = poiOverlays[newIndex] + mapView?.animateCamera( + CameraUpdateFactory.newLatLngZoom(getOverlayCenter(overlay), getOverlayZoom(overlay)), + 500, + ) + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + ) + + // FAB for highlight toggle + FloatingActionButton( + onClick = { + isHighlighted = !isHighlighted + val overlay = poiOverlays[selectedIndex] + mapView?.let { + updateHighlight(it, isHighlighted, selectedIndex) + it.animateCamera( + CameraUpdateFactory.newLatLngZoom(getOverlayCenter(overlay), getOverlayZoom(overlay)), + 500, + ) + } + }, + containerColor = if (isHighlighted) OsmHighwayPink else OsmWaterBlue, + contentColor = Color.Black, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.Default.Highlight, + contentDescription = "Toggle Highlight", + ) + } + } } diff --git a/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/OverlayData.kt b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/OverlayData.kt new file mode 100644 index 0000000..326c1f2 --- /dev/null +++ b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/OverlayData.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview.example04polylines + +import androidx.compose.ui.graphics.Color +import de.afarber.openmapview.LatLng + +/** + * Common interface for overlay data classes. + */ +interface OverlayData { + val title: String + val snippet: String + val points: List +} + +/** + * Data class representing a polyline with its display properties. + * + * @param title Display title for the polyline. + * @param snippet Additional description text. + * @param points List of coordinates defining the polyline path. + * @param color Stroke color for the polyline. + * @param width Stroke width in pixels. + * @param geodesic Whether segments are drawn as geodesics (great-circle paths). + */ +data class PolylineData( + override val title: String, + override val snippet: String, + override val points: List, + val color: Color, + val width: Float, + val geodesic: Boolean = false, +) : OverlayData + +/** + * Data class representing a polygon with its display properties. + * + * @param title Display title for the polygon. + * @param snippet Additional description text. + * @param points List of coordinates defining the polygon outline. + * @param holes List of hole definitions (each hole is a list of coordinates). + * @param strokeColor Stroke color for the polygon outline. + * @param fillColor Fill color for the polygon interior. + */ +data class PolygonData( + override val title: String, + override val snippet: String, + override val points: List, + val holes: List> = emptyList(), + val strokeColor: Color, + val fillColor: Color, +) : OverlayData + +/** + * Predefined overlays at notable Bochum locations. + * + * Includes real routes and areas in Bochum, Germany: + * - Springorum cycling path (former railway line) + * - Ruhr riverside walk + * - Hauptbahnhof to Bermuda3eck route + * - Stadtpark (city park) + * - Westpark + * - Ruhr University campus + */ +val poiOverlays: List = listOf( + // Ruhr University Campus - first in the list (polygon with hole) + PolygonData( + title = "Ruhr University Campus", + snippet = "University grounds with lake", + points = listOf( + LatLng(51.4410, 7.2550), + LatLng(51.4480, 7.2540), + LatLng(51.4500, 7.2620), + LatLng(51.4490, 7.2720), + LatLng(51.4420, 7.2730), + LatLng(51.4400, 7.2650), + ), + holes = listOf( + // Kemnader See (lake) as a hole + listOf( + LatLng(51.4440, 7.2620), + LatLng(51.4460, 7.2615), + LatLng(51.4465, 7.2660), + LatLng(51.4445, 7.2665), + ), + ), + strokeColor = Color(0xFF3F51B5), // Indigo + fillColor = Color(0x663F51B5), // Semi-transparent indigo + ), + // Polylines - Real Bochum routes + PolylineData( + title = "Springorum Radweg", + snippet = "Cycling path on old railway", + points = listOf( + LatLng(51.4565, 7.2145), + LatLng(51.4590, 7.2210), + LatLng(51.4620, 7.2280), + LatLng(51.4655, 7.2350), + LatLng(51.4690, 7.2420), + ), + color = Color(0xFF2196F3), // Blue + width = 8f, + ), + PolylineData( + title = "Ruhr Riverside Walk", + snippet = "Scenic walk along the Ruhr", + points = listOf( + LatLng(51.4380, 7.2350), + LatLng(51.4375, 7.2420), + LatLng(51.4365, 7.2490), + LatLng(51.4360, 7.2560), + LatLng(51.4355, 7.2630), + LatLng(51.4350, 7.2700), + ), + color = Color(0xFF00BCD4), // Cyan + width = 6f, + ), + PolylineData( + title = "Hbf to Bermuda3eck", + snippet = "City center walking route", + points = listOf( + LatLng(51.4783, 7.2231), // Hauptbahnhof + LatLng(51.4778, 7.2210), + LatLng(51.4772, 7.2190), + LatLng(51.4765, 7.2175), + LatLng(51.4761, 7.2161), // Bermuda3eck + ), + color = Color(0xFFFF5722), // Deep Orange + width = 7f, + ), + // Geodesic polyline - Long distance route (demonstrates curved great-circle path) + PolylineData( + title = "Bochum to Berlin", + snippet = "Geodesic (great-circle) path", + points = listOf( + LatLng(51.4818, 7.2162), // Bochum + LatLng(52.5200, 13.4050), // Berlin + ), + color = Color(0xFFE91E63), // Pink + width = 5f, + geodesic = true, + ), + // Polygons - Real Bochum areas + PolygonData( + title = "Stadtpark", + snippet = "Central city park", + points = listOf( + LatLng(51.4820, 7.2260), + LatLng(51.4850, 7.2260), + LatLng(51.4870, 7.2310), + LatLng(51.4860, 7.2360), + LatLng(51.4830, 7.2370), + LatLng(51.4810, 7.2330), + ), + strokeColor = Color(0xFF4CAF50), // Green + fillColor = Color(0x664CAF50), // Semi-transparent green + ), + PolygonData( + title = "Westpark", + snippet = "Western recreational park", + points = listOf( + LatLng(51.4750, 7.1980), + LatLng(51.4780, 7.1990), + LatLng(51.4790, 7.2050), + LatLng(51.4770, 7.2080), + LatLng(51.4740, 7.2060), + LatLng(51.4735, 7.2010), + ), + strokeColor = Color(0xFF8BC34A), // Light Green + fillColor = Color(0x668BC34A), // Semi-transparent light green + ), +) diff --git a/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/OverlayToolbar.kt b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/OverlayToolbar.kt new file mode 100644 index 0000000..058bd3f --- /dev/null +++ b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/OverlayToolbar.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview.example04polylines + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardDoubleArrowLeft +import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * A horizontal toolbar with buttons to navigate between overlays. + * + * Both buttons use OsmParkGreen for visual consistency with OSM styling. + * + * @param onPrevClick Callback invoked when the previous overlay button is clicked. + * @param onNextClick Callback invoked when the next overlay button is clicked. + * @param modifier Modifier to be applied to the toolbar. + */ +@Composable +fun OverlayToolbar( + onPrevClick: () -> Unit, + onNextClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(ToolbarCornerRadius), + shadowElevation = 6.dp, + color = MaterialTheme.colorScheme.surface, + ) { + Row { + FilledIconButton( + onClick = onPrevClick, + modifier = Modifier.size(56.dp), + shape = RoundedCornerShape(topStart = ToolbarCornerRadius, bottomStart = ToolbarCornerRadius), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = OsmParkGreen, + contentColor = Color.Black, + ), + ) { + Icon(Icons.Default.KeyboardDoubleArrowLeft, contentDescription = "Previous Overlay") + } + FilledIconButton( + onClick = onNextClick, + modifier = Modifier.size(56.dp), + shape = RoundedCornerShape(topEnd = ToolbarCornerRadius, bottomEnd = ToolbarCornerRadius), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = OsmParkGreen, + contentColor = Color.Black, + ), + ) { + Icon(Icons.Default.KeyboardDoubleArrowRight, contentDescription = "Next Overlay") + } + } + } +} diff --git a/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/StatusToolbar.kt b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/StatusToolbar.kt new file mode 100644 index 0000000..ae355d9 --- /dev/null +++ b/examples/Example04Polylines/src/main/kotlin/de/afarber/openmapview/example04polylines/StatusToolbar.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview.example04polylines + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * A status overlay displaying current overlay selection and camera state. + * + * Shows the currently selected overlay index, title, type, and camera state. + * The index text turns red when the overlay is highlighted. + * + * @param totalCount Total number of overlays. + * @param selectedIndex The index of the currently selected overlay. + * @param overlayTitle Title of the currently selected overlay. + * @param overlayType Type of overlay ("Polyline" or "Polygon"). + * @param cameraState Current camera state description (e.g., "Idle", "Moving (gesture)"). + * @param isHighlighted Whether the overlay is currently highlighted. + * @param modifier Modifier to be applied to the status overlay. + */ +@Composable +fun StatusToolbar( + totalCount: Int, + selectedIndex: Int, + overlayTitle: String, + overlayType: String, + cameraState: String, + isHighlighted: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(ToolbarCornerRadius)) + .background(Color.White) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Overlay #${selectedIndex + 1} of $totalCount", + color = if (isHighlighted) Color.Red else Color.Black, + ) + Text( + text = overlayTitle, + color = Color.Black, + ) + Text( + text = overlayType, + color = Color.Gray, + ) + Text( + text = "Camera: $cameraState", + color = Color.Black, + ) + } +} diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/GeodesicUtils.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/GeodesicUtils.kt new file mode 100644 index 0000000..e6cb497 --- /dev/null +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/GeodesicUtils.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Utility object for geodesic (great-circle) calculations. + * + * Provides functions for calculating distances and interpolating points + * along the shortest path on Earth's surface between two coordinates. + */ +internal object GeodesicUtils { + /** Earth's mean radius in meters. */ + private const val EARTH_RADIUS_METERS = 6371000.0 + + /** Minimum distance in meters to consider for interpolation. */ + private const val MIN_INTERPOLATION_DISTANCE = 1000.0 + + /** Distance between interpolated points in meters. */ + private const val INTERPOLATION_STEP_METERS = 50000.0 + + /** + * Calculates the great-circle distance between two points using the Haversine formula. + * + * @param from Starting coordinate. + * @param to Ending coordinate. + * @return Distance in meters. + */ + fun haversineDistance( + from: LatLng, + to: LatLng, + ): Double { + val lat1 = Math.toRadians(from.latitude) + val lat2 = Math.toRadians(to.latitude) + val deltaLat = Math.toRadians(to.latitude - from.latitude) + val deltaLon = Math.toRadians(to.longitude - from.longitude) + + val a = + sin(deltaLat / 2) * sin(deltaLat / 2) + + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + return EARTH_RADIUS_METERS * c + } + + /** + * Interpolates points along the great-circle path between two coordinates. + * + * Uses spherical linear interpolation (slerp) to generate intermediate points + * that follow the shortest path on Earth's surface. + * + * @param from Starting coordinate. + * @param to Ending coordinate. + * @param numPoints Number of intermediate points to generate (not including from/to). + * @return List of intermediate LatLng points. Empty if numPoints <= 0. + */ + fun interpolateGreatCircle( + from: LatLng, + to: LatLng, + numPoints: Int, + ): List { + if (numPoints <= 0) return emptyList() + + val lat1 = Math.toRadians(from.latitude) + val lon1 = Math.toRadians(from.longitude) + val lat2 = Math.toRadians(to.latitude) + val lon2 = Math.toRadians(to.longitude) + + // Calculate angular distance + val deltaLat = lat2 - lat1 + val deltaLon = lon2 - lon1 + val a = + sin(deltaLat / 2) * sin(deltaLat / 2) + + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) + val angularDistance = 2 * asin(sqrt(a)) + + // If points are very close, no interpolation needed + if (angularDistance < 1e-10) return emptyList() + + val points = mutableListOf() + + for (i in 1..numPoints) { + val fraction = i.toDouble() / (numPoints + 1) + + // Spherical linear interpolation + val factorA = sin((1 - fraction) * angularDistance) / sin(angularDistance) + val factorB = sin(fraction * angularDistance) / sin(angularDistance) + + val x = factorA * cos(lat1) * cos(lon1) + factorB * cos(lat2) * cos(lon2) + val y = factorA * cos(lat1) * sin(lon1) + factorB * cos(lat2) * sin(lon2) + val z = factorA * sin(lat1) + factorB * sin(lat2) + + val lat = atan2(z, sqrt(x * x + y * y)) + val lon = atan2(y, x) + + points.add(LatLng(Math.toDegrees(lat), Math.toDegrees(lon))) + } + + return points + } + + /** + * Expands a list of points with geodesic interpolation between each consecutive pair. + * + * Automatically determines the number of interpolation points based on distance, + * using approximately one point per [INTERPOLATION_STEP_METERS] meters. + * + * @param points Original list of coordinates. + * @return Expanded list with interpolated points inserted between original points. + */ + fun expandWithGeodesicPoints(points: List): List { + if (points.size < 2) return points + + val expanded = mutableListOf() + + for (i in 0 until points.size - 1) { + expanded.add(points[i]) + + val distance = haversineDistance(points[i], points[i + 1]) + if (distance > MIN_INTERPOLATION_DISTANCE) { + val numPoints = (distance / INTERPOLATION_STEP_METERS).toInt().coerceAtLeast(1) + val interpolated = interpolateGreatCircle(points[i], points[i + 1], numPoints) + expanded.addAll(interpolated) + } + } + + expanded.add(points.last()) + return expanded + } +} diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt index 34aceb9..4c47a32 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt @@ -1316,29 +1316,119 @@ class MapController( for (polyline in polylines) { if (!polyline.visible || polyline.points.size < 2) continue - paint.color = polyline.strokeColor.toArgb() - paint.strokeWidth = polyline.strokeWidth - paint.pathEffect = polyline.strokePattern?.asAndroidPathEffect() - paint.strokeCap = polyline.strokeCap.toAndroidCap() - paint.strokeJoin = polyline.strokeJoin.toAndroidJoin() + // Expand points with geodesic interpolation if needed + val points = + if (polyline.geodesic) { + GeodesicUtils.expandWithGeodesicPoints(polyline.points) + } else { + polyline.points + } - val path = android.graphics.Path() - var isFirst = true + // Handle spans (multi-color segments) or single color + if (polyline.spans.isNotEmpty()) { + drawPolylineWithSpans(canvas, polyline, points, centerPixelX, centerPixelY) + } else { + paint.color = polyline.strokeColor.toArgb() + paint.strokeWidth = polyline.strokeWidth + paint.pathEffect = polyline.strokePattern?.asAndroidPathEffect() + paint.strokeCap = polyline.startCap.toAndroidCap() // Use startCap for full path + paint.strokeJoin = polyline.strokeJoin.toAndroidJoin() - for (point in polyline.points) { - val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) - val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() - val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + val path = android.graphics.Path() + var isFirst = true - if (isFirst) { - path.moveTo(screenX, screenY) - isFirst = false - } else { - path.lineTo(screenX, screenY) + for (point in points) { + val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) + val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + + if (isFirst) { + path.moveTo(screenX, screenY) + isFirst = false + } else { + path.lineTo(screenX, screenY) + } } + + canvas.drawPath(path, paint) + } + } + } + + private fun drawPolylineWithSpans( + canvas: Canvas, + polyline: Polyline, + points: List, + centerPixelX: Float, + centerPixelY: Float, + ) { + val paint = Paint() + paint.style = Paint.Style.STROKE + paint.isAntiAlias = true + paint.strokeWidth = polyline.strokeWidth + paint.pathEffect = polyline.strokePattern?.asAndroidPathEffect() + paint.strokeJoin = polyline.strokeJoin.toAndroidJoin() + + var segmentIndex = 0 + val totalSegments = points.size - 1 + + for (span in polyline.spans) { + if (segmentIndex >= totalSegments) break + + paint.color = span.color.toArgb() + + for (i in 0 until span.segments) { + if (segmentIndex >= totalSegments) break + + val isFirstSegment = segmentIndex == 0 + val isLastSegment = segmentIndex == totalSegments - 1 + + // Apply appropriate cap + paint.strokeCap = + when { + isFirstSegment -> polyline.startCap.toAndroidCap() + isLastSegment -> polyline.endCap.toAndroidCap() + else -> Paint.Cap.BUTT + } + + val from = points[segmentIndex] + val to = points[segmentIndex + 1] + + val (fromPixelX, fromPixelY) = ProjectionUtils.latLngToPixel(from, zoom.toInt()) + val fromScreenX = (fromPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val fromScreenY = (fromPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + + val (toPixelX, toPixelY) = ProjectionUtils.latLngToPixel(to, zoom.toInt()) + val toScreenX = (toPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val toScreenY = (toPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + + canvas.drawLine(fromScreenX, fromScreenY, toScreenX, toScreenY, paint) + segmentIndex++ } + } + + // Draw remaining segments with default color + if (segmentIndex < totalSegments) { + paint.color = polyline.strokeColor.toArgb() + + while (segmentIndex < totalSegments) { + val isLastSegment = segmentIndex == totalSegments - 1 + paint.strokeCap = if (isLastSegment) polyline.endCap.toAndroidCap() else Paint.Cap.BUTT + + val from = points[segmentIndex] + val to = points[segmentIndex + 1] + + val (fromPixelX, fromPixelY) = ProjectionUtils.latLngToPixel(from, zoom.toInt()) + val fromScreenX = (fromPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val fromScreenY = (fromPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() - canvas.drawPath(path, paint) + val (toPixelX, toPixelY) = ProjectionUtils.latLngToPixel(to, zoom.toInt()) + val toScreenX = (toPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val toScreenY = (toPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + + canvas.drawLine(fromScreenX, fromScreenY, toScreenX, toScreenY, paint) + segmentIndex++ + } } } @@ -1358,6 +1448,14 @@ class MapController( for (polygon in polygons) { if (!polygon.visible || polygon.points.size < 3) continue + // Expand points with geodesic interpolation if needed + val points = + if (polygon.geodesic) { + GeodesicUtils.expandWithGeodesicPoints(polygon.points) + } else { + polygon.points + } + fillPaint.color = polygon.fillColor.toArgb() strokePaint.color = polygon.strokeColor.toArgb() strokePaint.strokeWidth = polygon.strokeWidth @@ -1370,7 +1468,7 @@ class MapController( // Draw main polygon outline var isFirst = true - for (point in polygon.points) { + for (point in points) { val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() @@ -1387,8 +1485,15 @@ class MapController( // Draw holes for (hole in polygon.holes) { if (hole.size < 3) continue + // Expand hole points with geodesic interpolation if needed + val holePoints = + if (polygon.geodesic) { + GeodesicUtils.expandWithGeodesicPoints(hole) + } else { + hole + } isFirst = true - for (point in hole) { + for (point in holePoints) { val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() @@ -1600,6 +1705,14 @@ class MapController( for (polygon in sortedPolygons) { if (polygon.zIndex != zIndex || !polygon.visible || polygon.points.size < 3) continue + // Expand points with geodesic interpolation if needed + val points = + if (polygon.geodesic) { + GeodesicUtils.expandWithGeodesicPoints(polygon.points) + } else { + polygon.points + } + fillPaint.color = polygon.fillColor.toArgb() strokePaint.color = polygon.strokeColor.toArgb() strokePaint.strokeWidth = polygon.strokeWidth @@ -1610,7 +1723,7 @@ class MapController( val path = android.graphics.Path() var isFirst = true - for (point in polygon.points) { + for (point in points) { val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() @@ -1626,8 +1739,15 @@ class MapController( for (hole in polygon.holes) { if (hole.size < 3) continue + // Expand hole points with geodesic interpolation if needed + val holePoints = + if (polygon.geodesic) { + GeodesicUtils.expandWithGeodesicPoints(hole) + } else { + hole + } var isFirstHole = true - for (point in hole) { + for (point in holePoints) { val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() @@ -1662,29 +1782,119 @@ class MapController( for (polyline in sortedPolylines) { if (polyline.zIndex != zIndex || !polyline.visible || polyline.points.size < 2) continue - paint.color = polyline.strokeColor.toArgb() - paint.strokeWidth = polyline.strokeWidth - paint.pathEffect = polyline.strokePattern?.asAndroidPathEffect() - paint.strokeCap = polyline.strokeCap.toAndroidCap() - paint.strokeJoin = polyline.strokeJoin.toAndroidJoin() + // Expand points with geodesic interpolation if needed + val points = + if (polyline.geodesic) { + GeodesicUtils.expandWithGeodesicPoints(polyline.points) + } else { + polyline.points + } - val path = android.graphics.Path() - var isFirst = true + // Handle spans (multi-color segments) or single color + if (polyline.spans.isNotEmpty()) { + drawPolylineWithSpansZIndex(canvas, polyline, points, centerPixelX, centerPixelY) + } else { + paint.color = polyline.strokeColor.toArgb() + paint.strokeWidth = polyline.strokeWidth + paint.pathEffect = polyline.strokePattern?.asAndroidPathEffect() + paint.strokeCap = polyline.startCap.toAndroidCap() // Use startCap for full path + paint.strokeJoin = polyline.strokeJoin.toAndroidJoin() - for (point in polyline.points) { - val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) - val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() - val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + val path = android.graphics.Path() + var isFirst = true - if (isFirst) { - path.moveTo(screenX, screenY) - isFirst = false - } else { - path.lineTo(screenX, screenY) + for (point in points) { + val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt()) + val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + + if (isFirst) { + path.moveTo(screenX, screenY) + isFirst = false + } else { + path.lineTo(screenX, screenY) + } } + + canvas.drawPath(path, paint) } + } + } + + private fun drawPolylineWithSpansZIndex( + canvas: Canvas, + polyline: Polyline, + points: List, + centerPixelX: Float, + centerPixelY: Float, + ) { + val paint = Paint() + paint.style = Paint.Style.STROKE + paint.isAntiAlias = true + paint.strokeWidth = polyline.strokeWidth + paint.pathEffect = polyline.strokePattern?.asAndroidPathEffect() + paint.strokeJoin = polyline.strokeJoin.toAndroidJoin() + + var segmentIndex = 0 + val totalSegments = points.size - 1 + + for (span in polyline.spans) { + if (segmentIndex >= totalSegments) break + + paint.color = span.color.toArgb() + + for (i in 0 until span.segments) { + if (segmentIndex >= totalSegments) break + + val isFirstSegment = segmentIndex == 0 + val isLastSegment = segmentIndex == totalSegments - 1 - canvas.drawPath(path, paint) + // Apply appropriate cap + paint.strokeCap = + when { + isFirstSegment -> polyline.startCap.toAndroidCap() + isLastSegment -> polyline.endCap.toAndroidCap() + else -> Paint.Cap.BUTT + } + + val from = points[segmentIndex] + val to = points[segmentIndex + 1] + + val (fromPixelX, fromPixelY) = ProjectionUtils.latLngToPixel(from, zoom.toInt()) + val fromScreenX = (fromPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val fromScreenY = (fromPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + + val (toPixelX, toPixelY) = ProjectionUtils.latLngToPixel(to, zoom.toInt()) + val toScreenX = (toPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val toScreenY = (toPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + + canvas.drawLine(fromScreenX, fromScreenY, toScreenX, toScreenY, paint) + segmentIndex++ + } + } + + // Draw remaining segments with default color + if (segmentIndex < totalSegments) { + paint.color = polyline.strokeColor.toArgb() + + while (segmentIndex < totalSegments) { + val isLastSegment = segmentIndex == totalSegments - 1 + paint.strokeCap = if (isLastSegment) polyline.endCap.toAndroidCap() else Paint.Cap.BUTT + + val from = points[segmentIndex] + val to = points[segmentIndex + 1] + + val (fromPixelX, fromPixelY) = ProjectionUtils.latLngToPixel(from, zoom.toInt()) + val fromScreenX = (fromPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val fromScreenY = (fromPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + + val (toPixelX, toPixelY) = ProjectionUtils.latLngToPixel(to, zoom.toInt()) + val toScreenX = (toPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat() + val toScreenY = (toPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat() + + canvas.drawLine(fromScreenX, fromScreenY, toScreenX, toScreenY, paint) + segmentIndex++ + } } } diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/Polygon.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/Polygon.kt index 6c99c33..4c15f50 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/Polygon.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/Polygon.kt @@ -20,10 +20,12 @@ import androidx.compose.ui.graphics.StrokeJoin * @property strokeColor Color of the polygon outline (default: black) * @property strokeWidth Width of the outline in pixels (default: 10f) * @property strokePattern Pattern for the stroke (dashed, dotted, etc.). Null means solid line (default: null) - * @property strokeCap Shape of line endpoints (default: Round) + * @property strokeCap Shape of line endpoints (default: Butt) * @property strokeJoin Shape of line corners (default: Round) * @property fillColor Fill color for the polygon interior (default: semi-transparent gray) * @property holes List of hole definitions, where each hole is a list of LatLng points + * @property geodesic Whether segments are drawn as geodesics (great-circle paths) instead of straight + * lines on the Mercator projection. Default is false. * @property visible Whether the polygon is visible. Default is true * @property clickable Whether the polygon is clickable. Default is false * @property zIndex Draw order. Polygons with higher zIndex are drawn on top. Default is 0.0 @@ -34,10 +36,11 @@ data class Polygon( val strokeColor: Color = Color.Black, val strokeWidth: Float = 10f, val strokePattern: PathEffect? = null, - val strokeCap: StrokeCap = StrokeCap.Round, + val strokeCap: StrokeCap = StrokeCap.Butt, val strokeJoin: StrokeJoin = StrokeJoin.Round, val fillColor: Color = Color(red = 128, green = 128, blue = 128, alpha = 128), val holes: List> = emptyList(), + val geodesic: Boolean = false, val visible: Boolean = true, val clickable: Boolean = false, val zIndex: Float = 0f, diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/PolygonOptions.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/PolygonOptions.kt index 45ba830..d5cac28 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/PolygonOptions.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/PolygonOptions.kt @@ -33,9 +33,10 @@ class PolygonOptions { private var strokeColor: Color = Color.Black private var strokeWidth: Float = 10f private var strokePattern: PathEffect? = null - private var strokeCap: StrokeCap = StrokeCap.Round + private var strokeCap: StrokeCap = StrokeCap.Butt private var strokeJoin: StrokeJoin = StrokeJoin.Round private var fillColor: Color = Color(red = 128, green = 128, blue = 128, alpha = 128) + private var geodesic: Boolean = false private var visible: Boolean = true private var clickable: Boolean = false private var zIndex: Float = 0f @@ -122,6 +123,15 @@ class PolygonOptions { return this } + /** + * Sets whether segments are drawn as geodesics (great-circle paths). + * @param geodesic true for geodesic paths, false for straight Mercator lines + */ + fun geodesic(geodesic: Boolean): PolygonOptions { + this.geodesic = geodesic + return this + } + /** * Sets whether the polygon is visible. * @param visible true to show the polygon, false to hide it @@ -172,6 +182,7 @@ class PolygonOptions { strokeJoin = strokeJoin, fillColor = fillColor, holes = holes.toList(), + geodesic = geodesic, visible = visible, clickable = clickable, zIndex = zIndex, diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/Polyline.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/Polyline.kt index 14c2af9..5314304 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/Polyline.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/Polyline.kt @@ -16,11 +16,16 @@ import androidx.compose.ui.graphics.StrokeJoin * Represents a polyline on the map, consisting of connected line segments. * * @property points List of geographic coordinates that define the polyline path - * @property strokeColor Color of the line stroke (default: black) + * @property strokeColor Color of the line stroke (default: black). Used when spans is empty. * @property strokeWidth Width of the line in pixels (default: 10f) * @property strokePattern Pattern for the stroke (dashed, dotted, etc.). Null means solid line (default: null) - * @property strokeCap Shape of line endpoints (default: Round) + * @property startCap Shape of the start endpoint (default: Butt) + * @property endCap Shape of the end endpoint (default: Butt) * @property strokeJoin Shape of line corners (default: Round) + * @property geodesic Whether segments are drawn as geodesics (great-circle paths) instead of straight + * lines on the Mercator projection. Default is false. + * @property spans List of style spans for multi-colored polylines. When provided, overrides strokeColor + * for the specified segments. Default is empty (use strokeColor for all segments). * @property visible Whether the polyline is visible. Default is true * @property clickable Whether the polyline is clickable. Default is false * @property zIndex Draw order. Polylines with higher zIndex are drawn on top. Default is 0.0 @@ -31,8 +36,11 @@ data class Polyline( val strokeColor: Color = Color.Black, val strokeWidth: Float = 10f, val strokePattern: PathEffect? = null, - val strokeCap: StrokeCap = StrokeCap.Round, + val startCap: StrokeCap = StrokeCap.Butt, + val endCap: StrokeCap = StrokeCap.Butt, val strokeJoin: StrokeJoin = StrokeJoin.Round, + val geodesic: Boolean = false, + val spans: List = emptyList(), val visible: Boolean = true, val clickable: Boolean = false, val zIndex: Float = 0f, diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/PolylineOptions.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/PolylineOptions.kt index e1e09a7..90fce12 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/PolylineOptions.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/PolylineOptions.kt @@ -32,8 +32,11 @@ class PolylineOptions { private var strokeColor: Color = Color.Black private var strokeWidth: Float = 10f private var strokePattern: PathEffect? = null - private var strokeCap: StrokeCap = StrokeCap.Round + private var startCap: StrokeCap = StrokeCap.Butt + private var endCap: StrokeCap = StrokeCap.Butt private var strokeJoin: StrokeJoin = StrokeJoin.Round + private var geodesic: Boolean = false + private var spans: List = emptyList() private var visible: Boolean = true private var clickable: Boolean = false private var zIndex: Float = 0f @@ -85,11 +88,38 @@ class PolylineOptions { } /** - * Sets the stroke cap style for line endpoints. + * Sets the stroke cap style for the start of the polyline. * @param cap StrokeCap style (Butt, Round, or Square) */ - fun strokeCap(cap: StrokeCap): PolylineOptions { - this.strokeCap = cap + fun startCap(cap: StrokeCap): PolylineOptions { + this.startCap = cap + return this + } + + /** + * Sets the stroke cap style for the end of the polyline. + * @param cap StrokeCap style (Butt, Round, or Square) + */ + fun endCap(cap: StrokeCap): PolylineOptions { + this.endCap = cap + return this + } + + /** + * Sets whether segments are drawn as geodesics (great-circle paths). + * @param geodesic true for geodesic paths, false for straight Mercator lines + */ + fun geodesic(geodesic: Boolean): PolylineOptions { + this.geodesic = geodesic + return this + } + + /** + * Sets style spans for multi-colored polylines. + * @param spans List of StyleSpan defining colors and segment counts + */ + fun spans(spans: List): PolylineOptions { + this.spans = spans return this } @@ -148,8 +178,11 @@ class PolylineOptions { strokeColor = strokeColor, strokeWidth = strokeWidth, strokePattern = strokePattern, - strokeCap = strokeCap, + startCap = startCap, + endCap = endCap, strokeJoin = strokeJoin, + geodesic = geodesic, + spans = spans, visible = visible, clickable = clickable, zIndex = zIndex, diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/StyleSpan.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/StyleSpan.kt new file mode 100644 index 0000000..3a71946 --- /dev/null +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/StyleSpan.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import androidx.compose.ui.graphics.Color + +/** + * Defines a style span for a portion of a polyline. + * + * Style spans allow different segments of a polyline to have different colors, + * enabling multi-colored polylines (e.g., traffic conditions, elevation gradients). + * + * @property color The color for this span's segments. + * @property segments The number of consecutive segments this style applies to. Default is 1. + */ +data class StyleSpan( + val color: Color, + val segments: Int = 1, +) { + init { + require(segments >= 1) { "StyleSpan must cover at least 1 segment" } + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/GeodesicUtilsTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/GeodesicUtilsTest.kt new file mode 100644 index 0000000..10baaad --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/GeodesicUtilsTest.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GeodesicUtilsTest { + // Bochum, Germany + private val bochum = LatLng(51.4818, 7.2162) + + // Berlin, Germany + private val berlin = LatLng(52.5200, 13.4050) + + // New York, USA + private val newYork = LatLng(40.7128, -74.0060) + + // London, UK + private val london = LatLng(51.5074, -0.1278) + + @Test + fun `haversineDistance returns correct distance between Bochum and Berlin`() { + // Bochum to Berlin is approximately 430km + val distance = GeodesicUtils.haversineDistance(bochum, berlin) + assertEquals(430000.0, distance, 10000.0) // 430km +/- 10km + } + + @Test + fun `haversineDistance returns correct distance between New York and London`() { + // New York to London is approximately 5570km + val distance = GeodesicUtils.haversineDistance(newYork, london) + assertEquals(5570000.0, distance, 50000.0) // 5570km +/- 50km + } + + @Test + fun `haversineDistance returns zero for same point`() { + val distance = GeodesicUtils.haversineDistance(bochum, bochum) + assertEquals(0.0, distance, 0.001) + } + + @Test + fun `haversineDistance is symmetric`() { + val distanceAB = GeodesicUtils.haversineDistance(bochum, berlin) + val distanceBA = GeodesicUtils.haversineDistance(berlin, bochum) + assertEquals(distanceAB, distanceBA, 0.001) + } + + @Test + fun `interpolateGreatCircle returns empty list for zero points`() { + val points = GeodesicUtils.interpolateGreatCircle(bochum, berlin, 0) + assertTrue(points.isEmpty()) + } + + @Test + fun `interpolateGreatCircle returns empty list for negative points`() { + val points = GeodesicUtils.interpolateGreatCircle(bochum, berlin, -5) + assertTrue(points.isEmpty()) + } + + @Test + fun `interpolateGreatCircle returns correct number of points`() { + val points = GeodesicUtils.interpolateGreatCircle(bochum, berlin, 5) + assertEquals(5, points.size) + } + + @Test + fun `interpolateGreatCircle returns midpoint correctly`() { + val points = GeodesicUtils.interpolateGreatCircle(bochum, berlin, 1) + assertEquals(1, points.size) + + // Midpoint should be roughly between Bochum and Berlin + val midpoint = points[0] + assertTrue(midpoint.latitude > bochum.latitude) + assertTrue(midpoint.latitude < berlin.latitude) + assertTrue(midpoint.longitude > bochum.longitude) + assertTrue(midpoint.longitude < berlin.longitude) + } + + @Test + fun `interpolateGreatCircle points are evenly spaced`() { + val points = GeodesicUtils.interpolateGreatCircle(newYork, london, 4) + assertEquals(4, points.size) + + // Calculate distances between consecutive points + val distances = mutableListOf() + distances.add(GeodesicUtils.haversineDistance(newYork, points[0])) + for (i in 0 until points.size - 1) { + distances.add(GeodesicUtils.haversineDistance(points[i], points[i + 1])) + } + distances.add(GeodesicUtils.haversineDistance(points.last(), london)) + + // All segment distances should be roughly equal + val avgDistance = distances.average() + for (distance in distances) { + assertEquals(avgDistance, distance, avgDistance * 0.05) // 5% tolerance + } + } + + @Test + fun `interpolateGreatCircle returns empty for same point`() { + // Exact same point should return empty (angular distance is zero) + val points = GeodesicUtils.interpolateGreatCircle(bochum, bochum, 5) + assertTrue(points.isEmpty()) + } + + @Test + fun `expandWithGeodesicPoints returns original for short distances`() { + // Points very close together (less than MIN_INTERPOLATION_DISTANCE) + val closePoints = + listOf( + bochum, + LatLng(bochum.latitude + 0.001, bochum.longitude + 0.001), + ) + val expanded = GeodesicUtils.expandWithGeodesicPoints(closePoints) + + // Should return original points without interpolation + assertEquals(2, expanded.size) + assertEquals(bochum, expanded[0]) + } + + @Test + fun `expandWithGeodesicPoints adds interpolation for long distances`() { + val points = listOf(bochum, berlin) + val expanded = GeodesicUtils.expandWithGeodesicPoints(points) + + // Bochum to Berlin is ~430km, with 50km step we expect 8-9 interpolated points + assertTrue(expanded.size > points.size) + assertEquals(bochum, expanded.first()) + assertEquals(berlin, expanded.last()) + } + + @Test + fun `expandWithGeodesicPoints handles multiple segments`() { + val points = listOf(bochum, berlin, london) + val expanded = GeodesicUtils.expandWithGeodesicPoints(points) + + // Should have more points due to interpolation + assertTrue(expanded.size > points.size) + assertEquals(bochum, expanded.first()) + assertEquals(london, expanded.last()) + } + + @Test + fun `expandWithGeodesicPoints returns original for single point`() { + val points = listOf(bochum) + val expanded = GeodesicUtils.expandWithGeodesicPoints(points) + assertEquals(1, expanded.size) + assertEquals(bochum, expanded[0]) + } + + @Test + fun `expandWithGeodesicPoints returns empty for empty input`() { + val points = emptyList() + val expanded = GeodesicUtils.expandWithGeodesicPoints(points) + assertTrue(expanded.isEmpty()) + } + + @Test + fun `haversineDistance handles equator crossing`() { + val north = LatLng(10.0, 0.0) + val south = LatLng(-10.0, 0.0) + val distance = GeodesicUtils.haversineDistance(north, south) + + // 20 degrees of latitude is approximately 2222km + assertEquals(2222000.0, distance, 50000.0) + } + + @Test + fun `haversineDistance handles antimeridian crossing`() { + val west = LatLng(0.0, 170.0) + val east = LatLng(0.0, -170.0) + val distance = GeodesicUtils.haversineDistance(west, east) + + // 20 degrees of longitude at equator is approximately 2222km + assertEquals(2222000.0, distance, 50000.0) + } +} diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/PolygonTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/PolygonTest.kt index 83c7750..40ed97c 100644 --- a/openmapview/src/test/kotlin/de/afarber/openmapview/PolygonTest.kt +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/PolygonTest.kt @@ -331,4 +331,28 @@ class PolygonTest { val polygon = Polygon(points = points, zIndex = 2.5f) assertEquals(2.5f, polygon.zIndex, 0.001f) } + + @Test + fun testPolygonGeodesic_Default() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + LatLng(51.4620, 7.2430), + ) + val polygon = Polygon(points = points) + assertEquals(false, polygon.geodesic) + } + + @Test + fun testPolygonGeodesic_SetToTrue() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + LatLng(51.4620, 7.2430), + ) + val polygon = Polygon(points = points, geodesic = true) + assertEquals(true, polygon.geodesic) + } } diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/PolylineTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/PolylineTest.kt index 5493b98..9d75c80 100644 --- a/openmapview/src/test/kotlin/de/afarber/openmapview/PolylineTest.kt +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/PolylineTest.kt @@ -264,7 +264,7 @@ class PolylineTest { } @Test - fun testPolylineStrokeCap_Default() { + fun testPolylineStartCap_Default() { val points = listOf( LatLng(51.4661, 7.2491), @@ -272,19 +272,56 @@ class PolylineTest { ) val polyline = Polyline(points = points) - assertEquals(StrokeCap.Round, polyline.strokeCap) + assertEquals(StrokeCap.Butt, polyline.startCap) } @Test - fun testPolylineStrokeCap_Custom() { + fun testPolylineStartCap_Custom() { val points = listOf( LatLng(51.4661, 7.2491), LatLng(51.4700, 7.2550), ) - val polyline = Polyline(points = points, strokeCap = StrokeCap.Square) + val polyline = Polyline(points = points, startCap = StrokeCap.Round) - assertEquals(StrokeCap.Square, polyline.strokeCap) + assertEquals(StrokeCap.Round, polyline.startCap) + } + + @Test + fun testPolylineEndCap_Default() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + ) + val polyline = Polyline(points = points) + + assertEquals(StrokeCap.Butt, polyline.endCap) + } + + @Test + fun testPolylineEndCap_Custom() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + ) + val polyline = Polyline(points = points, endCap = StrokeCap.Square) + + assertEquals(StrokeCap.Square, polyline.endCap) + } + + @Test + fun testPolylineStartCapAndEndCap_Different() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + ) + val polyline = Polyline(points = points, startCap = StrokeCap.Round, endCap = StrokeCap.Square) + + assertEquals(StrokeCap.Round, polyline.startCap) + assertEquals(StrokeCap.Square, polyline.endCap) } @Test @@ -325,14 +362,93 @@ class PolylineTest { strokeColor = Color.Blue, strokeWidth = 8f, strokePattern = pattern, - strokeCap = StrokeCap.Butt, + startCap = StrokeCap.Round, + endCap = StrokeCap.Square, strokeJoin = StrokeJoin.Bevel, ) assertEquals(Color.Blue, polyline.strokeColor) assertEquals(8f, polyline.strokeWidth, 0.001f) assertEquals(pattern, polyline.strokePattern) - assertEquals(StrokeCap.Butt, polyline.strokeCap) + assertEquals(StrokeCap.Round, polyline.startCap) + assertEquals(StrokeCap.Square, polyline.endCap) assertEquals(StrokeJoin.Bevel, polyline.strokeJoin) } + + @Test + fun testPolylineGeodesic_Default() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + ) + val polyline = Polyline(points = points) + + assertEquals(false, polyline.geodesic) + } + + @Test + fun testPolylineGeodesic_SetToTrue() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + ) + val polyline = Polyline(points = points, geodesic = true) + + assertEquals(true, polyline.geodesic) + } + + @Test + fun testPolylineSpans_Default() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + ) + val polyline = Polyline(points = points) + + assertEquals(emptyList(), polyline.spans) + } + + @Test + fun testPolylineSpans_WithMultipleColors() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + LatLng(51.4750, 7.2600), + ) + val spans = + listOf( + StyleSpan(Color.Red, 1), + StyleSpan(Color.Blue, 1), + ) + val polyline = Polyline(points = points, spans = spans) + + assertEquals(2, polyline.spans.size) + assertEquals(Color.Red, polyline.spans[0].color) + assertEquals(Color.Blue, polyline.spans[1].color) + } + + @Test + fun testPolylineSpans_WithMultipleSegments() { + val points = + listOf( + LatLng(51.4661, 7.2491), + LatLng(51.4700, 7.2550), + LatLng(51.4750, 7.2600), + LatLng(51.4800, 7.2650), + ) + val spans = + listOf( + StyleSpan(Color.Green, 2), + StyleSpan(Color.Yellow, 1), + ) + val polyline = Polyline(points = points, spans = spans) + + assertEquals(2, polyline.spans.size) + assertEquals(2, polyline.spans[0].segments) + assertEquals(1, polyline.spans[1].segments) + } }