Skip to content

Commit 8e8858b

Browse files
committed
Rename Projection to ProjectionUtils
1 parent 10e4ee5 commit 8e8858b

10 files changed

Lines changed: 658 additions & 83 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/*
2+
* Copyright (c) 2025 Alexander Farber
3+
* SPDX-License-Identifier: MIT
4+
*
5+
* This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView)
6+
*/
7+
8+
package de.afarber.openmapview
9+
10+
import kotlin.math.max
11+
import kotlin.math.min
12+
13+
/**
14+
* An immutable class representing a latitude/longitude aligned rectangle.
15+
*
16+
* Compatible with Google Maps API. The bounds are defined by the southwest
17+
* and northeast corners of the rectangle.
18+
*
19+
* @property southwest The southwest corner of the bounds
20+
* @property northeast The northeast corner of the bounds
21+
*/
22+
data class LatLngBounds(
23+
val southwest: LatLng,
24+
val northeast: LatLng,
25+
) {
26+
/**
27+
* Returns whether this bounds contains the given point.
28+
*
29+
* @param point The point to test
30+
* @return true if the point is contained within the bounds, false otherwise
31+
*/
32+
fun contains(point: LatLng): Boolean {
33+
val lat = point.latitude
34+
val lng = point.longitude
35+
36+
val latContained = lat >= southwest.latitude && lat <= northeast.latitude
37+
38+
val lngContained =
39+
if (southwest.longitude <= northeast.longitude) {
40+
lng >= southwest.longitude && lng <= northeast.longitude
41+
} else {
42+
lng >= southwest.longitude || lng <= northeast.longitude
43+
}
44+
45+
return latContained && lngContained
46+
}
47+
48+
/**
49+
* Returns the center of the bounds.
50+
*
51+
* @return The geographic center point
52+
*/
53+
fun getCenter(): LatLng {
54+
val lat = (southwest.latitude + northeast.latitude) / 2.0
55+
56+
val lng =
57+
if (southwest.longitude <= northeast.longitude) {
58+
(southwest.longitude + northeast.longitude) / 2.0
59+
} else {
60+
var center = (southwest.longitude + northeast.longitude + 360.0) / 2.0
61+
if (center >= 180.0) center -= 360.0
62+
center
63+
}
64+
65+
return LatLng(lat, lng)
66+
}
67+
68+
/**
69+
* Returns a new bounds that extends this bounds to include the given point.
70+
*
71+
* @param point The point to include
72+
* @return A new LatLngBounds that includes both the original bounds and the point
73+
*/
74+
fun including(point: LatLng): LatLngBounds {
75+
val minLat = min(southwest.latitude, point.latitude)
76+
val maxLat = max(northeast.latitude, point.latitude)
77+
78+
val minLng: Double
79+
val maxLng: Double
80+
81+
if (southwest.longitude <= northeast.longitude) {
82+
minLng = min(southwest.longitude, point.longitude)
83+
maxLng = max(northeast.longitude, point.longitude)
84+
} else {
85+
minLng = southwest.longitude
86+
maxLng = northeast.longitude
87+
}
88+
89+
return LatLngBounds(
90+
southwest = LatLng(minLat, minLng),
91+
northeast = LatLng(maxLat, maxLng),
92+
)
93+
}
94+
95+
companion object {
96+
/**
97+
* Creates a new builder for constructing bounds.
98+
*
99+
* @return A new Builder instance
100+
*/
101+
fun builder(): Builder = Builder()
102+
}
103+
104+
/**
105+
* Builder for creating LatLngBounds by including multiple points.
106+
*
107+
* The builder calculates the minimum bounding box that contains all
108+
* included points.
109+
*/
110+
class Builder {
111+
private var minLat: Double? = null
112+
private var maxLat: Double? = null
113+
private var minLng: Double? = null
114+
private var maxLng: Double? = null
115+
116+
/**
117+
* Includes a point in the bounds being built.
118+
*
119+
* The bounds will be extended to include this point.
120+
*
121+
* @param point The point to include
122+
* @return This builder for method chaining
123+
*/
124+
fun include(point: LatLng): Builder {
125+
val lat = point.latitude
126+
val lng = point.longitude
127+
128+
minLat =
129+
if (minLat == null) {
130+
lat
131+
} else {
132+
min(minLat!!, lat)
133+
}
134+
maxLat =
135+
if (maxLat == null) {
136+
lat
137+
} else {
138+
max(maxLat!!, lat)
139+
}
140+
minLng =
141+
if (minLng == null) {
142+
lng
143+
} else {
144+
min(minLng!!, lng)
145+
}
146+
maxLng =
147+
if (maxLng == null) {
148+
lng
149+
} else {
150+
max(maxLng!!, lng)
151+
}
152+
153+
return this
154+
}
155+
156+
/**
157+
* Creates the LatLngBounds from the included points.
158+
*
159+
* @return The constructed LatLngBounds
160+
* @throws IllegalStateException if no points have been included
161+
*/
162+
fun build(): LatLngBounds {
163+
require(minLat != null && maxLat != null && minLng != null && maxLng != null) {
164+
"Cannot build LatLngBounds: no points have been included"
165+
}
166+
167+
return LatLngBounds(
168+
southwest = LatLng(minLat!!, minLng!!),
169+
northeast = LatLng(maxLat!!, maxLng!!),
170+
)
171+
}
172+
}
173+
}

openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,24 @@ class MapController(
203203
zoom = zoom,
204204
)
205205

206+
/**
207+
* Creates a Projection instance for coordinate conversions.
208+
*
209+
* The projection captures the current map state (center, zoom, view size, pan offset)
210+
* and provides methods for converting between screen and geographic coordinates.
211+
*
212+
* @return A Projection instance for the current map state
213+
*/
214+
fun createProjection(): Projection =
215+
Projection(
216+
center = center,
217+
zoom = zoom,
218+
viewWidth = viewWidth,
219+
viewHeight = viewHeight,
220+
panOffsetX = panOffsetX,
221+
panOffsetY = panOffsetY,
222+
)
223+
206224
/**
207225
* Converts screen coordinates to geographic coordinates.
208226
*
@@ -217,15 +235,15 @@ class MapController(
217235
screenY: Float,
218236
): LatLng {
219237
// Get center pixel coordinates at current zoom
220-
val (centerPixelX, centerPixelY) = Projection.latLngToPixel(center, zoom.toInt())
238+
val (centerPixelX, centerPixelY) = ProjectionUtils.latLngToPixel(center, zoom.toInt())
221239

222240
// Convert screen coordinates to pixel coordinates
223241
// Account for view center offset and pan offset
224242
val pixelX = (centerPixelX + (screenX - viewWidth / 2 + panOffsetX).toDouble()).toInt()
225243
val pixelY = (centerPixelY + (screenY - viewHeight / 2 + panOffsetY).toDouble()).toInt()
226244

227245
// Convert pixel coordinates to LatLng
228-
return Projection.pixelToLatLng(pixelX, pixelY, zoom.toInt())
246+
return ProjectionUtils.pixelToLatLng(pixelX, pixelY, zoom.toInt())
229247
}
230248

231249
/**
@@ -432,11 +450,11 @@ class MapController(
432450
if (panOffsetX == 0f && panOffsetY == 0f) return
433451

434452
// Convert accumulated pan offset to new center
435-
val (centerPixelX, centerPixelY) = Projection.latLngToPixel(center, zoom.toInt())
453+
val (centerPixelX, centerPixelY) = ProjectionUtils.latLngToPixel(center, zoom.toInt())
436454
val newCenterPixelX = (centerPixelX + panOffsetX).toInt()
437455
val newCenterPixelY = (centerPixelY + panOffsetY).toInt()
438456

439-
center = Projection.pixelToLatLng(newCenterPixelX, newCenterPixelY, zoom.toInt())
457+
center = ProjectionUtils.pixelToLatLng(newCenterPixelX, newCenterPixelY, zoom.toInt())
440458

441459
// Reset pan offset
442460
panOffsetX = 0f
@@ -466,10 +484,10 @@ class MapController(
466484
panOffsetY,
467485
)
468486

469-
val (centerPixelX, centerPixelY) = Projection.latLngToPixel(center, zoom.toInt())
487+
val (centerPixelX, centerPixelY) = ProjectionUtils.latLngToPixel(center, zoom.toInt())
470488

471489
for (tile in visibleTiles) {
472-
val (tilePixelX, tilePixelY) = Projection.tileToPixel(tile)
490+
val (tilePixelX, tilePixelY) = ProjectionUtils.tileToPixel(tile)
473491

474492
val screenX = (tilePixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat()
475493
val screenY = (tilePixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat()
@@ -549,7 +567,7 @@ class MapController(
549567
var isFirst = true
550568

551569
for (point in polyline.points) {
552-
val (pixelX, pixelY) = Projection.latLngToPixel(point, zoom.toInt())
570+
val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt())
553571
val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat()
554572
val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat()
555573

@@ -593,7 +611,7 @@ class MapController(
593611
// Draw main polygon outline
594612
var isFirst = true
595613
for (point in polygon.points) {
596-
val (pixelX, pixelY) = Projection.latLngToPixel(point, zoom.toInt())
614+
val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt())
597615
val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat()
598616
val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat()
599617

@@ -611,7 +629,7 @@ class MapController(
611629
if (hole.size < 3) continue
612630
isFirst = true
613631
for (point in hole) {
614-
val (pixelX, pixelY) = Projection.latLngToPixel(point, zoom.toInt())
632+
val (pixelX, pixelY) = ProjectionUtils.latLngToPixel(point, zoom.toInt())
615633
val screenX = (pixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat()
616634
val screenY = (pixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat()
617635

@@ -641,7 +659,7 @@ class MapController(
641659
if (!marker.visible) continue
642660

643661
// Convert marker position to pixel coordinates
644-
val (markerPixelX, markerPixelY) = Projection.latLngToPixel(marker.position, zoom.toInt())
662+
val (markerPixelX, markerPixelY) = ProjectionUtils.latLngToPixel(marker.position, zoom.toInt())
645663

646664
// Calculate screen position
647665
val screenX = (markerPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat()
@@ -818,11 +836,11 @@ class MapController(
818836
x: Float,
819837
y: Float,
820838
): Marker? {
821-
val (centerPixelX, centerPixelY) = Projection.latLngToPixel(center, zoom.toInt())
839+
val (centerPixelX, centerPixelY) = ProjectionUtils.latLngToPixel(center, zoom.toInt())
822840

823841
// Check markers in reverse order (top to bottom) for correct z-ordering
824842
for (marker in markers.reversed()) {
825-
val (markerPixelX, markerPixelY) = Projection.latLngToPixel(marker.position, zoom.toInt())
843+
val (markerPixelX, markerPixelY) = ProjectionUtils.latLngToPixel(marker.position, zoom.toInt())
826844

827845
val screenX = (markerPixelX - centerPixelX + viewWidth / 2 - panOffsetX).toFloat()
828846
val screenY = (markerPixelY - centerPixelY + viewHeight / 2 - panOffsetY).toFloat()

openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,24 @@ class OpenMapView
252252
*/
253253
fun getCameraPosition(): CameraPosition = controller.getCameraPosition()
254254

255+
/**
256+
* Returns a Projection object for coordinate transformations.
257+
*
258+
* The Projection allows you to convert between screen coordinates (in pixels)
259+
* and geographic coordinates (LatLng), as well as query the visible region.
260+
*
261+
* Example:
262+
* ```kotlin
263+
* val projection = mapView.getProjection()
264+
* val latLng = projection.fromScreenLocation(Point(100, 100))
265+
* val screenPoint = projection.toScreenLocation(LatLng(51.5074, -0.1278))
266+
* val visibleRegion = projection.getVisibleRegion()
267+
* ```
268+
*
269+
* @return A Projection instance for the current map state
270+
*/
271+
fun getProjection(): Projection = controller.createProjection()
272+
255273
/**
256274
* Moves the camera to a new position instantly, without animation.
257275
*

0 commit comments

Comments
 (0)