diff --git a/src/commonMain/kotlin/com/jillesvangurp/geo/GeoGeometry.kt b/src/commonMain/kotlin/com/jillesvangurp/geo/GeoGeometry.kt index 13e5d11..de8f7e9 100644 --- a/src/commonMain/kotlin/com/jillesvangurp/geo/GeoGeometry.kt +++ b/src/commonMain/kotlin/com/jillesvangurp/geo/GeoGeometry.kt @@ -61,7 +61,7 @@ class GeoGeometry { const val WGS84_RADIUS = 6378137 const val EARTH_CIRCUMFERENCE_METERS = EARTH_RADIUS_METERS * PI * 2.0 const val DEGREE_LATITUDE_METERS = EARTH_RADIUS_METERS * PI / 180.0 - const val DEGREES_TO_RADIANS = 2.0 * PI / 360.0 + const val DEGREES_TO_RADIANS = PI / 180.0 const val RADIANS_TO_DEGREES = 1.0 / DEGREES_TO_RADIANS @@ -517,7 +517,14 @@ class GeoGeometry { return translateLatitude(longitudal.latitude, longitudal.longitude, latitudalMeters) } - /** + fun translate( + point: PointCoordinates, + xMeters: Double, + yMeters: Double + ): DoubleArray = translate(point.latitude,point.longitude, yMeters, xMeters) + + + /** * Calculate a bounding box of the specified longitudal and latitudal meters with the latitude/longitude as the center. * @param latitude latitude * @param longitude longitude @@ -563,8 +570,8 @@ class GeoGeometry { return degrees * DEGREES_TO_RADIANS } - fun fromRadians(degrees: Double): Double { - return degrees * RADIANS_TO_DEGREES + fun fromRadians(radians: Double): Double { + return radians * RADIANS_TO_DEGREES } /** @@ -862,8 +869,8 @@ class GeoGeometry { return arrayOf(points.toTypedArray()) } - fun rotateAround(anchor: PointCoordinates, point: PointCoordinates, degrees: Double): PointCoordinates { - // we have to work coordinates in meters because otherwise we get a weird elipse :-) + fun rotateAroundOld(anchor: PointCoordinates, point: PointCoordinates, degrees: Double): PointCoordinates { + // we have to work in meters because otherwise we get a weird elipse instead of a circle :-) // start by calculating the compass direction of the point from the anchor val heading = headingFromTwoPoints(anchor, point) // calculate the distance in meters @@ -878,6 +885,34 @@ class GeoGeometry { return translate(anchor.latitude, anchor.longitude, y, x) } + fun rotateAround(anchor: PointCoordinates, point: PointCoordinates, degrees: Double): PointCoordinates { + val x = distance(anchor, doubleArrayOf(point.x, anchor.y)).let { + if(anchor.x>point.x) { + -it + } else { + it + } + } + val y = distance(anchor, doubleArrayOf(anchor.x, point.y)).let { + if(anchor.y>point.y) { + -it + } else { + it + } + } + + val d = distance(anchor.translate(y, x), point) + + if(d > 50.0) error("srously WTF?!?!?!? $d") + + val radians = toRadians(degrees) + + val newX = x*cos(radians) - y*sin(radians) + val newY = x*sin(radians) + y*cos(radians) + + return translate(anchor,newX,newY) + } + /** * @param left a 2d array representing a polygon * @param right a 2d array representing a polygon diff --git a/src/commonMain/kotlin/com/jillesvangurp/geojson/geojson-extensions.kt b/src/commonMain/kotlin/com/jillesvangurp/geojson/geojson-extensions.kt index 500bbb2..fe862b6 100644 --- a/src/commonMain/kotlin/com/jillesvangurp/geojson/geojson-extensions.kt +++ b/src/commonMain/kotlin/com/jillesvangurp/geojson/geojson-extensions.kt @@ -1,6 +1,7 @@ package com.jillesvangurp.geojson import com.jillesvangurp.geo.GeoGeometry +import kotlinx.serialization.encodeToString fun LineStringCoordinates.centroid(): DoubleArray { var minLon = 180.0 @@ -87,6 +88,8 @@ fun Geometry.area() = when(this) { else -> 0.0 } +val Geometry.asFeatureCollection get() = FeatureCollection(listOf(this.asFeature())) + fun T.scaleX(percent: Double): T { @Suppress("UNCHECKED_CAST") // it's fine, generics confusing things return when(this) { @@ -185,44 +188,52 @@ fun Array>>>.scaleY(percent: Double): Array fun Feature.scaleY(percent: Double) = copy(geometry=geometry?.scaleY(percent)) fun FeatureCollection.scaleY(percent: Double) = copy(features = features.map { it.scaleY(percent) }) -fun T.rotate(degrees: Double): T { +fun T.rotate(degrees: Double, around: PointCoordinates?=null): T { @Suppress("UNCHECKED_CAST") // it's fine, generics confusing things return when(this) { - is Geometry.Point -> this - is Geometry.LineString -> this.copy(coordinates = coordinates?.rotate(degrees)) as T - is Geometry.MultiLineString -> this.copy(coordinates = coordinates?.rotate(degrees)) as T - is Geometry.MultiPoint -> this.copy(coordinates = coordinates?.rotate(degrees)) as T - is Geometry.Polygon -> this.copy(coordinates = coordinates?.rotate(degrees)) as T - is Geometry.MultiPolygon -> this.copy(coordinates = coordinates?.rotate(degrees)) as T - is Geometry.GeometryCollection -> this.copy(geometries = geometries.map { it.rotate(degrees) }.toTypedArray()) as T + is Geometry.Point -> this.copy(coordinates = coordinates?.rotate(degrees,around)) as T + is Geometry.LineString -> this.copy(coordinates = coordinates?.rotate(degrees,around)) as T + is Geometry.MultiLineString -> this.copy(coordinates = coordinates?.rotate(degrees,around)) as T + is Geometry.MultiPoint -> this.copy(coordinates = coordinates?.rotate(degrees,around)) as T + is Geometry.Polygon -> this.copy(coordinates = coordinates?.rotate(degrees,around)) as T + is Geometry.MultiPolygon -> this.copy(coordinates = coordinates?.rotate(degrees,around)) as T + is Geometry.GeometryCollection -> this.copy(geometries = geometries.map { it.rotate(degrees,around) }.toTypedArray()) as T else -> error("shouldn't happen") } } -fun Array.rotate(degrees: Double): Array { - val centroid = centroid() +fun PointCoordinates.rotate (degrees: Double, around: PointCoordinates?=null): PointCoordinates { + return if(around == null) { + this + } else { + GeoGeometry.rotateAround(around, this, degrees) + } +} + +fun Array.rotate(degrees: Double, around: PointCoordinates?=null): Array { + val centroid = around?:centroid() return map { p -> - GeoGeometry.rotateAround(centroid, p, degrees) + p.rotate(degrees, centroid) }.toTypedArray() } -fun Array>.rotate(degrees: Double): Array> { +fun Array>.rotate(degrees: Double, around: PointCoordinates?=null): Array> { return map { ps -> - ps.rotate(degrees) + ps.rotate(degrees,around) }.toTypedArray() } -fun Array>>.rotate(degrees: Double): Array>> { +fun Array>>.rotate(degrees: Double, around: PointCoordinates?=null): Array>> { return map { ps -> ps.rotate(degrees) }.toTypedArray() } -fun Array>>>.rotate(degrees: Double): Array>>> { +fun Array>>>.rotate(degrees: Double, around: PointCoordinates?=null): Array>>> { return map { ps -> - ps.rotate(degrees) + ps.rotate(degrees,around) }.toTypedArray() } -fun Feature.rotate(degrees: Double) = copy(geometry=geometry?.rotate(degrees)) -fun FeatureCollection.rotate(degrees: Double) = copy(features = features.map { it.rotate(degrees) }) \ No newline at end of file +fun Feature.rotate(degrees: Double, around: PointCoordinates?=null) = copy(geometry=geometry?.rotate(degrees,around)) +fun FeatureCollection.rotate(degrees: Double, around: PointCoordinates?=null) = copy(features = features.map { it.rotate(degrees,around) }) \ No newline at end of file diff --git a/src/commonMain/kotlin/com/jillesvangurp/geojson/geojson.kt b/src/commonMain/kotlin/com/jillesvangurp/geojson/geojson.kt index c180f97..32b46b6 100644 --- a/src/commonMain/kotlin/com/jillesvangurp/geojson/geojson.kt +++ b/src/commonMain/kotlin/com/jillesvangurp/geojson/geojson.kt @@ -10,22 +10,21 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Required import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonClassDiscriminator -import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.* import kotlin.math.* /** * Simple type aliases to have a bit more readable code. Based on https://tools.ietf.org/html/rfc7946#section-3.1.2 */ typealias PointCoordinates = DoubleArray +// should be array with 2 points +typealias LineSegment = Array typealias MultiPointCoordinates = Array typealias LineStringCoordinates = Array typealias LinearRingCoordinates = Array typealias MultiLineStringCoordinates = Array // Outer polygon + holes typealias PolygonCoordinates = Array // Outer polygon + holes typealias MultiPolygonCoordinates = Array - /** * Lowest axes followed by highest axes * BoundingBox = [westLongitude,southLatitude,eastLongitude,westLatitude] @@ -44,6 +43,14 @@ fun MultiLineStringCoordinates.multiLineStringGeometry() = Geometry.MultiLineStr fun PolygonCoordinates.polygonGeometry() = Geometry.Polygon(coordinates = this) fun MultiPolygonCoordinates.geometry() = Geometry.MultiPolygon(coordinates = this) +val LinearRingCoordinates.segments + get() = + this.indices.map { index -> + arrayOf(this[index], this[(index + 1) % this.size]) + } + +val PolygonCoordinates.outerSegments get() = this[0].segments + fun Geometry.ensureFollowsRightHandSideRule() = when (this) { is Geometry.Polygon -> this.copy(coordinates = this.coordinates?.ensureFollowsRightHandSideRule()) is Geometry.MultiPolygon -> this.copy(coordinates = this.coordinates?.ensureFollowsRightHandSideRule()) @@ -66,9 +73,11 @@ fun BoundingBox.isValid(): Boolean { val PointCoordinates.latitude: Double get() = this[1] +val PointCoordinates.y get() = latitude val PointCoordinates.longitude: Double get() = this[0] +val PointCoordinates.x get() = longitude enum class CompassDirection(val letter: Char) { East('E'), West('W'), South('S'), North('N') } @@ -81,8 +90,19 @@ val Degree.northOrSouth: CompassDirection get() = if (this >= 0) CompassDirectio val Degree.eastOrWest: CompassDirection get() = if (this >= 0) CompassDirection.East else CompassDirection.West fun PointCoordinates.humanReadable(): String { - return """${latitude.degree}° ${latitude.minutes}' ${roundToDecimals(latitude.seconds,2)}" ${latitude.northOrSouth.letter}, ${longitude.degree}° ${longitude.minutes}' ${roundToDecimals(longitude.seconds,2)}" ${longitude.eastOrWest.letter}""" + return """${latitude.degree}° ${latitude.minutes}' ${ + roundToDecimals( + latitude.seconds, + 2 + ) + }" ${latitude.northOrSouth.letter}, ${longitude.degree}° ${longitude.minutes}' ${ + roundToDecimals( + longitude.seconds, + 2 + ) + }" ${longitude.eastOrWest.letter}""" } + val PointCoordinates.altitude: Double? get() = if (this.size == 3) this[2] else null @@ -133,7 +153,11 @@ fun BoundingBox.zoomLevel(height: Int = 512, width: Int = 512, minZoom: Double = val latFraction = (GeoGeometry.toRadians(northEast.latitude) - GeoGeometry.toRadians(southWest.latitude)) / PI val lngDiff = northEast.longitude - southWest.longitude - val lngFraction = if (lngDiff < 0) { (lngDiff + 360) / 360 } else { (lngDiff / 360) } + val lngFraction = if (lngDiff < 0) { + (lngDiff + 360) / 360 + } else { + (lngDiff / 360) + } val globePixelSize = 256 // Google's world dimensions in pixels at zoom level 0 for the globe val latZoom = zoom(height, globePixelSize, latFraction) @@ -142,8 +166,41 @@ fun BoundingBox.zoomLevel(height: Int = 512, width: Int = 512, minZoom: Double = return minOf(latZoom, lngZoom, minZoom) } -fun Geometry.asFeature(properties: JsonObject? = JsonObject(mapOf()), bbox: BoundingBox? = null) = - Feature(this, properties, bbox) +// extension function to set a few supported properties on feature properties +// note, not everything is supported in geojson.io +// https://github.com/mapbox/simplestyle-spec/tree/master/1.1.0 +fun JsonObjectBuilder.markerColor(color: String = "red") = put("marker-color", color) + +/** + * Set size of small, medium, large + */ +fun JsonObjectBuilder.markerSize(size: String) = put("marker-size", size) +fun JsonObjectBuilder.markerSymbol(symbol: String) = put("marker-symbol", symbol) +fun JsonObjectBuilder.symbolColor(color: String) = put("symbol-color", color) +fun JsonObjectBuilder.stroke(color: String) = put("stroke", color) +fun JsonObjectBuilder.strokeOpacity(opacity: Double) = put("stroke-opacity", opacity) +fun JsonObjectBuilder.strokeWidth(width: Double) = put("stroke-opacity", width) +fun JsonObjectBuilder.fill(color: String) = put("fill", color) +fun JsonObjectBuilder.fillOpacity(opacity: Double) = put("fill-opacity", opacity) +fun JsonObjectBuilder.title(title: String) = put("title", title) +fun JsonObjectBuilder.description(description: String) = put("description", description) + +fun Geometry.asFeature( + properties: JsonObject? = null, + bbox: BoundingBox? = null, +): Feature { + return Feature(this, properties, bbox) +} +fun Geometry.asFeatureWithProperties( + bbox: BoundingBox? = null, + propertiesBuilder: (JsonObjectBuilder.() -> Unit) +): Feature { + val ps = buildJsonObject { + propertiesBuilder.invoke(this) + } + + return Feature(this, ps, bbox) +} private fun deepEquals(left: Array<*>?, right: Array<*>?): Boolean { // for some reason the kotlin compiler freaks out over right == null and insists there is no equals method @@ -179,6 +236,7 @@ sealed class Geometry { is GeometryCollection -> GeometryType.GeometryCollection } } + @Serializable @SerialName("Point") data class Point( @@ -392,6 +450,12 @@ data class Feature( override fun toString(): String = Json.encodeToString(serializer(), this) } +fun Collection.toFeatureCollection(properties: JsonObject? = null) = + FeatureCollection(map { it.geometry().asFeature(properties) }) + +fun Collection.asFeatureCollection(properties: JsonObject? = null) = + FeatureCollection(map { it.asFeature(properties) }) + @Serializable data class FeatureCollection( val features: List, diff --git a/src/commonMain/kotlin/com/jillesvangurp/geojson/json-helpers.kt b/src/commonMain/kotlin/com/jillesvangurp/geojson/json-helpers.kt new file mode 100644 index 0000000..9635496 --- /dev/null +++ b/src/commonMain/kotlin/com/jillesvangurp/geojson/json-helpers.kt @@ -0,0 +1,59 @@ +package com.jillesvangurp.geojson + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +val DEFAULT_JSON: Json by lazy { + Json { + // don't rely on external systems being written in kotlin or even having a language with default values + // the default of false is FFing insane and dangerous + encodeDefaults = true + // save space + prettyPrint = false + // people adding shit to the json is OK, we're forward compatible and will just ignore it + isLenient = true + // encoding nulls is meaningless and a waste of space. + explicitNulls = false + // adding enum values is OK even if older clients won't understand it + ignoreUnknownKeys = true + } +} + +val DEFAULT_JSON_PRETTY: Json by lazy { + Json { + // don't rely on external systems being written in kotlin or even having a language with default values + // the default of false is FFing insane and dangerous + encodeDefaults = true + // save space + prettyPrint = false + // people adding shit to the json is OK, we're forward compatible and will just ignore it + isLenient = true + // encoding nulls is meaningless and a waste of space. + explicitNulls = false + // adding enum values is OK even if older clients won't understand it + ignoreUnknownKeys = true + prettyPrint = true + } +} + + +val FeatureCollection.geoJsonIOUrl get() = DEFAULT_JSON.encodeToString(this).let { json-> + "https://geojson.io/#data=${"data:application/json,$json".urlEncode()}" +} + +val Geometry.geoJsonIOUrl get() = this.asFeatureCollection.geoJsonIOUrl + +fun String.urlEncode(): String { + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '.', '_', '~') + return buildString { + this@urlEncode.forEach { char -> + if (char in allowedChars) { + append(char) + } else { + append(char.toByte().toInt().let { + "%${it.shr(4).and(0xF).toString(16)}${it.and(0xF).toString(16)}" + }.toUpperCase()) + } + } + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/ConcaveHullTest.kt b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/ConcaveHullTest.kt index 13ea510..2cfd63f 100644 --- a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/ConcaveHullTest.kt +++ b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/ConcaveHullTest.kt @@ -26,7 +26,7 @@ class ConcaveHullTest { ).toTypedArray())) println( - jsonPretty.encodeToString(FeatureCollection.serializer(), FeatureCollection(listOf(p.asFeature(), polygon.asFeature()))) + DEFAULT_JSON_PRETTY.encodeToString(FeatureCollection.serializer(), FeatureCollection(listOf(p.asFeature(), polygon.asFeature()))) ) } diff --git a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoGeometryMigratedTests.kt b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoGeometryMigratedTests.kt index 161ca82..198221a 100644 --- a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoGeometryMigratedTests.kt +++ b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoGeometryMigratedTests.kt @@ -21,7 +21,6 @@ import com.jillesvangurp.geo.GeoGeometry.Companion.polygonForPoints import com.jillesvangurp.geo.GeoGeometry.Companion.rightTurn import com.jillesvangurp.geo.GeoGeometry.Companion.roundToDecimals import com.jillesvangurp.geo.GeoGeometry.Companion.simplifyLine -import com.jillesvangurp.geo.GeoGeometry.Companion.translate import com.jillesvangurp.geo.GeoGeometry.Companion.validate import com.jillesvangurp.geo.GeoGeometry.Companion.vicentyDistance import com.jillesvangurp.geo.GeoHashUtils.Companion.isWest @@ -35,7 +34,9 @@ import io.kotest.matchers.doubles.shouldBeLessThanOrEqual import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe -import kotlin.math.* +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.round import kotlin.random.Random import kotlin.test.Test @@ -152,16 +153,6 @@ class GeoGeometryMigratedTests { round(vicentyDistance(bergstr16Berlin, bergstr16InvalidenBerlin)) shouldBe 135.0 } - @Test - fun shouldTranslateCorrectly() { - val translated = translate(52.530564, 13.394964, 1000.0, 3000.0) - val pythagorasDistance = sqrt(1000.0.pow(2.0) + 3000.0.pow(2.0)) - val distance = distance(doubleArrayOf(13.394964, 52.530564), translated) - withClue("distance should be correct for translated coordinate") { - abs(distance - pythagorasDistance) shouldBeLessThan 1.0 - } - } - @Test fun shouldHaveDistanceOfRadiusForEachPoint() { val radius = 50000 diff --git a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoGeometryTest.kt b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoGeometryTest.kt index 587fade..ddd022c 100644 --- a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoGeometryTest.kt +++ b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoGeometryTest.kt @@ -11,7 +11,6 @@ import io.kotest.matchers.doubles.shouldBeLessThan import io.kotest.matchers.shouldBe import kotlinx.serialization.json.JsonObject import kotlin.math.abs -import kotlin.math.absoluteValue import kotlin.math.roundToLong import kotlin.test.Test @@ -78,53 +77,20 @@ class GeoGeometryTest { @Test fun shouldBeValid() { - val polygon = json.decodeFromString(Geometry.serializer(), badGeo) as Geometry.Polygon + val polygon = DEFAULT_JSON.decodeFromString(Geometry.serializer(), badGeo) as Geometry.Polygon polygon.coordinates?.isValid() shouldBe false (polygon.ensureFollowsRightHandSideRule() as Geometry.Polygon).coordinates?.isValid() shouldBe true } @Test fun shouldSerializeToSame() { - val polygonObject = json.decodeFromString(JsonObject.serializer(), testPolygon) - val polygon = json.decodeFromJsonElement(Geometry.serializer(), polygonObject) as Geometry.Polygon - val serializedPolygonObject = json.encodeToJsonElement(Geometry.serializer(), polygon) + val polygonObject = DEFAULT_JSON.decodeFromString(JsonObject.serializer(), testPolygon) + val polygon = DEFAULT_JSON.decodeFromJsonElement(Geometry.serializer(), polygonObject) as Geometry.Polygon + val serializedPolygonObject = DEFAULT_JSON.encodeToJsonElement(Geometry.serializer(), polygon) polygonObject shouldBe serializedPolygonObject } - @Test - fun shouldRotate() { - val anchor = bergstr16Berlin - val point = oranienburgerTor - val d = GeoGeometry.distance(anchor, point) - val points = (0..240).step(10).map { - GeoGeometry.rotateAround(anchor, point, it.toDouble()) - } - .also { - // all points should be the same distance - it.forEach { (GeoGeometry.distance(bergstr16Berlin, it) - d).absoluteValue shouldBeLessThan 0.1 } - it.size shouldBe 25 - it.distinct().size shouldBe 25 - it.contains(bergstr16Berlin) shouldBe false - } - - val features = (points + point + anchor).map { - Geometry.Point(coordinates = it) - }.map { - it.asFeature() - } - println(FeatureCollection(features)) - } - - @Test - fun rotateByZeroDegreesShouldBeSamePoint() { - val anchor = bergstr16Berlin - val point = oranienburgerTor - GeoGeometry.distance(point, GeoGeometry.rotateAround(anchor, point, 0.0)) shouldBeLessThan 1.0 - (GeoGeometry.distance(point, GeoGeometry.rotateAround(anchor, point, 180.0)) - 2 * GeoGeometry.distance(anchor, - point)).absoluteValue shouldBeLessThan 1.0 - } - @Test fun headingFromTwoPoints() { GeoGeometry.headingFromTwoPoints( diff --git a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoHashUtilsTest.kt b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoHashUtilsTest.kt index cf78dd6..d964727 100644 --- a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoHashUtilsTest.kt +++ b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/GeoHashUtilsTest.kt @@ -108,12 +108,12 @@ class GeoHashUtilsTest { ] } """.trimIndent() - val p = json.decodeFromString(Geometry.serializer(), concavePolygon) as Geometry.Polygon + val p = DEFAULT_JSON.decodeFromString(Geometry.serializer(), concavePolygon) as Geometry.Polygon val coordinates = p.coordinates?.get(0) ?: throw IllegalStateException() val hashes = GeoHashUtils.geoHashesForLinearRing(coordinates = coordinates, includePartial = true) println(hashes.size) - println(json.encodeToString(FeatureCollection.serializer(), FeatureCollection.fromGeoHashes(hashes))) + println(DEFAULT_JSON.encodeToString(FeatureCollection.serializer(), FeatureCollection.fromGeoHashes(hashes))) val totalHashedArea = hashes.map { GeoHashUtils.decodeBbox(it) }.sumOf { GeoGeometry.area(it) } val bboxArea = GeoGeometry.area(GeoGeometry.boundingBox(p.coordinates as PolygonCoordinates)) @@ -443,7 +443,7 @@ class GeoHashUtilsTest { val longitude = 13.401284 val radius = 100 val hashes = GeoHashUtils.geoHashesForCircle(8, latitude, longitude, radius.toDouble()) - println(json.encodeToString(FeatureCollection.serializer(), FeatureCollection.fromGeoHashes(hashes))) + println(DEFAULT_JSON.encodeToString(FeatureCollection.serializer(), FeatureCollection.fromGeoHashes(hashes))) println(hashes.size) for (hash in hashes) { GeoGeometry.distance( diff --git a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/RotateScaleTranslateTest.kt b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/RotateScaleTranslateTest.kt new file mode 100644 index 0000000..7b37d40 --- /dev/null +++ b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/RotateScaleTranslateTest.kt @@ -0,0 +1,251 @@ +package com.jillesvangurp.geogeometry + +import com.jillesvangurp.geo.GeoGeometry +import com.jillesvangurp.geojson.* +import io.kotest.assertions.withClue +import io.kotest.matchers.collections.shouldContainInOrder +import io.kotest.matchers.doubles.shouldBeGreaterThan +import io.kotest.matchers.doubles.shouldBeLessThan +import io.kotest.matchers.shouldBe +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.math.* +import kotlin.test.Test + +class RotationTest { + + @Test + fun shouldTranslateCircle() { + val circle = GeoGeometry.circle2polygon(20, rosenthalerPlatz.latitude, rosenthalerPlatz.longitude, 20.0) + .polygonGeometry() + + val moved = circle.translate(oranienburgerTor.geometry()) + + // we'll allow a few meters deviation. Earth is not perfectly spherical + GeoGeometry.distance(oranienburgerTor, moved.centroid()) shouldBeLessThan 10.0 + moved as Geometry.Polygon + moved.coordinates?.get(0)!!.forEach { + // radius of the circle should be similar, it will change a little + val radius = GeoGeometry.distance(moved.centroid(), it) + radius shouldBeGreaterThan 19.0 + radius shouldBeLessThan 21.0 + } + } + + @Test + fun scaleXShouldScaleCorrectly() { + val rectangle = rectangle(brandenBurgerGate, 70.0) + var cs = rectangle.coordinates!! + GeoGeometry.distance(cs[0][0], cs[0][1]).roundToLong() shouldBe 70 + GeoGeometry.distance(cs[0][1], cs[0][2]).roundToLong() shouldBe 70 + cs = rectangle.scaleX(30.0).coordinates!! + GeoGeometry.distance(cs[0][0], cs[0][1]).roundToLong() shouldBe (70.0 * 0.3).toLong() + cs = rectangle.scaleX(130.0).coordinates!! + GeoGeometry.distance(cs[0][0], cs[0][1]).roundToLong() shouldBe (70.0 * 1.3).toLong() + cs = rectangle.scaleY(30.0).coordinates!! + GeoGeometry.distance(cs[0][1], cs[0][2]).roundToLong() shouldBe (70.0 * 0.3).toLong() + cs = rectangle.scaleY(130.0).coordinates!! + GeoGeometry.distance(cs[0][1], cs[0][2]).roundToLong() shouldBe (70.0 * 1.3).toLong() + } + + @Test + fun shouldRotateCorrectly() { + val rectangle = rectangle(brandenBurgerGate, 70.0) + val centroid = rectangle.centroid() + var cs = rectangle.coordinates!! + cs[0].forEach { it.distanceTo(centroid).roundToLong() shouldBe 49 } + cs = rectangle.rotate(45.0).coordinates!! + cs[0].forEach { it.distanceTo(centroid).roundToLong() shouldBe 49 } + cs = rectangle.rotate(360.0).coordinates!! + cs[0].forEach { it.distanceTo(centroid).roundToLong() shouldBe 49 } + cs = rectangle.rotate(2000.0).coordinates!! + cs[0].forEach { it.distanceTo(centroid).roundToLong() shouldBe 49 } + } + + @Test + fun shouldScaleAndRotateRectangle() { + val rectangle = rectangle(brandenBurgerGate, 70.0) + + FeatureCollection( + listOf( + rectangle.asFeature(properties = mapOf("fill" to "red").toJsonObject()), + Geometry.Point(rectangle.centroid()) + .asFeature(properties = mapOf("marker-color" to "red").toJsonObject()), + Geometry.Point(rectangle.coordinates!![0][0]) + .asFeature(properties = mapOf("marker-color" to "blue").toJsonObject()), + Geometry.Point(rectangle.coordinates!![0][1]) + .asFeature(properties = mapOf("marker-color" to "yellow").toJsonObject()), + Geometry.Point(rectangle.coordinates!![0][2]) + .asFeature(properties = mapOf("marker-color" to "green").toJsonObject()), + Geometry.Point(rectangle.coordinates!![0][3]) + .asFeature(properties = mapOf("marker-color" to "purple").toJsonObject()), + + rectangle.scaleX(50.0).asFeature(properties = mapOf("fill" to "blue").toJsonObject()), + rectangle.scaleY(50.0).asFeature(properties = mapOf("fill" to "purple").toJsonObject()), + rectangle.scaleX(130.0).asFeature(properties = mapOf("fill" to "green").toJsonObject()), + rectangle.scaleY(130.0).asFeature(properties = mapOf("fill" to "yellow").toJsonObject()), + rectangle.rotate(10.0).asFeature(properties = mapOf("fill" to "brown").toJsonObject()), + rectangle.rotate(45.0).asFeature(properties = mapOf("fill" to "grey").toJsonObject()), + + ) + ).let { + println(Json.Default.encodeToString(it)) + } + } + + @Test + fun shouldMoveInRightDirection() { + + val circle = GeoGeometry.circle2polygon(20, rosenthalerPlatz.latitude, rosenthalerPlatz.longitude, 20.0) + .polygonGeometry() + listOf( + GeoGeometry.translate(circle.centroid().latitude, circle.centroid().longitude, 0.0, 50.0), + GeoGeometry.translate(circle.centroid().latitude, circle.centroid().longitude, 50.0, 0.0), + GeoGeometry.translate(circle.centroid().latitude, circle.centroid().longitude, -50.0, 0.0), + GeoGeometry.translate(circle.centroid().latitude, circle.centroid().longitude, 0.0, -50.0), + ).forEach { point -> + GeoGeometry.distance(circle.translate(Geometry.Point(point)).centroid(), point) shouldBeLessThan 1.0 + } + } + + @Test + fun shouldRotate() { + val anchor = bergstr16Berlin + val point = oranienburgerTor + val d = GeoGeometry.distance(anchor, point) + val points = (0..240).step(10).map { + GeoGeometry.rotateAround(anchor, point, it.toDouble()) + } + .also { + // all points should be the same distance + it.forEach { (GeoGeometry.distance(bergstr16Berlin, it) - d).absoluteValue shouldBeLessThan 0.1 } + it.size shouldBe 25 + it.distinct().size shouldBe 25 + it.contains(bergstr16Berlin) shouldBe false + } + + val features = (points + point + anchor).map { + Geometry.Point(coordinates = it) + }.map { + it.asFeature() + } + println(FeatureCollection(features)) + } + + @Test + fun rotateByZeroDegreesShouldBeSamePoint() { + val anchor = bergstr16Berlin + val point = oranienburgerTor + GeoGeometry.distance(point, GeoGeometry.rotateAround(anchor, point, 0.0)) shouldBeLessThan 1.0 + (GeoGeometry.distance(point, GeoGeometry.rotateAround(anchor, point, 180.0)) - 2 * GeoGeometry.distance( + anchor, + point + )).absoluteValue shouldBeLessThan 1.0 + } + + @Test + fun shouldTranslateCorrectly() { + val translated = GeoGeometry.translate(52.530564, 13.394964, 1000.0, 3000.0) + val pythagorasDistance = sqrt(1000.0.pow(2.0) + 3000.0.pow(2.0)) + val distance = GeoGeometry.distance(doubleArrayOf(13.394964, 52.530564), translated) + withClue("distance should be correct for translated coordinate") { + abs(distance - pythagorasDistance) shouldBeLessThan 1.0 + } + } + + @Test + fun shouldRotatePointAround() { + val around = moritzPlatz + val distance = GeoGeometry.distance(around, rosenthalerPlatz) + + val rotatedPoints = (0..36).map { d -> + rosenthalerPlatz.rotate(d.toDouble() * 10, around).geometry().asFeatureWithProperties { + val gray = (d.toDouble()/36*255).roundToInt().toString(16).padStart(2,'0') + markerColor("#$gray$gray$gray") + title("$d") + } + } + rotatedPoints.forEach { + val actualDistance = GeoGeometry.distance(around, (it.geometry as Geometry.Point).coordinates!!) + withClue("$actualDistance is too different from expected $distance") { + abs(actualDistance - distance) shouldBeLessThan 1.0 + } + } + + println(FeatureCollection(rotatedPoints + around.geometry().asFeatureWithProperties { + markerColor("red") + }).geoJsonIOUrl) + } + + @Test + fun shouldRotateTriangle() { + val triangle = arrayOf(arrayOf(rosenthalerPlatz, moritzPlatz, potsDammerPlatz, rosenthalerPlatz)) + FeatureCollection(listOf( + triangle.polygonGeometry().asFeatureWithProperties { + fill("red") + fillOpacity(0.25) + }, + rosenthalerPlatz.geometry().asFeatureWithProperties { + markerColor("green") + title("og rosenthaler") + }, + rosenthalerPlatz.rotate(0.0, moritzPlatz).geometry().asFeatureWithProperties { + markerColor("pink") + title("0 degrees") + }, + rosenthalerPlatz.rotate(10.0, moritzPlatz).geometry().asFeatureWithProperties { + markerColor("yellow") + title("10 degrees") + }, + potsDammerPlatz.rotate(20.0, rosenthalerPlatz).geometry().asFeatureWithProperties { + fill("brown") + fillOpacity(0.25) + title("20 degrees potsdammerplatz") + }, + triangle.rotate(20.0, rosenthalerPlatz).polygonGeometry().asFeatureWithProperties { + fill("green") + fillOpacity(0.25) + }, + triangle.rotate(20.0, rosenthalerPlatz).rotate(20.0, rosenthalerPlatz).polygonGeometry().asFeatureWithProperties { + fill("blue") + fillOpacity(0.25) + } + )).geoJsonIOUrl.let { + println(it) + } + } + + @Test + fun shouldRotateMetersCorrectly() { + rotateMeters(2.0,2.0, 0.0).map { it.roundToInt().toDouble() } shouldContainInOrder listOf(2.0,2.0) + rotateMeters(2.0,2.0, 180.0).map { it.roundToInt().toDouble() } shouldContainInOrder listOf(-2.0,-2.0) + } + + @Test + fun shouldMoveBackAndForth() { + val moved = rosenthalerPlatz.translate(-1000.0,-2000.0).translate(1000.0,2000.0) + GeoGeometry.distance(rosenthalerPlatz,moved) shouldBeLessThan 1.0 + } +} + +fun rotateMeters(x:Double, y:Double, degrees: Double): List { + val radians = GeoGeometry.toRadians(degrees) + + val newX = x*cos(radians) - y*sin(radians) + val newY = x*sin(radians) + y*cos(radians) + + return listOf(newX,newY) +} + +fun rectangle(point: PointCoordinates, size: Double): Geometry.Polygon { + return arrayOf( + arrayOf( + point.translate(size / 2.0, size / -2.0), + point.translate(size / 2.0, size / 2.0), + point.translate(size / -2.0, size / 2.0), + point.translate(size / -2.0, size / -2.0), + point.translate(size / 2.0, size / -2.0), + ) + ).polygonGeometry() +} + diff --git a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/base64.kt b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/base64.kt new file mode 100644 index 0000000..8b19514 --- /dev/null +++ b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/base64.kt @@ -0,0 +1,412 @@ +package com.jillesvangurp.geogeometry + +/* + * Copyright (c) 2012, 2018, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import kotlin.math.min + +/** + * This class consists exclusively of static methods for obtaining + * encoders and decoders for the Base64 encoding scheme. The + * implementation of this class supports the following types of Base64 + * as specified in + * [RFC 4648](http://www.ietf.org/rfc/rfc4648.txt) and + * [RFC 2045](http://www.ietf.org/rfc/rfc2045.txt). + * + * + * * **Basic** + * + * Uses "The Base64 Alphabet" as specified in Table 1 of + * RFC 4648 and RFC 2045 for encoding and decoding operation. + * The encoder does not add any line feed (line separator) + * character. The decoder rejects data that contains characters + * outside the base64 alphabet. + * + * * **URL and Filename safe** + * + * Uses the "URL and Filename safe Base64 Alphabet" as specified + * in Table 2 of RFC 4648 for encoding and decoding. The + * encoder does not add any line feed (line separator) character. + * The decoder rejects data that contains characters outside the + * base64 alphabet. + * + * * **MIME** + * + * Uses "The Base64 Alphabet" as specified in Table 1 of + * RFC 2045 for encoding and decoding operation. The encoded output + * must be represented in lines of no more than 76 characters each + * and uses a carriage return `'\r'` followed immediately by + * a linefeed `'\n'` as the line separator. No line separator + * is added to the end of the encoded output. All line separators + * or other characters not found in the base64 alphabet table are + * ignored in decoding operation. + * + * + * + * Unless otherwise noted, passing a `null` argument to a + * method of this class will cause a [ NullPointerException][java.lang.NullPointerException] to be thrown. + * + * @author Xueming Shen + * @since 1.8 + */ +object Base64 { + + fun ByteArray.encodeToBase64(): String { + return encoder.encode(this).decodeToString() + } + + fun String.decodeFromBase64(): ByteArray { + return decoder.decode(this.encodeToByteArray()) + } + + /** + * Returns a [Encoder] that encodes using the + * [Basic](#basic) type base64 encoding scheme. + * + * @return A Base64 encoder. + */ + val encoder = Encoder(null, -1, true) + + /** + * Returns a [Decoder] that decodes using the + * [Basic](#basic) type base64 encoding scheme. + * + * @return A Base64 decoder. + */ + val decoder = Decoder() + + /** + * This class implements an encoder for encoding byte data using + * the Base64 encoding scheme as specified in RFC 4648 and RFC 2045. + * + * + * Instances of [Encoder] class are safe for use by + * multiple concurrent threads. + * + * + * Unless otherwise noted, passing a `null` argument to + * a method of this class will cause a + * [NullPointerException][java.lang.NullPointerException] to + * be thrown. + * + * @see Decoder + * + * @since 1.8 + */ + class Encoder internal constructor(private val newline: ByteArray?, private val linemax: Int, private val doPadding: Boolean) { + private fun outLength(srclen: Int): Int { + var len = 0 + len = if (doPadding) { + 4 * ((srclen + 2) / 3) + } else { + val n = srclen % 3 + 4 * (srclen / 3) + if (n == 0) 0 else n + 1 + } + if (linemax > 0) // line separators + len += (len - 1) / linemax * newline!!.size + return len + } + + /** + * Encodes all bytes from the specified byte array into a newly-allocated + * byte array using the [Base64] encoding scheme. The returned byte + * array is of the length of the resulting bytes. + * + * @param src + * the byte array to encode + * @return A newly-allocated byte array containing the resulting + * encoded bytes. + */ + fun encode(src: ByteArray): ByteArray { + val len = outLength(src.size) // dst array size + val dst = ByteArray(len) + val ret = encode0(src, 0, src.size, dst) + return if (ret != dst.size) dst.copyOf(ret) else dst + } + + private fun encodeBlock(src: ByteArray, sp: Int, sl: Int, dst: ByteArray, dp: Int) { + var sp0 = sp + var dp0 = dp + while (sp0 < sl) { + val bits: Int = src[sp0++].toInt() and 0xff shl 16 or ( + src[sp0++].toInt() and 0xff shl 8) or + (src[sp0++].toInt() and 0xff) + dst[dp0++] = toBase64[bits ushr 18 and 0x3f].toByte() + dst[dp0++] = toBase64[bits ushr 12 and 0x3f].toByte() + dst[dp0++] = toBase64[bits ushr 6 and 0x3f].toByte() + dst[dp0++] = toBase64[bits and 0x3f].toByte() + } + } + + private fun encode0(src: ByteArray, off: Int, end: Int, dst: ByteArray): Int { + val base64 = toBase64 + var sp = off + var slen = (end - off) / 3 * 3 + val sl = off + slen + if (linemax > 0 && slen > linemax / 4 * 3) slen = linemax / 4 * 3 + var dp = 0 + while (sp < sl) { + val sl0: Int = min(sp + slen, sl) + encodeBlock(src, sp, sl0, dst, dp) + val dlen = (sl0 - sp) / 3 * 4 + dp += dlen + sp = sl0 + if (dlen == linemax && sp < end) { + for (b in newline!!) { + dst[dp++] = b + } + } + } + if (sp < end) { // 1 or 2 leftover bytes + val b0: Int = src[sp++].toInt() and 0xff + dst[dp++] = base64[b0 shr 2].toByte() + if (sp == end) { + dst[dp++] = base64[b0 shl 4 and 0x3f].toByte() + if (doPadding) { + dst[dp++] = '='.toByte() + dst[dp++] = '='.toByte() + } + } else { + val b1: Int = src[sp++].toInt() and 0xff + dst[dp++] = base64[b0 shl 4 and 0x3f or (b1 shr 4)].toByte() + dst[dp++] = base64[b1 shl 2 and 0x3f].toByte() + if (doPadding) { + dst[dp++] = '='.toByte() + } + } + } + return dp + } + + companion object { + /** + * This array is a lookup table that translates 6-bit positive integer + * index values into their "Base64 Alphabet" equivalents as specified + * in "Table 1: The Base64 Alphabet" of RFC 2045 (and RFC 4648). + */ + val toBase64 = charArrayOf( + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/' + ) + + /** + * It's the lookup table for "URL and Filename safe Base64" as specified + * in Table 2 of the RFC 4648, with the '+' and '/' changed to '-' and + * '_'. This table is used when BASE64_URL is specified. + */ + internal val toBase64URL = charArrayOf( + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_' + ) + } + } + + /** + * This class implements a decoder for decoding byte data using the + * Base64 encoding scheme as specified in RFC 4648 and RFC 2045. + * + * + * The Base64 padding character `'='` is accepted and + * interpreted as the end of the encoded byte data, but is not + * required. So if the final unit of the encoded byte data only has + * two or three Base64 characters (without the corresponding padding + * character(s) padded), they are decoded as if followed by padding + * character(s). If there is a padding character present in the + * final unit, the correct number of padding character(s) must be + * present, otherwise `IllegalArgumentException` ( + * `IOException` when reading from a Base64 stream) is thrown + * during decoding. + * + * + * Instances of [Decoder] class are safe for use by + * multiple concurrent threads. + * + * + * Unless otherwise noted, passing a `null` argument to + * a method of this class will cause a + * [NullPointerException][java.lang.NullPointerException] to + * be thrown. + * + * @see Encoder + * + * @since 1.8 + */ + class Decoder { + companion object { + /** + * Lookup table for decoding unicode characters drawn from the + * "Base64 Alphabet" (as specified in Table 1 of RFC 2045) into + * their 6-bit positive integer equivalents. Characters that + * are not in the Base64 alphabet but fall within the bounds of + * the array are encoded to -1. + * + */ + internal val fromBase64 = IntArray(256) + + /** + * Lookup table for decoding "URL and Filename safe Base64 Alphabet" + * as specified in Table2 of the RFC 4648. + */ + private val fromBase64URL = IntArray(256) + + init { + fromBase64.fill(-1) + for (i in Encoder.toBase64.indices) fromBase64[Encoder.toBase64[i].toInt()] = i + fromBase64['='.toInt()] = -2 + } + + init { + fromBase64URL.fill(-1) + for (i in Encoder.toBase64URL.indices) fromBase64URL[Encoder.toBase64URL[i].toInt()] = i + fromBase64URL['='.toInt()] = -2 + } + } + + /** + * Decodes all bytes from the input byte array using the [Base64] + * encoding scheme, writing the results into a newly-allocated output + * byte array. The returned byte array is of the length of the resulting + * bytes. + * + * @param src + * the byte array to decode + * + * @return A newly-allocated byte array containing the decoded bytes. + * + * @throws IllegalArgumentException + * if `src` is not in valid Base64 scheme + */ + fun decode(src: ByteArray): ByteArray { + var dst = ByteArray(outLength(src, 0, src.size)) + val ret = decode0(src, 0, src.size, dst) + if (ret != dst.size) { + dst = dst.copyOf(ret) + } + return dst + } + + + private fun outLength(src: ByteArray, sp: Int, sl: Int): Int { + var sp = sp + var paddings = 0 + var len = sl - sp + if (len == 0) return 0 + if (len < 2) { + throw IllegalArgumentException( + "Input byte[] should at least have 2 bytes for base64 bytes" + ) + } + if (src[sl - 1].toChar() == '=') { + paddings++ + if (src[sl - 2].toChar() == '=') paddings++ + } + if (paddings == 0 && len and 0x3 != 0) paddings = 4 - (len and 0x3) + return 3 * ((len + 3) / 4) - paddings + } + + private fun decode0(src: ByteArray, sp: Int, sl: Int, dst: ByteArray): Int { + var sp = sp + val base64 = if (false) fromBase64URL else fromBase64 + var dp = 0 + var bits = 0 + var shiftto = 18 // pos of first byte of 4-byte atom + while (sp < sl) { + if (shiftto == 18 && sp + 4 < sl) { // fast path + val sl0 = sp + (sl - sp and 3.inv()) + while (sp < sl0) { + val b1 = base64[src[sp++].toInt() and 0xff] + val b2 = base64[src[sp++].toInt() and 0xff] + val b3 = base64[src[sp++].toInt() and 0xff] + val b4 = base64[src[sp++].toInt() and 0xff] + if (b1 or b2 or b3 or b4 < 0) { // non base64 byte + sp -= 4 + break + } + val bits0 = b1 shl 18 or (b2 shl 12) or (b3 shl 6) or b4 + dst[dp++] = (bits0 shr 16).toByte() + dst[dp++] = (bits0 shr 8).toByte() + dst[dp++] = bits0.toByte() + } + if (sp >= sl) break + } + var b: Int = src[sp++].toInt() and 0xff + if (base64[b].also { b = it } < 0) { + if (b == -2) { // padding byte '=' + // = shiftto==18 unnecessary padding + // x= shiftto==12 a dangling single x + // x to be handled together with non-padding case + // xx= shiftto==6&&sp==sl missing last = + // xx=y shiftto==6 last is not = + require( + !(shiftto == 6 && (sp == sl || src[sp++].toChar() != '=') || + shiftto == 18) + ) { "Input byte array has wrong 4-byte ending unit" } + break + } + throw IllegalArgumentException("Illegal base64 character " + src[sp - 1].toInt().toString(16)) + } + bits = bits or (b shl shiftto) + shiftto -= 6 + if (shiftto < 0) { + dst[dp++] = (bits shr 16).toByte() + dst[dp++] = (bits shr 8).toByte() + dst[dp++] = bits.toByte() + shiftto = 18 + bits = 0 + } + } + // reached end of byte array or hit padding '=' characters. + when (shiftto) { + 6 -> { + dst[dp++] = (bits shr 16).toByte() + } + 0 -> { + dst[dp++] = (bits shr 16).toByte() + dst[dp++] = (bits shr 8).toByte() + } + else -> require(shiftto != 12) { + // dangling single "x", incorrectly encoded. + "Last unit does not have enough valid bits" + } + } + // anything left is invalid, if is not MIME. + // if MIME, ignore all non-base64 character + while (sp < sl) { + throw IllegalArgumentException( + "Input byte array has incorrect ending byte at $sp" + ) + } + return dp + } + } +} + + diff --git a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/helpers.kt b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/helpers.kt index 8c2920a..ed9cd08 100644 --- a/src/commonTest/kotlin/com/jillesvangurp/geogeometry/helpers.kt +++ b/src/commonTest/kotlin/com/jillesvangurp/geogeometry/helpers.kt @@ -3,9 +3,11 @@ package com.jillesvangurp.geogeometry import com.jillesvangurp.geojson.PointCoordinates +import com.jillesvangurp.geojson.DEFAULT_JSON import io.kotest.matchers.doubles.shouldBeLessThan import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import kotlin.math.abs infix fun PointCoordinates.shouldBeNear(expected: PointCoordinates) { @@ -22,36 +24,5 @@ infix fun Double.shouldBeApproximately(other: Double) { this.shouldBeApproximately(other, 0.0000001) } -val json: Json by lazy { - Json { - // don't rely on external systems being written in kotlin or even having a language with default values - // the default of false is FFing insane and dangerous - encodeDefaults = true - // save space - prettyPrint = false - // people adding shit to the json is OK, we're forward compatible and will just ignore it - isLenient = true - // encoding nulls is meaningless and a waste of space. - explicitNulls = false - // adding enum values is OK even if older clients won't understand it - ignoreUnknownKeys = true - } -} - +fun Map.toJsonObject() = DEFAULT_JSON.encodeToJsonElement(this).jsonObject -val jsonPretty: Json by lazy { - Json { - // don't rely on external systems being written in kotlin or even having a language with default values - // the default of false is FFing insane and dangerous - encodeDefaults = true - // save space - prettyPrint = false - // people adding shit to the json is OK, we're forward compatible and will just ignore it - isLenient = true - // encoding nulls is meaningless and a waste of space. - explicitNulls = false - // adding enum values is OK even if older clients won't understand it - ignoreUnknownKeys = true - prettyPrint = true - } -} diff --git a/src/commonTest/kotlin/com/jillesvangurp/geojson/GeoJsonExtensionsTest.kt b/src/commonTest/kotlin/com/jillesvangurp/geojson/GeoJsonExtensionsTest.kt index bd0aa4f..31c0bbe 100644 --- a/src/commonTest/kotlin/com/jillesvangurp/geojson/GeoJsonExtensionsTest.kt +++ b/src/commonTest/kotlin/com/jillesvangurp/geojson/GeoJsonExtensionsTest.kt @@ -1,35 +1,17 @@ package com.jillesvangurp.geojson import com.jillesvangurp.geo.GeoGeometry -import com.jillesvangurp.geogeometry.* +import com.jillesvangurp.geogeometry.oranienburgerTor +import com.jillesvangurp.geogeometry.rosenthalerPlatz import io.kotest.matchers.doubles.shouldBeGreaterThan import io.kotest.matchers.doubles.shouldBeLessThan -import io.kotest.matchers.shouldBe -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.* -import kotlin.math.roundToLong +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import kotlin.test.Test class GeoJsonExtensionsTest { - @Test - fun shouldTranslateCircle() { - val circle = GeoGeometry.circle2polygon(20, rosenthalerPlatz.latitude, rosenthalerPlatz.longitude, 20.0) - .polygonGeometry() - - val moved = circle.translate(oranienburgerTor.geometry()) - - // we'll allow a few meters deviation. Earth is not perfectly spherical - GeoGeometry.distance(oranienburgerTor, moved.centroid()) shouldBeLessThan 10.0 - moved as Geometry.Polygon - moved.coordinates?.get(0)!!.forEach { - // radius of the circle should be similar, it will change a little - val radius = GeoGeometry.distance(moved.centroid(), it) - radius shouldBeGreaterThan 19.0 - radius shouldBeLessThan 21.0 - } - } - @Test fun shouldCreateTriangle() { GeoGeometry.circle2polygon(3, rosenthalerPlatz.latitude, rosenthalerPlatz.longitude, 20.0) @@ -37,88 +19,6 @@ class GeoJsonExtensionsTest { println(it) } } - - @Test - fun shouldMoveInRightDirection() { - - val circle = GeoGeometry.circle2polygon(20, rosenthalerPlatz.latitude, rosenthalerPlatz.longitude, 20.0) - .polygonGeometry() - listOf( - GeoGeometry.translate(circle.centroid().latitude, circle.centroid().longitude, 0.0, 50.0), - GeoGeometry.translate(circle.centroid().latitude, circle.centroid().longitude, 50.0, 0.0), - GeoGeometry.translate(circle.centroid().latitude, circle.centroid().longitude, -50.0, 0.0), - GeoGeometry.translate(circle.centroid().latitude, circle.centroid().longitude, 0.0, -50.0), - ).forEach { point -> - GeoGeometry.distance(circle.translate(Geometry.Point(point)).centroid(), point) shouldBeLessThan 1.0 - } - } - - @Test - fun scaleXShouldScaleCorrectly() { - val rectangle = rectangle(brandenBurgerGate, 70.0) - var cs = rectangle.coordinates!! - GeoGeometry.distance(cs[0][0], cs[0][1]).roundToLong() shouldBe 70 - GeoGeometry.distance(cs[0][1], cs[0][2]).roundToLong() shouldBe 70 - cs = rectangle.scaleX(30.0).coordinates!! - GeoGeometry.distance(cs[0][0], cs[0][1]).roundToLong() shouldBe (70.0*0.3).toLong() - cs = rectangle.scaleX(130.0).coordinates!! - GeoGeometry.distance(cs[0][0], cs[0][1]).roundToLong() shouldBe (70.0*1.3).toLong() - cs = rectangle.scaleY(30.0).coordinates!! - GeoGeometry.distance(cs[0][1], cs[0][2]).roundToLong() shouldBe (70.0*0.3).toLong() - cs = rectangle.scaleY(130.0).coordinates!! - GeoGeometry.distance(cs[0][1], cs[0][2]).roundToLong() shouldBe (70.0*1.3).toLong() - } - - @Test - fun shouldRotateCorrectly() { - val rectangle = rectangle(brandenBurgerGate, 70.0) - val centroid = rectangle.centroid() - var cs = rectangle.coordinates!! - cs[0].forEach { it.distanceTo(centroid).roundToLong() shouldBe 49 } - cs = rectangle.rotate(45.0).coordinates!! - cs[0].forEach { it.distanceTo(centroid).roundToLong() shouldBe 49 } - cs = rectangle.rotate(360.0).coordinates!! - cs[0].forEach { it.distanceTo(centroid).roundToLong() shouldBe 49 } - cs = rectangle.rotate(2000.0).coordinates!! - cs[0].forEach { it.distanceTo(centroid).roundToLong() shouldBe 49 } - } - - @Test - fun shouldScaleAndRotateRectangle() { - val rectangle = rectangle(brandenBurgerGate, 70.0) - - FeatureCollection(listOf( - rectangle.asFeature(properties = mapOf("fill" to "red").toJsonObject()), - Geometry.Point(rectangle.centroid()).asFeature(properties = mapOf("marker-color" to "red").toJsonObject()), - Geometry.Point(rectangle.coordinates!![0][0]).asFeature(properties = mapOf("marker-color" to "blue").toJsonObject()), - Geometry.Point(rectangle.coordinates!![0][1]).asFeature(properties = mapOf("marker-color" to "yellow").toJsonObject()), - Geometry.Point(rectangle.coordinates!![0][2]).asFeature(properties = mapOf("marker-color" to "green").toJsonObject()), - Geometry.Point(rectangle.coordinates!![0][3]).asFeature(properties = mapOf("marker-color" to "purple").toJsonObject()), - - rectangle.scaleX(50.0).asFeature(properties = mapOf("fill" to "blue").toJsonObject()), - rectangle.scaleY(50.0).asFeature(properties = mapOf("fill" to "purple").toJsonObject()), - rectangle.scaleX(130.0).asFeature(properties = mapOf("fill" to "green").toJsonObject()), - rectangle.scaleY(130.0).asFeature(properties = mapOf("fill" to "yellow").toJsonObject()), - rectangle.rotate(10.0).asFeature(properties = mapOf("fill" to "brown").toJsonObject()), - rectangle.rotate(45.0).asFeature(properties = mapOf("fill" to "grey").toJsonObject()), - - )).let { - println( Json.Default.encodeToString(it)) - } - } - -} -fun rectangle(point: PointCoordinates, size: Double): Geometry.Polygon { - return arrayOf( - arrayOf( - point.translate(size / 2.0, size / -2.0), - point.translate(size / 2.0, size / 2.0), - point.translate(size / -2.0, size / 2.0), - point.translate(size / -2.0, size / -2.0), - point.translate(size / 2.0, size / -2.0), - ) - ).polygonGeometry() } -fun Map.toJsonObject() = Json.Default.encodeToJsonElement(this).jsonObject diff --git a/src/commonTest/kotlin/com/jillesvangurp/geojson/GeojsonTest.kt b/src/commonTest/kotlin/com/jillesvangurp/geojson/GeojsonTest.kt index 7bf2843..93e589c 100644 --- a/src/commonTest/kotlin/com/jillesvangurp/geojson/GeojsonTest.kt +++ b/src/commonTest/kotlin/com/jillesvangurp/geojson/GeojsonTest.kt @@ -3,7 +3,6 @@ package com.jillesvangurp.geojson import com.jillesvangurp.geo.GeoGeometry import com.jillesvangurp.geo.GeoHashUtils import com.jillesvangurp.geogeometry.bergstr16Berlin -import com.jillesvangurp.geogeometry.json import io.kotest.matchers.collections.shouldContainInOrder import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -82,7 +81,7 @@ class GeojsonKtTest { @Test fun shouldCoverBerlinWithGeohashes() { - val berlin = json.decodeFromString(Geometry.serializer(), berlinGeoJson) as Geometry.MultiPolygon + val berlin = DEFAULT_JSON.decodeFromString(Geometry.serializer(), berlinGeoJson) as Geometry.MultiPolygon val hashes = GeoHashUtils.geoHashesForMultiPolygon( coordinates = berlin.coordinates ?: throw IllegalArgumentException("coordinates missing"), @@ -90,7 +89,7 @@ class GeojsonKtTest { maxLength = 6 ) FeatureCollection.fromGeoHashes(hashes) - val json = json.encodeToString( + val json = DEFAULT_JSON.encodeToString( FeatureCollection.serializer(), //hashesCollection + FeatureCollection(listOf(berlin.asFeature())) @@ -106,7 +105,7 @@ class GeojsonKtTest { @Test fun equalsTestForGeometry() { - val berlin = json.decodeFromString(Geometry.serializer(), berlinGeoJson) as Geometry.MultiPolygon + val berlin = DEFAULT_JSON.decodeFromString(Geometry.serializer(), berlinGeoJson) as Geometry.MultiPolygon berlin.shouldBe(berlin) berlin.shouldBe(berlin.copy()) diff --git a/versions.properties b/versions.properties index 5575fe1..d6bb9ce 100644 --- a/versions.properties +++ b/versions.properties @@ -11,8 +11,9 @@ version.junit.jupiter=5.10.1 version.kotest=5.8.0 -version.kotlin=1.9.21 +version.kotlin=1.9.22 +## # available=1.9.22 ## # available=2.0.0-Beta1 ## # available=2.0.0-Beta2 -version.kotlinx.serialization=1.6.2 \ No newline at end of file +version.kotlinx.serialization=1.6.2