Skip to content

Comments

Convert library to KotlinMultiplatform#40

Open
Fabi755 wants to merge 68 commits intomaplibre:mainfrom
Fabi755:feature/kotlin-kmp
Open

Convert library to KotlinMultiplatform#40
Fabi755 wants to merge 68 commits intomaplibre:mainfrom
Fabi755:feature/kotlin-kmp

Conversation

@Fabi755
Copy link
Collaborator

@Fabi755 Fabi755 commented Jan 7, 2025

This PR is converting the whole code to Kotlin and also use converts to use Multiplatform Framework.

The goal is to use this library in more parts than the Android one. As we also converting the maplibre-navigation-android one to Kotlin Multiplatform and @sargunv is working on a Multiplatform Compose variant of MapLibre.

Converting the Java stuff to Kotlin, and removing the Android and JVM specific stuff was straight forward work. More complicated was to avoid a breaking change release, and keep the support with MapLibre Native for Android.

To use the new models, we need to convert it back to JVM with toJvm() extension. But this should only a temporary solution, as I would create a PR on MapLibre Native to use the newer variant of this library.

The idea is that I create also an PR for including this library to the iOS platform code.


We have starting using this PR changes already in our PR that convert the navigation core. There is also a small example usage of navigation with this GeoJSON lib.

@sotomski is also implemented this early state of this repository with navigation one in a private repository for Kurviger.


Changes

As mentioned, the project is now a KMP one. So we having a new code folder structure:

  • commonMain (generic Kotlin native)
  • commonJvm (JVM target code. Here located the old method schemes)
  • commonTest (tests for generic Kotlin native)
  • jvmTest (tests for JVM target. Only used for test utils setup yet)
  • appleTest (tests for Apple target. Only used for test utils setup yet)

To avoid a breaking change release, I added all old function patterns to the JVM module. These are marked as deprecated and are working as proxy. The logic is removed and all calls are forwareded to the generic functions in Multiplatform commonMain package.


Follow up

Finally we need to decide how we handle the naming of this repository & project. The current maplibre-java is not matching anymore. I suggest something like maplibre-geojson.

@Fabi755 Fabi755 changed the title Convert library to KotlinMultiplatform DRAFT: Convert library to KotlinMultiplatform Jan 8, 2025
@Fabi755 Fabi755 changed the title DRAFT: Convert library to KotlinMultiplatform Convert library to KotlinMultiplatform Jan 17, 2025
@Fabi755
Copy link
Collaborator Author

Fabi755 commented Jan 17, 2025

Ready for review and discuss now @louwers and any other who is working on this project!

@louwers louwers requested a review from artakka January 17, 2025 14:35
Copy link
Member

@louwers louwers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a very welcomed contribution!

I could use some help from maybe @sargunv and @westnordost to review this.

Did you already test this with MapLibre Android? I was thinking, maybe we make a pre-release of both this library and MapLibre Android, so we can test it?

Are there any breaking APIs needed in MapLibre Android you are aware of?

@frankkienl
Copy link

( commenting here, to keep in the loop )

@Fabi755
Copy link
Collaborator Author

Fabi755 commented Aug 13, 2025

From my side, this is ready to merge now (except the naming).

I improved the models already based on the reviews. And the Turf functions are now extension functions on the models.

The turf module was only used in the navigation project and the native Android test app. The native project itself is using only the models.
I removed all turf functions that were not used there. We can think about to also drop the Point.circle(...) functions, that is only used in the test app of the native project.

And we need to discuss about the naming. Should we keep the turf? Or do a util will be better here, because we don't want be a turf.js copy.

@Fabi755 Fabi755 marked this pull request as ready for review August 13, 2025 12:27
@Fabi755 Fabi755 changed the title WIP: Convert library to KotlinMultiplatform Convert library to KotlinMultiplatform Aug 13, 2025
@westnordost
Copy link
Collaborator

I put the review of this on my todo, since it is a lot, and I will review on the side bit by bit, it will take some time.
Especially regarding the multiplatform+gradle+iOS things, I am not so proficient.

@louwers
Copy link
Member

louwers commented Aug 14, 2025

Since these are dependencies of MapLibre Android, I would like to have a migration plan and probably a draft PR with the needed changes.

Copy link
Collaborator

@westnordost westnordost left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've read through the data classes and started reading the serializers. Love how clean it is, I found mostly typos.

For the latter, I noticed that this is a lot of boilerplate code as pretty much every single data class has a custom serializer, because of the coordinates field.
I recognize that the coordinates field is a different data structure for the different geometry types - a double array for the Point, an array of double arrays for LineString and MultiPoint, an array of arrays of double arrays for Polygon, MultiLineString and and array of arrays of arrays of double arrays of MultiPolygon so I guess maybe it can't be helped?

