Open nreese opened 2 years ago
Can you share the style that you used for the example pictures above? The main problem I see here is that you can't describe it in a style file. One of the good things about the style is that it is declarative. Having said that, it's not a must, just like addProtocol is breaking the declarative nature of the style. My only question here is elaborating on the use cases as I tend to say that in most cases this can be solved by the current code...?
Assuming this is really needed (judging by the upstream issue https://github.com/mapbox/mapbox-gl-js/issues/9462), there are several paths to implement this:
text-custom-transform
expression similar to text-transform, so it would have far smaller API to maintain. It would simply take the name of the registered custom transformation, a text string, and any extra parameters from the style (params could be computed on the fly by MapLibre if needed).
Usage: "text-custom-transform": "to-decimal"
or "text-custom-transform": ["to-decimal", "arg1", "arg2", ...]
-- where to-decimal
is a registered function that accepts the content of the text-field
value and can accept any optional arguments.["get", "name_en"]
and ignore all else. Can you share the style that you used for the example pictures above?
I would like to be able to register an expression extension so instead of
"layout": {
"text-field": [
// text-field value is computed: coalesce(get("bytes"), "")
"coalesce", [ "get", "bytes" ], ""
],
"text-size": 14
}
I could do something like the below, where kibana-text-transform
is an extension I have registered with maplibre and it takes a value, runs a callback function and returns a value.
"layout": {
"text-field": [
// text-field value is computed: my_custom(coalesce(get("bytes"), ""))
"my_custom": [
"coalesce", [ "get", "bytes" ], ""
],
],
"text-size": 14
}
@nreese thx for the example. I agree custom expression functions would be the most flexible solution. It might also put too much maintenance burden compared to a dedicated text transformation function. Compare the API requirement for an expression extension that can do anything (i.e. has execution context and supports all data types as inputs and outputs), vs a text-only function with no context. I will let @HarelM speak about the API complexities/burderns, as I might be totally wrong here :)
My usage example with a custom text function:
"layout": {
"text-field": [
// text_custom_transform("my_custom", coalesce(get("bytes"), ""))
// note that the second value could be "{name_en}" instead of an array
"text-custom-transform": [
"my-custom",
["coalesce", [ "get", "bytes" ], ""],
]
],
"text-size": 14
}
There is this concept of runtime styling versus the style.json approach. I don't quite understand it but maybe we should be careful to not mix the two. Can someone comment on the declarative thing? Sorry if I am a bit confused...
The point is that if we introduce this feature, the style file alone will not tell you how the map looks like. You need style plus callback code. This is not necessarily bad, but we have to have a plan how this would look like on the native project...
@nreese can you maybe share some more use case examples? I tend to say that we should rather expand the expressions than introduce a more general callback registration functionality. The cool thing about the current way of things is that you can copy-paste style sections. This works because they are sort of self-describing.
For your example above. Turning 2536
into 2.5KB
can be achieved with something like this: ['concat', ['to-string', ['/', ['ceil', ['/', 2536, 100]], 10]], 'KB']
It is not possible to display formatted values with 'vector' source.
I did not know that. Always thought that you can do something like ['get', 'name_en']
on a vector source. Or maybe I am misunderstanding?
Per @1ec5 there was also a relevant discussion on the native side in https://github.com/mapbox/mapbox-gl-native/issues/7860
can you maybe share some more use case examples
For my specific use case in Kibana, there are many formatters so it would not be trivial to solve them all with expression code. There are durations, percentages, static look-up tables, and more.
For the specific example of displaying a value as bytes, ['concat', ['to-string', ['/', ['ceil', ['/', 2536, 100]], 10]], 'KB']
would not work as values need to be expressed as MB or GB or whatever suffix depending on the size. Coding all of this in an expression is non-trivial and error prone.
I did not know that. Always thought that you can do something like ['get', 'name_en'] on a vector source. Or maybe I am misunderstanding?
@wipfli This was addressed as a "Design Alternatives"
"Custom expression logic could be avoided by performing the logic at vector tile generation. However, this is not always possible. In the use case above, the vector tiles are generated from the data store and logic for formatting labels is not available at the data store abstraction level."
The short answer being, it is not always possible to add formatted data to the tile generation process.
In yesterday's technical steering committee meeting, the following came up: Americana's creative usage of the missing style image was mentioned by @mojodna as a way to get run-time callbacks from the style.
Americana's creative usage of the missing style image was mentioned by @mojodna as a way to get run-time callbacks from the style.
The style image callback runs well after style evaluation: https://github.com/ZeLonewolf/openstreetmap-americana/pull/243#issuecomment-1104269627. By contrast, anything done as part of evaluating an expression has to run in a Web worker (multiple Web workers), which complicates things a bit. Some languages like Swift make it possible to prevent unintended capturing of variables outside of scope, but I don’t know if that’s the case for TypeScript.
Seems like this got a bit stalled. Another use case where I think we do not have a good answer is a mix of static and dynamic data. For example, let's say there are millions of data points with metadata. Each point can be styled based to that metadata, and have some popup which shows that metadata on a click/mouseover. But now let's say there is some dynamic "current state" -- something that rapidly changes each second. I could subscribe a stream of (<feature ID>, <status>)
feed that updates some data storage in memory, and I could animate those points on the screen, e.g. show the point as green or red.
Ideally, I would love to just add a styling rule like get_dynamic_state(feature_id)
that executes my own function, which performs a lookup in the dynamic state and tells the styling system which color to use with the current feature.
Doesn't setFeatureState solves this?
@HarelM sorry for not replying earlier - yes, the feature state concept fully solves it, thanks!
Hi. Joining the conversation as I'm facing a use-case related to this feature. Documented here #2077
Basically, I'm trying to color some symbol icons based on the distance relative to a GeoJSON point.
The distance
operator defined in the spec would be a perfect fit. Unfortunately, neither mapbox or maplibre implements this in gl.js There was a great draft PR which basically implements it perfectly in mapbox; but it has been blocked in draft for almost 2 years because of a significant bundle size increase.
As stated in #2077; "significant bundle size increase" + "already implemented" sounds for a perfect "plugin" fit.
I think this is the perfect use-case because its both :
In other words, with an expression "extension" mecanism; this would be roughly implementable in 1 hour — and would even be style-spec compliant; but in the current state of art this is very tough to implement.
I'm not sure how easy it will be to add an expression as a plugin, but if you manage to do it, or even create a PR that will allow to add expression as plugins it can open a world of opportunities.
To understand what's needed for a "distance"-plugin, I have patched a rudimentary implementation of distance
expression, which only computes the distance between point features, into the current version of maplibre-gl.js
. You can try it here.
As you can see, the implementation of distance
needs access to the EvaluationContext
ctx, more precisely the members geometryType()
and geometry()
to get the feature's pixel coordinates, and to member canonical
of type ICanonicalTileID
to convert the pixel coordinates to LonLat.
It would be quite easy to naively add a hook Map.addExpressionType(...)
to dynamically add expression types to the library, but this would need to expose the EvaluationContext
and member types, which I think is a bad idea.
There already is a geometry-type
expression (spec), which delivers the ctx.feature.type as string.
We can easily add a expression geometry
to the library, which returns the feature's geometry as an object, already converted from pixel coordinates to geographic coordinates.
With "native" I mean the function will receive evaluated values of JS base type as arguments and has no access to the enviroment or other parts of the library at all.
Map.addNativeExpressionType( 'mean',
'number', ['number', 'number'],
(a, b) => return (a+b) / 2
);
Map.addNativeExpressionType( 'string-index-of',
'number', ['string', 'string'],
(needle, haystack) => return haystack.indexOf(needle)
);
Map.addNativeExpressionType( 'string-char',
'string', ['string', 'number'],
(s, index) => return s.charAt(index)
);
This way we can implement the distance computation as a native expression type:
Map.addNativeExpressionType( 'explicit-distance',
'number', ['string', 'object', object],
(geoType, geo, referenceFeature) => {
//some math
return distance;
}
);
and use it:
['explicit-distance',
['geometry-type'],
['geometry'],
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [2, 40]
}
}
]
Unfortunately this way we cannot provide the distance
expression type exactly as specified in the documentation, because the distance
needs implicit access to the context.
To fix this, we could add "macro expressions", which would not be added to the list of available expressions, but used while compiling an expression; may be this way (I'm not sure about the syntax):
Map.addMacroExpressionType( 'distance', 1 /*one arg*/,
[ 'explicit-distance', ['geometry-type'], ['geometry'], [1/*placeholder for arg 1*/] ]
Resulting in the expression
['distance',
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [2, 40]
}
}
]
being replaced by and compiled as
['explicit-distance',
['geometry-type'],
['geometry'],
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [2, 40]
}
}
]
Heureka.
Addendum: the literal object {type: ...} has to be written as ['literal', {type: ...}]
Can you better clarify the difference between the two APIs? From my point of view there should be a single API registerCustomExpression or something similar. Why is gaining access to the evaluation context is problematic?
Why is gaining access to the evaluation context is problematic?
Because you have to use the MapLibre library source while creating the plugin and use version numbers to verify the compatibility between a plugin and the library. Which is cumbersome and overkill if the only thing you want is to define a ['replace-all'].
Otherwise, you would have to freeze the EvaluationContext class, which is something I certainly do not want.
Can you better clarify the difference between the two APIs?
The calls have nothing in common and have completely different signatures. In fact, only the first one is important for the very registration of custom expression types.
I made up the second one only to allow implementing the distance
expression exactly as specified in the documentation without having to give native custom expressions access to library elements not exposed to the public.
Currently, I see no other usage. In fact, if it were only for me, I would permanently add the distance
expression to the library with a rudimentary implementation, which you could replace by registering a custom distance-implementation
.
Unfortunately, I have to take back the “easily”.
The expressions are evaluated both in the UI process and in the Worker processes; for this purpose, the buildin expressions are marshalled and included in the Worker blob-uri. If after creating the Worker you want to add an expression, you have to both store it in the UI process and send a serialization of it as a message to the workers, where a message handler would add it to the buildin expressions.
For the same reason, custom expression also have no access to static variables, i.e. lookup tables; the possibilities are therefore very limited. Maybe they are not worth the effort at all.
Nevertheless, to have a basis for further discussions, I've patched the current maplibre-gl.js to allow custom expressions as maplibre-gl+distance+custom-expressions-v1.js. It does not use the Worker message system, but I have set the library initialization on hold, and you have to explicitly initialize the library after you have defined your custom expressions.
You can find a usage example as jsfiddle.
This hack is for obvious reasons not intended as a base for a real implementation.
I agree a plugin system such as one that is discussed here should have a good interface that would change rarely, like the Cordova/Ionic plugin interface/system, which holds nicely for a long time now.
In theory one could wrap the context in another class to facilitate for the relevant plugin capabilities.
I like a general registerExpression
method to facilitate for as many options as possible.
I'll be happy to review a design if someone would like to push this forward.
The current implementation of CompoundExpression.register()
in src/expression/compound_expression.ts
allows it to be called only once; a second call would destroy the former registration.
I suggest a small (obvious) patch, which makes the method callable multiple time.
With this change, you get the internal hook to design an interface around.
***************
*** 31,37 ****
_evaluate: Evaluate;
args: Array<Expression>;
! static definitions: {[_: string]: Definition};
constructor(name: string, type: Type, evaluate: Evaluate, args: Array<Expression>) {
this.name = name;
--- 31,37 ----
_evaluate: Evaluate;
args: Array<Expression>;
! static definitions: {[_: string]: Definition} = {};
constructor(name: string, type: Type, evaluate: Evaluate, args: Array<Expression>) {
this.name = name;
***************
*** 146,153 ****
registry: ExpressionRegistry,
definitions: {[_: string]: Definition}
) {
- CompoundExpression.definitions = definitions;
for (const name in definitions) {
registry[name] = CompoundExpression;
}
}
--- 146,153 ----
registry: ExpressionRegistry,
definitions: {[_: string]: Definition}
) {
for (const name in definitions) {
+ CompoundExpression.definitions[name] = definitions[name];
registry[name] = CompoundExpression;
}
}
Motivation
I work on a mapping application that allows users to configure formatters to customize how values are displayed. These formatters are javascript functions that take a value and return a string. I would like to be able to use these formatters in a maplibre expression to format values displayed as labels.
Below are 2 images. The first uses 'vector' source and displays the raw value. The second uses 'geojson' source and adds a new property to each feature that contains the value transformed into a human readable byte string. It is not possible to display formatted values with 'vector' source. Allowing expression extension points would close this feature gap and allow custom label formatting with 'vector' source.
Here are some other example of formatting functions
Beyond these, there are limitless possibilities for formatting values. Custom expressions provides an escape hatch to allow users easily fill any functionality gaps.
There are many additional use cases and further discussion can be found at https://github.com/mapbox/mapbox-gl-js/issues/9462
Design Alternatives
Custom expression logic could be avoided by performing the logic at vector tile generation. However, this is not always possible. In the use case above, the vector tiles are generated from the data store and logic for formatting labels is not available at the data store abstraction level.
Users could also fall back to 'geojson' sources and add new properties to features as a work around. However, falling back to 'geojson' sources provides a much slower alternative when large data volumes are involved.
Design
Mock-Up
The below mock-up is copied from https://github.com/mapbox/mapbox-gl-js/issues/9462
This is modelled after how expression functions are defined in definitions/index.js.
Note that its using the type names defined in compound_expression.js