Closed louwers closed 1 month ago
Ah, sure! I didn't expect anyone to find this so quick lol.
My original plan was to add a license when I split off my MapLibre KMM wrapper here into a full blown library
I've added the Mozilla Public License for this repo, intended for the app. I intend to spin off any libraries coming out of this project under the BSD license, so if that would be useful to you now I'm happy to prioritize that
Awesome! Quite a lot of interested people actually. I see you found the thread. ๐
By the way if you are interested in exchanging opinions and experiences on working on a MapLibre Compose wrapper, the other people interested in this currently linger on OSM Slack US channel #maplibre-swiftui-compose-playground (because maplibre-compose-playground is so far the only currently actively developed FOSS MapLibre Compose wrapper).
I also recently thought a bit about how a good layers API could look like. Maybe you would like to have a look at it, cause I am a bit clueless how a convenient type-safe Kotlin-idiomatic API could be achieved for layer styling taking into account that most styling properties could be either a fixed value or an expression. Maybe the Java builder approach is the best, after all?: https://github.com/westnordost/maplibre-compose-layers-api
Thanks for the links! Will def poke my head into the Slack channel.
Looks like maplibre-compose-playground uses a custom Composition to manage map annotations; def something I wanted to play around with as well.
For the lack of union types, an idea I had was to create a Kotlin builder DSL with overloaded property setters for constants, but then learned that Kotlin doesn't allow setter overloading (though one could do it with regular functions, like a Java builder). I might look to kotlinx.html or something like that for inspiration. Currently thinking either:
width = const(5)
(imo the most straightforward)width = 5.px
(I've seen this in compose ui)width of 5
(I've seen this with a Kotlin JSON dsl)width(5)
(I believe the Gradle Kotlin dsl does this)Kotlin type safe builders
Additionally to that it doesn't work, I'd argue that this is not the right use case for this pattern. Builders are ideal in situations where 1. order matters and 2. elements can have multiple children of the same type. E.g. works fine for document builders like e.g.
div {
button { text("yes") }
button { text("no") }
}
but for defining styles, neither does it matter whether the width of a line is defined before or after the color, nor can the color be defined twice.
So, like it is also done in Jetpack Compose, defining properties simply as parameters (to a function, or in a constructor) seems to be the better fit. (For the Modifier
, order matters, that's why it is a builder.)
So, the idea exhibited in my example repo is to have factory functions that have as optional parameters both e.g. color
and colorExpr
but I kind of agree that width = const(5)
is more... honest, even if it comes down to being somewhat verbose. Either that, or stick with Java-like builders. Theoretically, one can have both. Bare Kotlin would be width = const(5)
and the Java-like builder could be added on top (for covenience)
Regarding width = 5.px
, to be somewhat consistent to the rest of Compose API, wherever the width is specified, I think it should be done in dp
anyway.
On a slightly different topic, since you mention that slack channel, the authors of maplibre-compose-playground (recently) commented that getting the map camera handling right is quite complex/difficult. Maybe because the (Android) API does not support animating several properties in parallel (e.g. camera follows GPS position in continuous animation and then parallel to that the user taps the zoom in button to zoom in)?
(I haven't looked at this yet, but I would have thought that it would be most straightforward to let Compose handle any animations.)
Builders are ideal in situations where 1. order matters and 2. elements can have multiple children of the same type.
Agreed. I could see it potentially being used for map styling (a style can have source/layer children, and layer order matters iirc).
Also, any solution to the constant problem involving overloading the property setters would involve a lot of extra declarations; gonna try to avoid that.
I've pushed a first pass at expressions, usable like:
val layer = Layer(
id = "amtrak-route-lines-casing",
source = "amtrak-geojson",
below = "boundary_3",
type = useExpressions {
Layer.Type.Line(
color = color(0xFF888888u),
width =
interpolate(
exponential(const(2f)),
zoom(),
0 to const(1f),
10 to const(2f),
),
cap = const("round"),
join = const("miter"),
)
}
)
The useExpressions
call brings all the expression functions into scope. You can wrap anything with it; not necessarily just around the Layer.Type.
The expression object keeps track of its value
which is just any JSON-like elements (primitives, arrays, maps, etc). The iOS adapter just calls NSExpression.expressionWithMLNJSONObject(expr.value)
while the Android adapter transforms it to a Gson JsonElement
and then calls Expression.Converter.convert(jsonElement)
to pass into properties.
Constant Color values have some expect/actual special handling as they need to be UIColor on iOS and String on Android.
So far I've written the expression functions I needed for interpolate
plus about half of the ones listed in the docs.
Agreed. I could see it potentially being used for map styling (a style can have source/layer children, and layer order matters iirc).
Yeah, layer order very much matters. It's the drawing order.
useExpressions
Interesting solution! So whenever one wants to use expressions in a layer, one needs to wrap any property literal that can be an expression in literal
but if no expressions at all are used, they can be written plainly? (For style I am using, I use interpolate expressions for the width, too, although, for almost all layers) ... or... what happens if one doesn't write useExpressions { }
?
By the way, in Compose a lot of things that look like (data) classes are actually functions or value classes, like e.g. Color(0xff0000)
and the reason for that is to avoid allocations cause during recomposition, this stuff may be called many times over. I.e. to avoid garbage collector runs. ... Not sure if something similar can be done with Expression
s, though. Mmm... on the other hand, the API to the C++ native interface is just (JSON) strings, if I remember correctly. And if that is the case, Expressions, even Layers are just intermediate data structures that directly translate to Strings? (Don't have the source in front of me right now)
colors
Wouldn't it make sense to use Compose Colors (androidx.compose.ui.graphics.Color
) on the interface? (which is ultimately a value class of type ULong).
Edit: Also would save you from reimplementing functionality already present in Compose, such as color constants and (factory) functions for Color
The width is actually the width in pixels, or in device independent pixels? I think it is the latter. Even though it ultimately doesn't make a difference, as MapLibre handles device display density internally as far as I know, it just may make the interface more consistent with Compose API.
So whenever one wants to use expressions in a layer, one needs to wrap any property literal that can be an expression in literal but if no expressions at all are used, they can be written plainly?
I haven't gotten quite that far yet but yeah I'm hoping I can shadow the expression-accepting constructors with functions in the useExpressions
scope. And then have primary internal constructors for the layer types with Expression params, and public constructors with const params. It would double up all the layer type constructors (+ a shadow, so triple) which I'm not super happy with, but probably not too bad.
Wouldn't it make sense to use Compose Colors (androidx.compose.ui.graphics.Color) on the interface?
For whatever DSL function instantiates an Expression<TColor>
, definitely (though I haven't written that one yet). For the actual internal value
in the expression, I kept it to a mostly json-like value accepted by the underlying MapLibre SDK's expression api (so lists, maps, numbers, bools, nulls, strings, and on iOS, UIColor). I could use a compose Color directly, and do the conversion to String/UIColor within the platform sourcesets (similar to how I convert to gson JsonElements on the Android side) but didn't yet as I wasn't sure how that conversion would interact with a value class Color
of a Long
in a when(value) { is Color -> ...; is Number -> ... }
case (maybe it just works, idk).
The width is actually the width in pixels, or in device independent pixels? I think it is the latter. Even though it ultimately doesn't make a difference, as MapLibre handles device display density internally as far as I know, it just may make the interface more consistent with Compose API
Just a regular number at the moment. Passing Dp
felt weird as not all the Number
inputs to expressions actually represent pixels. I might experiment with that later. Maybe just a helperfun const(dp: Dp): Expression<Number> = const(dp.value)
usable anywhere a Number expression is accepted (whether it makes sense or not). And then the non-expression public constructors of layer types accept Dp wherever the property represents a size in "pixels").
Btw in case it is helpful to you, I hereby release anything seen in the https://github.com/westnordost/maplibre-compose-layers-api repo as public domain. All those data classes were quite complete already, IIRC
Thanks, that's very helpful!
Re: const expressions, do you have thoughts on .invoke()
operator functions to reduce the verbosity? Here's examples with both (also available in the repo):
with foo.invoke()
:
Layer(
id = "amtrak-route-lines-inner",
source = "amtrak-geojson",
above = "amtrak-route-lines-casing",
type =
Layer.Type.Line(
color = Color.Cyan(),
width = interpolate(exponential(2f()), zoom(), 0 to 1f(), 10 to 2f()),
cap = "round"(),
join = "miter"(),
),
)
with const(foo)
:
Layer(
id = "amtrak-route-lines-casing",
source = "amtrak-geojson",
below = "boundary_3",
type =
Layer.Type.Line(
color = const(Color.White),
width =
interpolate(
exponential(const(2f)),
zoom(),
0 to const(2f),
10 to const(4f),
),
cap = const("round"),
join = const("miter"),
),
)
It's a bit weird to me to use ()
in this way, but https://github.com/Kotlin/kotlinx.html already has precedent for using operator overload not for their intended purpose (+"some string"
to add text into html). And it's only available within a useExpressions
block, so won't get in the way when using these types elsewhere. And
const(foo)
looks much better, IMO.
Too much magic, and it just gets confusing. I always considered kotlinx.html as a sort of demo project the likes of "look, what (magic) we can do!"
By the way, you use below = "boundary_3",
instead of defining the layers in a list? Interesting approach, this makes it easier to "inject" layers defined in code into a preexisting style defined in json. (Currently, I have to do this programmatically) On the other hand, it adds one line of code per layer and adds potential for mistakes for when id
and below
do not match up due to one having been renamed or a typo or whatever.
Edit: Also... hmm... what if there are several layers that define the same below
? Is the order of these layers then undefined amongst each other? Seems to me a good reason for a list to define the layer order being better after all?
Too much magic, and it just gets confusing.
Totally fair, and agreed. There's one more alternative here I'm considering:
Layer(
id = "amtrak-route-lines-casing",
source = "amtrak-geojson",
below = "boundary_3",
type =
Layer.Type.Line(
color = Color.White.const,
width =
interpolate(
exponential(2f.const),
zoom(),
0 to 2f.const,
10 to 4f.const,
),
cap = "round".const,
join = "miter".const,
),
)
Still feel like const(foo)
looks better, but also I like that the actual value comes first. I might provide both.
I also tested the shadowing approach, and it works, but I expect it's pretty common to pass at least one property as an expression, so a constants-only constructor probably isn't worth all those extra definitions (at least without codegen, which might be worth tackling later).
Seems to me a good reason for a list to define the layer order being better after all?
The layers are defined in a list, and added to the underlying map SDK in the order of the list, but each entry supports an optional param for below
, above
, or index
, which, if provided, is passed to the underlying SDK. I use this because I'm rendering some lines below the labels on the base map style, so need to inject in the middle.
adds potential for mistakes for when id and below do not match up due to one having been renamed or a typo or whatever
Yeah, I considered passing the reference to the Layer itself (the iOS SDK works like that anyway) but it was awkward to use for the end user. So I think I'll leave it up to the end user to define their IDs as const val
if they're worried about typos.
what if there are several layers that define the same below? Is the order of these layers then undefined amongst each other?
It'll do whatever the underlying SDK does when you style.addLayerBelow()
twice. I expect the one added later (and so at a higher index in the list) is the one closer to the target id.
Still feel like const(foo) looks better
Agree. something.x
looks natural if x
is a unit, like dp
, sp
or m
(meters), etc., but const
is not a unit.
For the layer insertion, I've thought about having it be defined outside the layer constructor itself. Currently it's just a list like:
MaplibreMap(
styleUrl = "https://whatever",
sources = listOf(Source(...)),
layers = listOf(Layer(...), Layer(below = "id", ...)),
)
But not entirely happy with how layers inserted normally at the end can be intermingled with layers injected into the middle of the style. An alternative I'm considering is a more DSL approach:
MaplibreMap(styleUrl = "https://whatever") {
add(Source(...))
add(Layer(...)) // default behavior is to add to the end of the list (top of the map)
insertBelow("id") {
// all layers within this block will be inserted below layer "id", in order
add(Layer(...))
}
insertAbove("id") { ... }
insertAt(5) { ... }
}
With the list-only approach, if we want to insert many layers in the middle of the style (common for me at least, inserting below the labels) we have to repeat below = "foo"
many times and manage all the ids ourselves. With this DSL approach, we can pull that location info out and insert multiple layers under a single below
declaration.
Referencing layers directly rather than by id is also more ergonomic this way, to avoid the typo problem, though I'm unsure how useful this is as we'll only really have references to layers we defined, and we control the order of those:
MaplibreMap(styleUrl = "https://example.com/style.json") {
add(Source(..))
val l = Layer(..).let(::add)
insertBelow(l.id) { // IRL we'd just move this up
add(Layer(..))
}
insertAbove("id") { .. }
insertAt(5) { .. }
}
And then long term, if we really want to add sugar, we can codegen some builders with const and expression property scopes onto this pattern
MaplibreMap(styleUrl = "https://example.com/style.json") {
addGeoJsonSource(..)
insertBelow("some-label-layer") {
addLineLayer {
lineWidth = interpolate(..)
literal {
color = Color.White
}
}
addLineLayer { .. }
}
}
Hmm, you know, the creation of base style.json
s has always been notoriously inconvenient, for layers need to be basically duplicated many times to have a good-looking and well-working style: To render correctly in the right order, e.g. all roads that are bridges need to be rendered (in otherwise the same style) on top of the other roads. The same for tunnels but the other way round. Then, everything times two for the road casing.
So, few people actually write such style.jsons raw, they rather write some script that generates it. That's what I did for the StreetComplete style, too (in Kotlin :-) ).
What I am getting to is that with a good API, it becomes more convenient to write the base style in Kotlin, because you can just duplicate and modify layers with the .copy(...)
method.
Then, of course, the situation is that all current default styles are jsons and this will not change anytime soon. So, there should be some straightforward easy way to cover this use case.
If the DSL you propose would be the only way to define layers, then the convenience gained by being able to construct layers like data class instances is lost.
How about pulling the contents of the JSON into code by separating the parsing of the styling step and the initialization of the MapLibreMap?
val style: MapLibreStyle = parseMapLibreStyle("https://example.com/style.json")
// MapLibreStyle is just a (mutable?) data class, i.e. can be copied, modified, ...
val modifiedStyle = style.copy(layers = layers.apply { ... })
MaplibreMap(modifiedStyle )
If the DSL you propose would be the only way to define layers, then the convenience gained by being able to construct layers like data class instances is lost.
Oh I'd definitely keep the data classes around under the DSL, if such a DSL is built.
So, few people actually write such style.jsons raw, they rather write some script that generates it.
This is interesting. It actually makes me more confident removing these extra below
, above
, etc fields from the Layer data class is the right move. If the data classes align more directly with the actual style.json spec, they might be useful for generating or parsing JSON styles. So positioning fields like below and above should actually be pulled out into something else.
Thinking about some use cases to support:
I think it makes sense to have the map accept:
Maybe, separately codegen some DSL to work with those data classes, and eventually, perhaps split the pure kotlin style/layer/expression code into its own module from the compose map code, so the style data classes can be used in other tools (like style generators or whatever)
If possible I think the best way would be to allow layers to accept json, that way people don't have to learn new styling languages constantly and can reuse the same styling on web as well as the native side. IMHO the closer it is to the json you can also use in the style.json the better.
I've pushed tweaks to the layer api based on the above; now it's usable like:
MaplibreMapOptions.StyleOptions(Res.getUri("files/maplibre/style/positron.json")) {
val amtrakRoutes =
Source.GeoJson(
id = "amtrak-geojson",
url = Res.getUri("files/geojson/amtrak/routes.geojson"),
tolerance = 0.001f,
)
below("boundary_3") {
addAll(
Layer(
id = "amtrak-route-lines-casing",
source = amtrakRoutes,
type =
Layer.Type.Line(
color = const(Color.White),
width =
interpolate(
exponential(const(2f)),
zoom(),
0 to const(2f),
10 to const(4f),
),
cap = const("round"),
join = const("miter"),
),
),
Layer(
id = "amtrak-route-lines-inner",
source = amtrakRoutes,
type =
Layer.Type.Line(
color = const(Color.Cyan),
width =
interpolate(
exponential(const(2f)),
zoom(),
0 to const(1f),
10 to const(2f),
),
cap = const("round"),
join = const("miter"),
),
),
)
}
}
If possible I think the best way would be to allow layers to accept json
that's reasonable, though as far as I can tell the native SDKs don't expose any way to construct layers / sources from JSON, so I'd have to implement JSON (de)serialization independent of the SDK's internal JSON handling.
IMO that looks very good! Although, isn't adding the source to the style missing somewhere? I would kind of expect extra sources having to be specified explicitly in the constructor or something when they are referenced by reference in the layers. Also, how would one refer to a source from the base style.json when it is referred to by reference and not id?
On Compose, am I right to assume that the code in the lambda is executed on every recomposition?
Also, by the way, as Amir Hammad now released his own (WIP?) MapLibre compose multiplatform wrapper sakirmaps under an open source license, it might be worth peeking into it to learn on which API he has settled, as inspiration or to compare. (Among other things. Of course the question arises whether either a lot of code could be copied from there, or the other way round, or if the efforts could be merged `ยฏ_(ใ)/ยฏ`)_
From a quick look, it looks like layers are added exclusively with MapStyle.addLayer
etc. (although I didn't find how to get a reference to a MapStyle
) and map style layers are constructed like this:
MapLineStyleLayer(identifier = "layer_id", source = reference_to_a_source) {
// this = this MapLineStyleLayer. properties are variable fields in a map style layer
lineColor = Ex.Literal.String("#00FFFF"),
lineWidth = Ex.interpolator(/*something something... interface doesn't seem to be finished yet*/),
lineCap = Ex.Literal.String("round"),
lineJoin = Ex.Literal.String("miter"),
}
In short, MapLineStyleLayer
is an interface with all properties defined as variables, e.g. var lineColor: Ex
.
On Compose, am I right to assume that the code in the lambda is executed on every recomposition?
Anyway, the awareness I want to rise is that maybe layer properties should be able to change when some data changes. For example, in my project I have a pulsing animation that animates the width and alpha of lines displayed in a layer. In the classic imperative approach, this looks basically like this:
mapLibreStyle.addLayer(pulsingLayer)
// ... then, much later, at a different place
val animation = TimeAnimator()
animation.setTimeListener { _, _, _ ->
val width = // some calculation...
val opacity = // another calculation...
mapLibreMap.style?.getLayerAs<LineLayer>("id_of_that_pulsing_layer")?.setProperties(
lineWidth(width),
lineOpacity(opacity),
)
}
But layer style properties could change for all kinds of reasons. In the classic imperative approach, one can manipulate the layer properties by accessing the layer later-on. In the declarative Compose approach, I think that would need to look different.
I think it would look like that this would be part of the initial style definition. I.e. when the data changes, it is recomposed, and that means that the style is also recomposed. E.g.
val width = // some calculation...
val opacity = // another calculation...
val pulsingLayer = Layer(
id = "id_of_that_pulsing_layer",
type = Layer.Type.Line(
opacity = const(opacity),
width = const(width),
// ...etc
)
}
)
But that part could turn out somewhat complicated, maybe? Because for performance reasons, of course the MapLibre view cannot be completely re-instatiated on every recomposition. Even re-setting all the layers on every recomposition may be prohibitive.
I am not experienced enough in all this to know what would be the solution to this. I just want to raise awareness of this because it might very well dictate how the API would need to look like. Would all this layer style definition have to be @Composable
functions or something for Compose to know what to evaluate and what to skip? Ehh... ๐คทโโ๏ธ
Although, isn't adding the source to the style missing somewhere?
Right now, it's automatic when you add a layer that references a new source you haven't added. But you're right, that doesn't solve for adding layers that reference from a source in the base style. Current intention is to have some sort of handle you can get from the StyleManager that references a style (part of the same sealed interface as concrete styles defined by the user in code). If that feels awkward, I'll revert to selecting a style by id: String
instead and registering it manually with the StyleManager (similar to the old approach).
On Compose, am I right to assume that the code in the lambda is executed on every recomposition?
I believe so with the current approach, yeah. It's on my to-do list to learn more about how recomposition works, and then to be as performant as is reasonable wrt adding, removing, or changing layers/styles. I haven't investigated deeply here, though the docs about stability in compose are probably a good starting point.
Some assumptions I'm making (that I'd love for y'all to validate):
I'd like to support all of that, but I'm willing to make some usability tradeoffs for advanced users if it improves things for most users. And ideally, the performance characteristics of this library should not be significantly worse than the underlying SDKs for any of these types of users. There's ofc some overhead in wrapping the API, but I'd like it to be unnoticeable for typical use cases.
As far as a performant implementation for recomposition, I haven't done much research yet. One idea is a custom Composition to manage annotations (and maybe even layers?). The Compose runtime is great at trees of nodes, and this data isn't much of a tree, but still the gap buffer under the hood could work well if I can cleanly make the a custom Applier to manipulate the native MapLibre style.
Compose's Canvas, with it's DrawScope, is another potential source of inspiration. That's more similar to the current approach.
Of course the question arises whether either a lot of code could be copied from there
It seems that repo is licensed MPL, while my intention is to make this library BSD when I spin it out of this repo. I haven't poked around much at how he's doing things yet, but could be something to learn from as I think he's been at it longer?
though the docs about stability in compose are probably a good starting point.
Basically, when deciding whether to call a @Composable
function again on a recomposition, which of course happens all the time, Compose checks whether any parameters to it have changed. If none changed, it is skipped.
So, for updates to parts of the style to work, all the layer style construction must happen in a @Composable () -> Unit
block... and defining layers must be composable functions, too. (Just as defining buttons, images etc. is composable functions.) These composables, I guess, when called, would need to add-or-replace the layer with the provided properties.
So... uh... I think it very much dictates how the API can look. Running this through my mind, I am somewhat unsure if we can have data classes for the layers at all, even though it would surely be handy for the reasons discussed above.
Edit: Although, I agree, that the more common use case is that data in the data source changes, rather than how that data is displayed, i.e. the style. So, maybe the given example is somewhat of a fringe usage and could just as well be realized with annotations.
My mental model of how all this works is very much based on the React world, so I might be way off, but in the React world they have two kinds of "composable" function: hooks and components. Components are like our Composable functions that emit UI elements to the view hierarchy, while hooks are like our Composable functions that calculate and return values without emitting to the view hierarchy. I guess Composables can do both, which React doesn't have.
Here we're writing an API to manage layers within a classic Android view object. That to me feels similar to composables that manage the UI view hierarchy, even if our "hierarchy" is pretty flat. I'm not sure how the emitted views are modeled, but I imagine there's some data classes representing the nodes somewhere under the hood, similar to React's virtual DOM. I know it's possible to use the compose runtime to manage other kinds of node trees than the UI hierarchy, so if we take the custom composition approach and my hunch about the internals is correct then we'd still need data classes like this, even if a compose end user doesn't interact with them directly.
Alternatively, we keep layer management within the main UI composition. In that world, I guess our composables are the kind that return values? And so, we probably need some data classes there to return. And I suspect the wrapper data class representing our layers would be constructed anew on a recomposition triggered by a change on one of the properties of a layer (but maybe sibling layer instances would be reused). Still unsure how these get applied to the underlying map efficiently without removing/re-adding the whole stack (or at least the whole stack above the base map).
Alternatively, our layer composables emit or return, instead of data classes, functions mutating the map somehow. For example, a Layer composable emits or returns a function calling style.addLayer. Probably no data classes here. No idea how these would then get applied to the map and recomposed; I'm having a hard time wrapping my head around this one.
I think I need to read up on both Compose UI and other Compose runtime consumers out there (maybe Redwood), and peek at how things under the surface are modeled. I find it unlikely we won't need data classes like this somewhere in the stack.
Thinking some more, Expressions would probably have to be Composables that return values, and so if we want the expression API to be usable outside of compose-colored code (like in a style generator) we'd need two versions of them all, one for Compose and one not.
Finally, alternatively, maybe style generation is entirely modeled in the non-compose world, and style modifications (adding layers) are mutations on a State associated with the map component. Here, I guess the data classes are mutable wrappers around the underlying SDK's model, and they inform the Compose runtime when they're updated (like any other State). This feels like the most straightforward implementation, though it makes manipulating the style less compose-ey and more imperative.
Although, I agree, that the more common use case is that data in the data source changes, rather than how that data is displayed, i.e. the style. So, maybe the given example is somewhat of a fringe usage and could just as well be realized with annotations.
That's fair, though I think the problems above apply to both sources and layers (at least for programmatically generated sources, which I think are more likely to change). At least sources are simpler in that order doesn't matter (but should their content be composable? do the underlying SDKs have patterns for sources that change regularly?)
I feel annotations are the most likely to end up as a custom composition, so maybe it's worth hacking up an implementation of that first to get a feel for it.
I've taken a look at how to build a custom composition. The way a ComposeNode
works, we'd have a factory for an object representing some part of the style (layer, source, etc) and an update function to apply the composable's parameters to it (actually very similar to how integrating non-compose views into compose works, or compose into swiftui, etc).
This does mean that our Source or Layer objects are mutable, long-lived, and hold a reference to the underlying SDK's equivalent object (to apply properties). So as you thought, keeping data classes around for layers probably isn't viable. But we should be able to just change one property, and compose will set that one property on the underlying source/layer.
We'll want to make sure sources and layers recompose when the base style changes, and layers when their source changes. Also how the applier would actually insert layers into the base style I'm not sure. Will play with it; gotta get a feel for the compose runtime.
Useful reading: https://arunkumar.dev/jetpack-compose-for-non-ui-tree-construction-and-code-generation/
Got it working (and pushed), here's what the API looks like now:
MaplibreMap(..) {
GeoJsonSource(url = Res.getUri("files/geojson/amtrak/routes.geojson"), tolerance = 0.001f)
.let { amtrakRoutes ->
StackBelow("boundary_3") {
LineLayer(
sourceId = amtrakRoutes,
lineColor = const(Color.White),
lineWidth = interpolate(exponential(const(2f)), zoom(), 0 to const(2f), 10 to const(4f)),
)
LineLayer(
sourceId = amtrakRoutes,
lineColor = const(color),
lineWidth = interpolate(exponential(const(2f)), zoom(), 0 to const(1f), 10 to const(2f)),
)
}
}
}
And I've validated I can animate layer props efficiently:
Wow, this is getting intense! Thanks for the link, I will be sure to read up on it! To be honest, it's becoming difficult for me to follow on the technicalities as I would consider myself to be somewhat of a novice user of Compose and also never worked with other declarative UI frameworks before. So the input I can give other than what would be convenient from the API's point of view is limited (currently).
Anyway, just want to say that I find your last comment really impressive ๐. So, LineLayer
is a composable function now?
(At least as a non-native speaker, it wasn't clear to me from the name which layer is above which layer in the StackBelow
block, cause "Stack" confused me.)
At least sources are simpler in that order doesn't matter (but should their content be composable? do the underlying SDKs have patterns for sources that change regularly?)
Well, actually, the underlying SDKs do not offer APIs to efficiently change parts of a source. It is only possible to replace all data in a source completely by re-setting the whole GeoJSON. Which, I guess, makes implementation in compose all the more easier, because then the whole source can be re-set on a recomposition when there is any change in data (i.e. any change in data is treated as that the data changed as a whole). It is truly a shame that MapLibre does not offer that feature, tangram-es, the library I have been coming from, by the way, also never did.
So, by the way, if you know my app, you see that after e.g. answering a quest, the quest pin associated with that quest vanishes. And there are hundreds and thousands of quest pins. When you change properties of some road in e.g. the surface overlay (second button to the top-right), the color of only the road edited changes. So, how is that done? Indeed, it is done by re-setting the whole data source on each single change. Performance-wise, this seems to be still ~okay. Although, of course, I employed a couple of tricks to make it more performant: Only the data currently in view is actually put into the MapLibre GeoJson data source and this is updated on panning the map. So, the amount of data that has to be re-set when something changes always stays manageable.
So the input I can give other than what would be convenient from the API's point of view is limited (currently).
Honestly, I've found your input as someone experienced with the Maplibre SDK super valuable. I'm a novice to both Compose and to MapLibre. I have some experience as a user of React, but it's always bothered me how little I understood of how these declarative UI frameworks work under the hood, which is part of the impetus for getting into this project. Most of my programming experience lies in infra, backend services, and dev tooling, and I wish I understood these UI frameworks' internals as well as I understand those things ๐
So, LineLayer is a composable function now?
Yup, GeoJsonSource, StackBelow, and LineLayer are all composables. In particular, they're composables in a separate Composition from the UI composition. This composition is (re)created when the map base style is loaded. This composition, internally, manages a tree of MapNodes that has a structure like:
StyleNode
โโโ SourceNode
โโโ LayerNode
โโโ LayerStackNode
โโโ LayerNode
style.addLayerBelow("boundary_3")
.The API composables like GeoJsonSource, StackBelow, and LineLayer are internally emitting a ComposeNode managing one of these MapNodes. When a parameter changes that's mutable on the underlying object (like LineLayer.lineWidth), the corresponding property is set on the underlying object (held by the node). When a parameter changes that's immutable on the underlying object (like LineLayer.source), the ComposeNode itself is recreated (which removes the old node and inserts a new one).
At least as a non-native speaker, it wasn't clear to me from the name which layer is above which layer in the StackBelow block, cause "Stack" confused me.
This is fair, and I'm also not totally happy with the naming here. Composables that emit nodes to the tree conventionally seem to be named as nouns, so maybe that composable should be like LayerStack(anchorBelow = "foo")
with the parameter as the verb? An example with that naming:
val source = GeoJsonSource(..)
// corresponds to style.addLayerAt(layer, index = 0)
BottomLayerStack {
LineLayer(source, ...)
LineLayer(source, ...)
}
// corresponds to style.addLayerBelow(layer, below = "example1")
LayerStack(anchorBelow = "example1") {
LineLayer(source, ...)
LineLayer(source, ...)
}
// corresponds to style.addLayerAbove(layer, above = "example2")
LayerStack(anchorAbove = "example2") {
LineLayer(source, ...)
LineLayer(source, ...)
}
// default is at the top of the map layers
// corresponds to style.addLayer(layer)
LineLayer(source, ..)
LineLayer(source, ..)
So, how is that done? Indeed, it is done by re-setting the whole data source on each single change.
This is interesting. I think for my use cases, I'll generally want to update the whole data source anyway so it's not a big deal. I suppose if a user wanted to efficiently update large feature collections with the current limitations in MapLibre, they'd have to shard the sources and layers on their own? I could see that looking like another tile management layer on top of Maplibre. Feels like a hack but honestly compose might make this easier (maintain an quadtree state of chunked data, for each leaf in the octree emit a source and layer(s), and let the compose runtime handle diffing as the tree changes)
Or maybe:
LayerStack(anchor = Anchor.Below("example1"))
LayerStack(anchor = Anchor.Above("example2))
LayerStack(anchor = Anchor.Bottom)
this feels right to me but would love your thoughts
What about LayerList
? anchor
could be an optional parameter that defaults to Anchor.Bottom
.
So, LineLayer is a composable function now?
I was asking, because then, the advantages of using data classes to copy styles is lost.ยน If we look at some other Compose APIs, there is TextStyle
(which is accepted as a parameter in a Text
). So, consistently to that, maybe LineLayer
could as parameter have a style: LineStyle
. This is also somewhat consistent to the Java API, where per layer we define id, filter, source and then the (style) properties separate from that. Do you think something like this would be viable?
ยน hmm not completely, actually, because in the Compose world, users could then define a @Composable fun RoadLayer() { ... }
which itself calls LineLayer
with certain parameters. The user can create something like an inheritance hierarchy with that.
I was hoping to keep the data classes around for other use cases (like scripts working with style JSON) if they were useful in the compose map but it seems they're not.
ยน hmm not completely, actually, because in the Compose world, users could then define a @Composable fun RoadLayer() { ... } which itself calls LineLayer with certain parameters. The user can create something like an inheritance hierarchy with that.
Yeah, for re-using style code within the compose map, this is the way to go, like how in Compose UI, folks use the primitives like Text, Button, and Box to construct higher level UI. A RoadLayer
could be two LineLayers, one for the road outline, one for the road fill, and take a base lineWidth and colors as parameters.
TextStyle exists as a single container to pass around a group of tightly coupled parameters, and it's accepted by not just Text, but other components that wrap Text but want to allow the caller to style the Text (like buttons, list items, and other views). As the rest of the layer spec is added to this compose API, if similar groups of tightly coupled parameters emerge, this library should prob do the same.
A RoadLayer could be two LineLayers, one for the road outline, one for the road fill, and take a base lineWidth and colors as parameters.
(That wouldn't work though, because the road outlines of all road types need to be below the road fills of all road types ๐)
What about LayerList? anchor could be an optional parameter that defaults to Anchor.Bottom.
As I think more, I'm trending towards AnchoredLayers
as it more directly conveys the purpose of the component. Not sure how I feel about the parameter being optional, because we already have the option of no anchor (layers directly under the style node instead of in this nested node)
Stack and List both evoke data structures, which doesn't quite feel right.
(That wouldn't work though, because the road outlines of all road types need to be below the road fills of all road types ๐)
Oh, you're right. And this should probably be a use case that informs how we place layers.
I think something for informing the applier how to anchor layers is the right direction, but just defining below/above a layer from the base map isn't enough. I have some ideas forming, but will probably need to support a more substantive set of layers and properties before I can experiment with them effectively. Thinking something like defining relative priority within the same anchored set, and then to avoid conflicts, deduplicating anchored sets somehow internally so two identically defined anchored sets collaborate on ordering.
In the meantime, for a simple case like road outline + fill, one could hack it with two anchors on opposite sides of the same point in the base style. Like if a base style has borders below labels, one could inject road outline above borders and road fill below labels. So outlines would always be below fill, while encapsulated in a reusable RoadLayer. Definitely not a true solution, and I can already think of ways it'll fall apart in more complex cases, but it's something for the moment.
I wouldn't think about the outline+fill stuff too much. This is not for the API to solve.
(The few) people who want to create their whole style in the DSL will just have a long list of layers anchored to... the root, and all the road outlines will be further up in that list and all the road fills further down, that's it. (Remember, there are also bridges and tunnels to deal with). The majority of people will just want their data in layers defined in the DSL to be always on top of everything else, so no need to anchor the outline somewhere in the middle and the fill somewhere else, they'll be at the bottom of the list (i.e. rendered in front). And the few people who really want to intermix data in layers defined in the DSL in the base style (such as me), can still just do so, the "highlighted-road-outline" layer will then just need to be anchored above e.g. "motorway-outline" layer and the "highlighted-road-fill" layer goes above "motorway-fill", no problem.
This is fair. I think it's a use case I want to solve for, because I myself would find it useful. That said, I don't think it needs to be solved this early in the development of this library, and perhaps not at all in the internals (I could see a potential solution built on top of the layer primitives I provide now).
There's other more important questions to answer before that, like:
The current implementation's answers to these are mostly just whatever was convenient to get it working, and not necessarily the right long term approach. All of these have implications for performance, usability, and potential user side footguns in the library, on which I'd like to write something in more detail sometime next week.
I've written some thoughts on the above in Slack: https://osmus.slack.com/archives/C06U5MM097B/p1731313912938259
Going to tackle the actual changes described in that post later.
Also pushed a new commit with support for all the documented properties of line layers and circle layers. Ran into a minor issue with the line-pattern property on iOS; most properties accept a null value to reset the property, but this one does not, nor do I know what a sensible default value would be for a ResolvedImage expression.
This is the first time I see MapLibre being used in a Kotlin Multiplatform project. Very cool!
Could you perhaps add a license to this repo?