Skip to content

Commit 2d2581f

Browse files
committed
deal with some edge cases for tile bounding boxes
add test to validate it does the right thing
1 parent c2d3122 commit 2d2581f

File tree

2 files changed

+105
-56
lines changed
  • src
    • commonMain/kotlin/com/jillesvangurp/geo/tiles
    • commonTest/kotlin/com/jillesvangurp/geo/tiles

2 files changed

+105
-56
lines changed

src/commonMain/kotlin/com/jillesvangurp/geo/tiles/Tile.kt

Lines changed: 75 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import kotlin.math.PI
1010
import kotlin.math.atan
1111
import kotlin.math.cos
1212
import kotlin.math.ln
13-
import kotlin.math.roundToInt
1413
import kotlin.math.sinh
1514
import kotlin.math.tan
1615
import kotlinx.serialization.Serializable
@@ -36,15 +35,66 @@ data class Tile(val x: Int, val y: Int, val zoom: Int) {
3635
require(y in 0..maxXY) { "y must be between 0 and $maxXY at $zoom" }
3736
}
3837

38+
override fun toString() = "$zoom/$x/$y"
39+
40+
val topLeft: PointCoordinates by lazy { topLeft(x = x, y = y, zoom = zoom) }
41+
42+
val topRight: PointCoordinates by lazy { topLeft(x = (x + 1) % maxXY, y = y, zoom = zoom, fixLonLat = true) }
43+
44+
val bottomLeft: PointCoordinates by lazy { topLeft(x = x, y = (y + 1) % maxXY, zoom = zoom) }
45+
46+
val bottomRight: PointCoordinates by lazy { topLeft(
47+
x = (x + 1) % maxXY,
48+
y = (y + 1) % maxXY,
49+
zoom = zoom,
50+
fixLonLat = true
51+
) }
52+
53+
val bbox: BoundingBox by lazy {
54+
if(zoom>0) {
55+
doubleArrayOf(
56+
topLeft.longitude,
57+
bottomRight.latitude,
58+
bottomRight.longitude,
59+
topLeft.latitude
60+
)
61+
} else {
62+
doubleArrayOf(-180.0, MAX_LATITUDE,180.0, MIN_LATITUDE)
63+
}
64+
}
65+
66+
val east: Tile by lazy { Tile((x + 1) % maxXY, y, zoom) }
67+
68+
val west: Tile by lazy { Tile((x - 1 + maxXY) % maxXY, y, zoom) }
69+
70+
val north: Tile by lazy {
71+
if (y > 0) Tile(x, y - 1, zoom) else Tile(x, 0, zoom)
72+
}
73+
74+
val south: Tile by lazy {
75+
val maxTiles = 1 shl zoom
76+
if (y < maxTiles - 1) Tile(x, y + 1, zoom) else Tile(x, maxTiles - 1, zoom)
77+
}
78+
79+
val northWest: Tile by lazy { north.west }
80+
81+
val southWest: Tile by lazy { south.west }
82+
83+
val southEast: Tile by lazy { south.east }
84+
85+
val northEast: Tile by lazy { north.east }
86+
3987
companion object {
4088
const val MAX_ZOOM = 22
4189
const val MIN_LATITUDE = -85.05112878
4290
const val MAX_LATITUDE = 85.05112878
4391

4492
/**
45-
* Returns the topLeft corner of the tile.
93+
* Returns the topLeft corner of the tile. Use [fixLonLat] if you are
94+
* calculating the topleft of a tile that is North/East of the current one to
95+
* dodge issues with MIN/MAX latitude and the dateline
4696
*/
47-
fun topLeft(x: Int, y: Int, zoom: Int): PointCoordinates {
97+
fun topLeft(x: Int, y: Int, zoom: Int, fixLonLat: Boolean=false): PointCoordinates {
4898
// n is the number of x and y coordinates at a zoom level
4999
// The shl operation (1 shl zoom) shifts the integer 1 to the left by zoom bits,
50100
// which is equivalent to calculating 2^zoom
@@ -53,8 +103,17 @@ data class Tile(val x: Int, val y: Int, val zoom: Int) {
53103
require(x in 0..maxCoords) { "x must be between 0 and $maxCoords at $zoom" }
54104
require(y in 0..maxCoords) { "y must be between 0 and $maxCoords at $zoom" }
55105
val lon = x.toDouble() / maxCoords * 360.0 - 180.0
56-
val lat = atan(sinh(PI * (1 - 2 * y.toDouble() / maxCoords))).toDegrees()
57-
return doubleArrayOf(lon, lat)
106+
val lat =
107+
atan(sinh(PI * (1 - 2 * y.toDouble() / maxCoords)))
108+
.toDegrees()
109+
.coerceIn(MIN_LATITUDE, MAX_LATITUDE)
110+
111+
112+
return doubleArrayOf(
113+
if(fixLonLat && lon <= -180.0) 180.0 else lon,
114+
// nice little rounding error here calculating the bottom latitude
115+
if(fixLonLat && lat >= 85.051128) MIN_LATITUDE else lat
116+
)
58117
}
59118

60119
/**
@@ -88,53 +147,20 @@ data class Tile(val x: Int, val y: Int, val zoom: Int) {
88147
@Deprecated("use coordinateToTile", ReplaceWith("coordinateToTile(p.latitude,p.longitude,zoom)"))
89148
fun deg2num(p: PointCoordinates, zoom: Int) = coordinateToTile(p.latitude, p.longitude, zoom)
90149

150+
fun allTilesAt(zoom: Int): Sequence<Tile> {
151+
require(zoom in 0..MAX_ZOOM) { "Zoom level must be between 0 and $MAX_ZOOM." }
152+
val maxXY = 1 shl zoom
153+
return sequence {
154+
for (x in 0 until maxXY) {
155+
for (y in 0 until maxXY) {
156+
yield(Tile(x, y, zoom))
157+
}
158+
}
159+
}
160+
}
91161
}
92162
}
93163

94-
val Tile.topLeft get() = Tile.topLeft(x, y, zoom)
95-
val Tile.topRight: PointCoordinates
96-
get() = Tile.topLeft((x + 1) % maxXY, y, zoom)
97-
98-
val Tile.bottomLeft: PointCoordinates
99-
get() = Tile.topLeft(x, (y + 1) % maxXY, zoom)
100-
101-
val Tile.bottomRight: PointCoordinates
102-
get() = Tile.topLeft((x + 1) % maxXY, (y + 1) % maxXY, zoom)
103-
104-
val Tile.bbox: BoundingBox
105-
get() {
106-
return doubleArrayOf(topLeft.longitude, bottomRight.latitude, bottomRight.longitude, topLeft.latitude)
107-
}
108-
109-
val Tile.east: Tile
110-
get() = Tile((x + 1) % maxXY, y, zoom)
111-
112-
val Tile.west: Tile
113-
get() = Tile((x - 1 + maxXY) % maxXY, y, zoom)
114-
115-
val Tile.north: Tile
116-
get() {
117-
return if (y > 0) Tile(x, y - 1, zoom) else Tile(x, 0, zoom)
118-
}
119-
120-
val Tile.south: Tile
121-
get() {
122-
val maxTiles = 1 shl zoom
123-
return if (y < maxTiles - 1) Tile(x, y + 1, zoom) else Tile(x, maxTiles - 1, zoom)
124-
}
125-
126-
val Tile.northWest: Tile
127-
get() = north.west
128-
129-
val Tile.southWest: Tile
130-
get() = south.west
131-
132-
val Tile.southEast: Tile
133-
get() = south.east
134-
135-
val Tile.northEast: Tile
136-
get() = north.east
137-
138164
fun Tile.parentTiles(): List<Tile> {
139165
if (zoom == 0) return emptyList()
140166
val parentTiles = mutableListOf<Tile>()

src/commonTest/kotlin/com/jillesvangurp/geo/tiles/TileTest.kt

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import com.jillesvangurp.geojson.toGeometry
88
import io.kotest.assertions.assertSoftly
99
import io.kotest.assertions.withClue
1010
import io.kotest.matchers.collections.shouldBeIn
11+
import io.kotest.matchers.doubles.shouldBeGreaterThan
1112
import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual
13+
import io.kotest.matchers.doubles.shouldBeLessThan
1214
import io.kotest.matchers.doubles.shouldBeLessThanOrEqual
1315
import io.kotest.matchers.shouldBe
1416
import kotlin.random.Random
1517
import kotlin.test.Test
1618

17-
fun randomTileCoordinate() = doubleArrayOf(Random.nextDouble(-180.0,180.0), Random.nextDouble(Tile.MIN_LATITUDE, Tile.MAX_LATITUDE))
19+
fun randomTileCoordinate() =
20+
doubleArrayOf(Random.nextDouble(-180.0, 180.0), Random.nextDouble(Tile.MIN_LATITUDE, Tile.MAX_LATITUDE))
1821

1922
class TileTest {
2023

@@ -74,11 +77,11 @@ class TileTest {
7477

7578
val testCases = listOf(
7679
TestCase(zoom = 13, x = 4399, y = 2687, lat = 52.49867, lon = 13.34169),
77-
TestCase(14, 8802, 5373,52.5200, 13.4050),
78-
TestCase(zoom = 18, x = 232797, y = 103246, lat = 35.659062,lon=139.698054),
80+
TestCase(14, 8802, 5373, 52.5200, 13.4050),
81+
TestCase(zoom = 18, x = 232797, y = 103246, lat = 35.659062, lon = 139.698054),
7982
)
8083
assertSoftly {
81-
testCases.forEach {t ->
84+
testCases.forEach { t ->
8285
withClue("$t -> https://www.openstreetmap.org/#map=${t.zoom}/${t.lat}/${t.lon} https://tile.openstreetmap.org/${t.zoom}/${t.x}/${t.y}.png") {
8386
val (zoom, x, y, lat, lon) = t
8487
val topLeft = Tile.topLeft(x, y, zoom)
@@ -171,9 +174,29 @@ class TileTest {
171174

172175
@Test
173176
fun shouldGenerateTileWithPointInside() {
174-
val zoom=8
175-
val point = doubleArrayOf(-10.6202579166835,40.113983580628)
176-
val tile = Tile.coordinateToTile(point.latitude,point.longitude,zoom)
177+
val zoom = 8
178+
val point = doubleArrayOf(-10.6202579166835, 40.113983580628)
179+
val tile = Tile.coordinateToTile(point.latitude, point.longitude, zoom)
177180
tile.bbox.toGeometry().contains(point) shouldBe true
178181
}
182+
183+
@Test
184+
fun tileBoundingBoxesShouldBeValid() {
185+
assertSoftly {
186+
for (zoom in 0..4) {
187+
Tile.allTilesAt(zoom).forEach { tile ->
188+
withClue(tile) {
189+
tile.topLeft.longitude shouldBeLessThan tile.bottomRight.longitude
190+
tile.topLeft.latitude shouldBeGreaterThan tile.bottomRight.latitude
191+
tile.bbox.toGeometry().contains(
192+
doubleArrayOf(
193+
(tile.topLeft.longitude + tile.bottomRight.longitude) / 2,
194+
(tile.topLeft.latitude + tile.bottomRight.latitude) / 2,
195+
),
196+
) shouldBe true
197+
}
198+
}
199+
}
200+
}
201+
}
179202
}

0 commit comments

Comments
 (0)