Skip to content

Commit

Permalink
Merge pull request #38 from jillesvangurp/geojsonio-support-and-impro…
Browse files Browse the repository at this point in the history
…ve-geo-transformations

geojsonio support and improve geo transformations
  • Loading branch information
jillesvangurp authored Jan 9, 2024
2 parents 80d5998 + 6e7fe2c commit 51465b0
Show file tree
Hide file tree
Showing 14 changed files with 890 additions and 230 deletions.
47 changes: 41 additions & 6 deletions src/commonMain/kotlin/com/jillesvangurp/geo/GeoGeometry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -87,6 +88,8 @@ fun Geometry.area() = when(this) {
else -> 0.0
}

val Geometry.asFeatureCollection get() = FeatureCollection(listOf(this.asFeature()))

fun <T: Geometry> T.scaleX(percent: Double): T {
@Suppress("UNCHECKED_CAST") // it's fine, generics confusing things
return when(this) {
Expand Down Expand Up @@ -185,44 +188,52 @@ fun Array<Array<Array<Array<PointCoordinates>>>>.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: Geometry> T.rotate(degrees: Double): T {
fun <T: Geometry> 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<PointCoordinates>.rotate(degrees: Double): Array<PointCoordinates> {
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<PointCoordinates>.rotate(degrees: Double, around: PointCoordinates?=null): Array<PointCoordinates> {
val centroid = around?:centroid()
return map { p ->
GeoGeometry.rotateAround(centroid, p, degrees)
p.rotate(degrees, centroid)
}.toTypedArray()
}

fun Array<Array<PointCoordinates>>.rotate(degrees: Double): Array<Array<PointCoordinates>> {
fun Array<Array<PointCoordinates>>.rotate(degrees: Double, around: PointCoordinates?=null): Array<Array<PointCoordinates>> {
return map { ps ->
ps.rotate(degrees)
ps.rotate(degrees,around)
}.toTypedArray()
}

fun Array<Array<Array<PointCoordinates>>>.rotate(degrees: Double): Array<Array<Array<PointCoordinates>>> {
fun Array<Array<Array<PointCoordinates>>>.rotate(degrees: Double, around: PointCoordinates?=null): Array<Array<Array<PointCoordinates>>> {
return map { ps ->
ps.rotate(degrees)
}.toTypedArray()
}

fun Array<Array<Array<Array<PointCoordinates>>>>.rotate(degrees: Double): Array<Array<Array<Array<PointCoordinates>>>> {
fun Array<Array<Array<Array<PointCoordinates>>>>.rotate(degrees: Double, around: PointCoordinates?=null): Array<Array<Array<Array<PointCoordinates>>>> {
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) })
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) })
80 changes: 72 additions & 8 deletions src/commonMain/kotlin/com/jillesvangurp/geojson/geojson.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<PointCoordinates>
typealias MultiPointCoordinates = Array<PointCoordinates>
typealias LineStringCoordinates = Array<PointCoordinates>
typealias LinearRingCoordinates = Array<PointCoordinates>
typealias MultiLineStringCoordinates = Array<LineStringCoordinates> // Outer polygon + holes
typealias PolygonCoordinates = Array<LinearRingCoordinates> // Outer polygon + holes
typealias MultiPolygonCoordinates = Array<PolygonCoordinates>

/**
* Lowest axes followed by highest axes
* BoundingBox = [westLongitude,southLatitude,eastLongitude,westLatitude]
Expand All @@ -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())
Expand All @@ -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') }

Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -179,6 +236,7 @@ sealed class Geometry {
is GeometryCollection -> GeometryType.GeometryCollection
}
}

@Serializable
@SerialName("Point")
data class Point(
Expand Down Expand Up @@ -392,6 +450,12 @@ data class Feature(
override fun toString(): String = Json.encodeToString(serializer(), this)
}

fun Collection<PointCoordinates>.toFeatureCollection(properties: JsonObject? = null) =
FeatureCollection(map { it.geometry().asFeature(properties) })

fun Collection<Geometry>.asFeatureCollection(properties: JsonObject? = null) =
FeatureCollection(map { it.asFeature(properties) })

@Serializable
data class FeatureCollection(
val features: List<Feature>,
Expand Down
59 changes: 59 additions & 0 deletions src/commonMain/kotlin/com/jillesvangurp/geojson/json-helpers.kt
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())))
)
}

Expand Down
Loading

0 comments on commit 51465b0

Please sign in to comment.