Anyway, two ideas to reduce copypasta / boilerplate:

  1. abstract superclass or delegate class for the shared code (optional bbox, boilerplate serializer code)
  2. serializers for linestring + multipoint as well as polygon+multilinestring should be the same, no?

Comment on lines +15 to +21
def xcf = new XCFrameworkConfig(project)
[iosX64(), iosArm64(), iosSimulatorArm64()].forEach { target ->
target.binaries.framework { framework ->
baseName = "maplibre-geojson"
xcf.add(framework)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this do? In other multiplatform libraries, I just see somethign like

Suggested change
def xcf = new XCFrameworkConfig(project)
[iosX64(), iosArm64(), iosSimulatorArm64()].forEach { target ->
target.binaries.framework { framework ->
baseName = "maplibre-geojson"
xcf.add(framework)
}
}
iosX64()
iosArm64()
iosSimulatorArm64()

Also, what about

linuxX64()
linuxArm64()
macosX64()
macosArm64()

?

Comment on lines +29 to +34
//TODO(fabi755): can we use here something generic (not from kotlinx) instead?
// any idea?
/**
* Additional custom properties for this feature.
*/
val properties: JsonObject? = null,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Map<String, Any?>?

I think JsonObject? is the best possible fit, because it can not have Any? values, just what is allowed in JSON, i.e. certain primitives, JsonArrays and JsonObjects.

Copy link
Collaborator Author

@Fabi755 Fabi755 Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes correct, but this is the only part where we force the developer using the serialization library (kotlinx.serialization). And exposing this library. The other parts are all internal and not relevant to the public API. But for now I also think, it's the best solution

Comment on lines +40 to +43
/**
* The [BoundingBox] of this point.
*/
override val boundingBox: BoundingBox? = null,
Copy link
Collaborator

@westnordost westnordost Aug 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I guess this is where the GeoJson spec doesn't make much sense. (A bounding box for a point? Lol)

However, the GeoJson spec distinguishes between Positions and Points while this PR does not.

A Point is a Geometry (hence having a bbox is allowed) while positions (or coordinates) are not.

It's not a big issue, but since positions/coordinates are used all over (in Polygons, LineStrings, ...), that means that the (in memory) data carries a lot of superfluous null bboxes. Anyway, does anything speak against keeping closer to the GeoJson spec? (distinguish between Position and Point)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a big issue, but since positions/coordinates are used all over (in Polygons, LineStrings, ...), that means that the (in memory) data carries a lot of superfluous null bboxes.

Very good hint! I don't see this part. Make sense to add also a Position model like in the spec 👍
Thanks!

Comment on lines +84 to +91
private fun LineString.requireLinearRing() {
require(points.size >= 4) {
"LinearString for Polygon rings need to be made up of 4 or more Points."
}

require(points.first() == points.last()) {
"LinearString for Polygon rings require first and last Point to be identical."
}
Copy link
Collaborator

@westnordost westnordost Aug 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For more informative error message (i.e. which ring exactly is wrong):

Suggested change
private fun LineString.requireLinearRing() {
require(points.size >= 4) {
"LinearString for Polygon rings need to be made up of 4 or more Points."
}
require(points.first() == points.last()) {
"LinearString for Polygon rings require first and last Point to be identical."
}
private fun LineString.requireLinearRing(name: String, index: Int?) {
require(points.size >= 4) {
"${getRingName}: LineString for Polygon rings need to be made up of 4 or more Points."
}
require(points.first() == points.last()) {
"${getRingName}: LineString for Polygon rings require first and last Point to be identical."
}
private fun getRingName(name: String, index: Int?): String {
val indexString = index?.let { " at index $it" } ?: ""
return "$name$indexString"
}

(also, typo: "LinearString" -> "LineString")

Comment on lines +27 to +35
/**
* The outer [LineString] ring of this Polygon.
*/
val outerLineStringRing: LineString,

/**
* The inner [LineString] rings of this Polygon. Or empty list if no inner holes exists.
*/
val holeLineStringRings: List<LineString> = emptyList(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* The outer [LineString] ring of this Polygon.
*/
val outerLineStringRing: LineString,
/**
* The inner [LineString] rings of this Polygon. Or empty list if no inner holes exists.
*/
val holeLineStringRings: List<LineString> = emptyList(),
/**
* The outer [LineString] ring of this Polygon.
*/
val outer: LineString,
/**
* The inner [LineString] rings of this Polygon. Or empty list if no inner holes exists.
*/
val inner: List<LineString> = emptyList(),

I think this would be more consistent.

classDiscriminatorMode = ClassDiscriminatorMode.ALL_JSON_OBJECTS

// Encode
encodeDefaults = true
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't that encode e.g. null for every Point (and other Geometry) for bbox?

Fabi755 and others added 7 commits August 14, 2025 23:30
Co-authored-by: Tobias Zwick <newton@westnordost.de>
Co-authored-by: Tobias Zwick <newton@westnordost.de>
Co-authored-by: Tobias Zwick <newton@westnordost.de>
Co-authored-by: Tobias Zwick <newton@westnordost.de>
Co-authored-by: Tobias Zwick <newton@westnordost.de>
Co-authored-by: Tobias Zwick <newton@westnordost.de>
Co-authored-by: Tobias Zwick <newton@westnordost.de>
Copy link
Collaborator

@westnordost westnordost left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't look at the tests because I think you just converted them to Kotlin and made them work after any interface changes you did, right?

Regarding the geodesy, it looks like the antimeridian is not taken into account at all. I expect this could lead to all kind of problems, but anyway, if it should not be implemented, then it should at least be very clearly documented at all affected places.

I also noticed that quite a bit of functionality that was available earlier seems to be missing now, such as

  • GeometryCollection.combine
  • GeometryCollection.explode
  • MultiPolygon.toMultiLineString
  • Polygon.toLineString
  • Polygon.contains
  • MultiPolygon.contains
  • Geometry.bbox
  • GeometryCollection.bbox
  • BoundingBox.toPolygon
  • Polygon.area
  • MultiPolygon.area
  • Geometry.center
  • GeometryCollection.center

I don't know if all of these are really necessary, but some look pretty necessary (point in polygon for example).
Edit: I see you stated earlier that you removed unused ones, ok.

Comment on lines +63 to +69
return drop(1)
.mapIndexed { index, point ->
// Using unmodified index for previous point is working,
// because we drop the first point
get(index).distanceTo(point, unit)
}
.sum()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever. But also, a bit difficult to understand.

How about

/** Return a sequence that iterates through the given list of points in pairs */
internal fun Iterable<Point>.asLineSequence(): Sequence<Pair<Point, Point>> = sequence {
    val it = iterator()
    if (!it.hasNext()) return@sequence
    var p1 = it.next()
    while (it.hasNext()) {
        val p2 = it.next()
        yield(p1 to p2)
        p1 = p2
    }
}

and then

Suggested change
return drop(1)
.mapIndexed { index, point ->
// Using unmodified index for previous point is working,
// because we drop the first point
get(index).distanceTo(point, unit)
}
.sum()
return asLineSequence().sumOf { (p1, p2) -> p1.distanceTo(p2, unit) }

?

Actually, this convenience function would also make implementation of List<Point>.along slightly more straightforward.

Comment on lines +78 to +82
fun Point.findMidpoint(to: Point): Point {
val dist = this.distanceTo(to, MeasureUnit.MILES)
val heading = this.bearingTo(to)
return this.destination(dist / 2, heading, MeasureUnit.MILES)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, other functions are not named something with find... (findLength, findBearingTo, ...), but of course, poin1.midpoint(point2) reads odd, true. I think a non-extension-function would be more expressive: midpoint(point1, point2)

Suggested change
fun Point.findMidpoint(to: Point): Point {
val dist = this.distanceTo(to, MeasureUnit.MILES)
val heading = this.bearingTo(to)
return this.destination(dist / 2, heading, MeasureUnit.MILES)
}
fun midpoint(p1: Point, p2: Point): Point {
val dist = p1.distanceTo(p2, MeasureUnit.RADIANS)
val heading = p1.bearingTo(p2)
return p1.destination(dist / 2, heading, MeasureUnit.RADIANS)
}

Comment on lines +3 to +9
/**
* Enum class representing various units of measurement and their corresponding factors.
* These units are used for geographical and mathematical calculations, particularly
* in the context of distance and angular measurements.
*/
@Suppress("unused")
enum class MeasureUnit(val factor: Double) {
Copy link
Collaborator

@westnordost westnordost Aug 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this from Turf, or from the old Java API?

I find a bit strange:

  • CENTIMETERS and also CENTIMETRES, METERS and METRES, KILOMETRES and KILOMETERS, why??

  • why even have anything else than METERS in SI measurement system? Users will I think be able to divide by 1000 for kilometers etc. themselves

  • An API like that requires the use of the MeasureUnit in every single function that deals with distances. why not always return in some standard unit, e.g. meters or probably rather degrees (because not dependent on hardcoded earth circumference) and either let the user do whatever conversion he needs after or provide convenience functions for that? MeasureUnit hardcodes the values for the circumference of Earth (except degrees, radians)

KILOMETRES(factor = 6373.0);

companion object {
val DEFAULT = KILOMETERS
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this for? This makes it opaque what DEFAULT actually is wherever this is set as a default parameter.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from before. But true, using this directly make it clear 👍

Comment on lines +205 to +214
val difLat = degreesToRadians((point.latitude - this.latitude))
val difLon = degreesToRadians((point.longitude - this.longitude))
val lat1 = degreesToRadians(this.latitude)
val lat2 = degreesToRadians(point.latitude)

val value = sin(difLat / 2).pow(2.0) + sin(difLon / 2).pow(2.0) * cos(lat1) * cos(lat2)

return radiansToLength(
2 * atan2(sqrt(value), sqrt(1 - value)), unit
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will not yield correct results if the line between point A and point B crosses the antimeridian.

* @param unit The unit of measurement.
* @return A [Feature] containing the closest point and properties for index and distance.
*/
fun LineString.closestPoint(point: Point, unit: MeasureUnit = MeasureUnit.DEFAULT): Feature {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not return a DistancePoint here? Returning some Feature with properties with hardcoded keys seems unnecessary (and requires additional documentation to make sense of for lib users)

* @param points The list of points.
* @return The nearest point in the list.
*/
fun Point.closestPoint(points: List<Point>): Point {
Copy link
Collaborator

@westnordost westnordost Aug 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That point1.closestPoint(emptyList()) returns point1 sounds odd. Not sure if this function is necessary at all, after all,

points.minBy { it.distanceTo(point) }

is hardly longer than

point.closestPoint(points)

but if it is necessary, I'd find it perfectly fine if it threw NoSuchElementException when points is empty.

Comment on lines +352 to +369
val longitude1 = degreesToRadians(longitude)
val latitude1 = degreesToRadians(latitude)
val bearingRad = degreesToRadians(bearing)

val radians = lengthToRadians(distance, unit)

val latitude2 = asin(
sin(latitude1) * cos(radians) + cos(latitude1) * sin(radians) * cos(bearingRad)
)
val longitude2 = longitude1 + atan2(
sin(bearingRad) * sin(radians) * cos(latitude1),
cos(radians) - sin(latitude1) * sin(latitude2)
)

return Point(
radiansToDegrees(longitude2),
radiansToDegrees(latitude2)
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the resulting point crosses the antimeridian, the resulting point will have a longitude outside of the expected bounds of -180 and +180. Not sure if valid lat+lon are required/enforced? But it could very well be that software that uses the Point data structure assumes that the points are valid lat lon coordinates.

* @property onLine1 True if the intersection is on the first line segment.
* @property onLine2 True if the intersection is on the second line segment.
*/
data class LineIntersects(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
data class LineIntersects(
private data class LineIntersects(

doesn't need to be exposed as long as it is only used internally

* @return A [Polygon] representing the circle.
* @throws IllegalArgumentException if steps is less than 1.
*/
fun Point.circle(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or circle(point: Point, radius: Double, ...)? Or Point.createCircle?

@westnordost
Copy link
Collaborator

And we need to discuss about the naming. Should we keep the turf? Or do a util will be better here, because we don't want be a turf.js copy.

or geodesy? I agree with not mentioning turf, it could be misleading.

@jgillich
Copy link

Have we considered forking spatialk under the maplibre org? It's already used by maplibre-compose and it's probably(?) more idiomatic. I'd be happy to help maintain a fork since I also have several open PRs that have gone unanswered

@sargunv
Copy link
Collaborator

sargunv commented Sep 16, 2025

I don't have any strong opinion on whether to use this or fork Spatial-K, but will note that:

  • I find spatial-k's API is quite nice to use, especially the DSL for constructing geometry
  • I haven't tried this one yet

MapLibre Compose uses Spatial-K right now. Long term, I'll most likely use whichever is maintained under the MapLibre org, whether that's a fork of Spatial-K or this KMP port.

@louwers
Copy link
Member

louwers commented Sep 16, 2025

@jgillich Here is the process for onboarding a new repo: https://github.com/maplibre/maplibre/issues/new?template=new-repo-checklist.md

@louwers
Copy link
Member

louwers commented Sep 16, 2025

@Fabi755 Do you want to try updating MapLibre Android in the maplibre-native repo with one of the pre-releases?

@Fabi755
Copy link
Collaborator Author

Fabi755 commented Sep 16, 2025

Yes I can do this as next step. I'm back from my holiday now, and will try this out next 👍

@jgillich
Copy link

I have submitted a proposal for forking spatialk ^, feel free to share your thoughts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants